fix: preserve interaction replies across chat recovery
This commit is contained in:
+84
-6
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user