fix: preserve interaction replies across chat recovery

This commit is contained in:
2026-10-29 17:53:33 +08:00
parent fbe273e90a
commit 52681a8451
38 changed files with 1623 additions and 314 deletions
+84 -6
View File
@@ -164,7 +164,7 @@ export class OpencodeRuntimeAdapter {
return response.output as { id: string; direct: boolean; sandboxed: boolean; workspaceRoot?: string }[] | undefined;
};
let tools = await readTools();
const required = [...LOCAL_QUERY_TOOL_IDS, ...(config.TJWATER_MCP_ENABLED ? PRODUCT_MCP_TOOL_IDS : []), "shell", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
const required = [...LOCAL_QUERY_TOOL_IDS, ...(config.TJWATER_MCP_ENABLED ? PRODUCT_MCP_TOOL_IDS : []), "shell", "ask_user", "final_answer", "present_answer", "locate_features", "zoom_to_map", "show_chart", "view_history", "memory_manager", "skill_manager", "render_scada_analysis", "clear_scada_analysis", "publish_execution_plan", "publish_task_report", "publish_task_progress", "publish_task_presentation", "propose_task_action", "add_task_human_evidence", "request_task_data_access", "todowrite", "send_task_message", "reply_agent_message"];
let missing = required.filter((name) => !tools?.some((tool) => tool.id === name && tool.direct));
const catalogDeadline = Date.now() + 10_000;
// Native MCP connection/catalog publication can finish after plugin setup.
@@ -362,10 +362,10 @@ export class OpencodeRuntimeAdapter {
description: "调查协作消息(非用户直接指令)", metadata, delivery: "steer", resume: false });
}
async enqueueUserInput(sessionId: string, input: ResponseInput) {
async enqueueUserInput(sessionId: string, input: ResponseInput, options: { resume?: boolean } = {}) {
const client = await this.ensureClient();
await client.session.prompt({ sessionID: sessionId, id: input.id, text: input.text,
delivery: input.delivery, resume: true, metadata: { source: "tjwater_user_input" } });
delivery: input.delivery, resume: options.resume ?? true, metadata: { source: "tjwater_user_input" } });
}
async listPendingUserInputs(sessionId: string): Promise<ResponseInput[]> {
@@ -416,7 +416,7 @@ export class OpencodeRuntimeAdapter {
return { ...item, status: "cancelled" };
}
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride) {
async prompt(sessionId: string, text: string, model?: RuntimeModelOverride, messageId?: string) {
const client = await this.ensureClient();
const startedAt = Date.now();
logDevelopmentDebug(
@@ -433,7 +433,7 @@ export class OpencodeRuntimeAdapter {
const agent = context?.purpose === "task_execution" && context.taskId && context.projectId ? TASK_AGENT : "instruction";
await client.session.switchAgent({ sessionID: sessionId, agent });
if (model) await client.session.switchModel({ sessionID: sessionId, model: { providerID: model.providerID, id: model.modelID } });
await client.session.prompt({ sessionID: sessionId, text });
await client.session.prompt({ sessionID: sessionId, text, ...(messageId ? { id: messageId, delivery: "steer" as const, metadata: { source: "tjwater_host_answer" } } : {}) });
logDevelopmentDebug(
"opencode session.prompt returned",
{
@@ -443,12 +443,79 @@ export class OpencodeRuntimeAdapter {
);
}
private interactionOperations?: Map<string, Promise<unknown>>;
private async serializeInteraction<T>(sessionId: string, operation: () => Promise<T>): Promise<T> {
const operations = this.interactionOperations ??= new Map();
const pending = (operations.get(sessionId) ?? Promise.resolve()).catch(() => undefined).then(operation);
operations.set(sessionId, pending);
try { return await pending; }
finally { if (operations.get(sessionId) === pending) operations.delete(sessionId); }
}
async stopInteractionInput(sessionId: string, messageId: string): Promise<"delivered" | "cancelled"> {
return this.serializeInteraction(sessionId, async () => {
const client = await this.ensureClient();
const state = await this.agentMessageState(sessionId, messageId);
if (state === "delivered") return "delivered";
if (state === "enqueued") await client.session.inbox.cancel({ sessionID: sessionId, inboxID: messageId });
return await this.agentMessageState(sessionId, messageId) === "delivered" ? "delivered" : "cancelled";
});
}
async continueInteraction(sessionId: string, messageId: string, text: string, model?: RuntimeModelOverride, signal?: AbortSignal): Promise<"started" | "observing" | "completed"> {
return this.serializeInteraction(sessionId, async () => {
signal?.throwIfAborted();
const client = await this.ensureClient();
const state = await this.agentMessageState(sessionId, messageId);
const active = Boolean((await client.session.active())[sessionId]);
signal?.throwIfAborted();
if (state === "missing") {
if (active) throw new Error("Previous native turn is still active");
await this.prompt(sessionId, text, model, messageId);
return "started";
}
if (active) return "observing";
if (state === "enqueued") {
// Resume an admitted steering input without constructing another message.
await client.session.interrupt({ sessionID: sessionId, resume: true });
return "observing";
}
return "completed";
});
}
async messages(sessionId: string, limit = 20) {
const client = await this.ensureClient();
const messages = await client.message.list({ sessionID: sessionId, limit, order: "desc" });
return messages.data.reverse().map((message) => normalizeRuntimeMessage(message, sessionId));
}
async readInteractionResult(sessionId: string, messageId: string): Promise<string> {
const client = await this.ensureClient();
const messages = (await client.message.list({ sessionID: sessionId, limit: 100, order: "desc" })).data.slice().reverse();
const start = messages.findIndex((message) => message.id === messageId && message.type === "user");
if (start < 0) throw new Error("Saved answer result is outside the recovery window");
const tail = messages.slice(start + 1);
const nextUser = tail.findIndex((message) => message.type === "user");
const related = nextUser < 0 ? tail : tail.slice(0, nextUser);
for (const message of related.slice().reverse()) {
if (message.type !== "assistant" || message.error) continue;
for (const content of message.content.slice().reverse()) {
if (content.type === "tool" && content.name === "final_answer" && content.state.status === "completed") {
for (const item of content.state.content) {
if (item.type !== "text") continue;
try { const parsed: unknown = JSON.parse(item.text); if (parsed && typeof parsed === "object" && "answer" in parsed && typeof parsed.answer === "string") return parsed.answer; } catch { /* A non-JSON tool result is not a final answer. */ }
}
}
}
if (message.time.completed && message.finish === "stop") {
const text = message.content.filter((part) => part.type === "text").map((part) => part.text).join("\n");
if (text) return text;
}
}
throw new Error("Answer was consumed but no completed response could be verified; do not resend it");
}
async revertMessage(sessionId: string, messageId: string) {
const client = await this.ensureClient();
return client.session.revert.stage({ sessionID: sessionId, messageID: messageId, files: false });
@@ -488,7 +555,7 @@ export class OpencodeRuntimeAdapter {
return client.session.interrupt({ sessionID: sessionId });
}
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS) {
async waitForSessionIdle(sessionId: string, timeoutMs = config.OPENCODE_TIMEOUT_MS, strict = false) {
const client = await this.ensureClient();
const startedAt = Date.now();
@@ -500,6 +567,7 @@ export class OpencodeRuntimeAdapter {
await delay(100);
}
if (strict) throw new Error("Native session did not reach the waiting boundary");
logger.warn(
{ sessionId, timeoutMs },
"timed out waiting for opencode session to become idle",
@@ -554,6 +622,16 @@ export class OpencodeRuntimeAdapter {
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) });
}
async getQuestionStatus(options: { requestId: string; sessionId: string }): Promise<"pending" | "answered" | "cancelled" | "missing"> {
const client = await this.ensureClient();
try { return (await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId })).state.status; }
catch (error) {
const tag = error && typeof error === "object" && "_tag" in error ? error._tag : undefined;
if (tag === "FormNotFoundError") return "missing";
throw error;
}
}
async rejectQuestion(options: { requestId: string; sessionId?: string }) {
if (!options.sessionId) throw new Error("OpenCode 2 form cancellation requires sessionId");
const client = await this.ensureClient();