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
@@ -0,0 +1,30 @@
import type { InteractionRequest } from "../chat/interaction-request-contract.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
import { InteractionRequestError, type InteractionRequestStore } from "./interaction-request-store.js";
export async function reconcileInteractionDelivery(
context: SessionWorkspaceContext,
record: InteractionRequest,
store: InteractionRequestStore,
runtime?: Pick<OpencodeRuntimeAdapter, "agentMessageState" | "readInteractionResult">,
running = false
): Promise<InteractionRequest> {
if (running || !runtime || !record.delivery || !["answered", "skipped"].includes(record.status)
|| ["delivered", "cancelled"].includes(record.delivery.status)) return record;
try {
if (await runtime.agentMessageState(context.sessionId, record.delivery.native_message_id) !== "delivered") return record;
// Receipt recovery requires completed output, not merely a consumed input or partial text.
if (!(await runtime.readInteractionResult(context.sessionId, record.delivery.native_message_id)).trim()) return record;
} catch { return record; }
try {
return await store.updateDelivery(context, record.interaction_id, {
expected_revision: record.delivery.revision, status: "delivered", response_id: record.delivery.response_id
});
} catch (error) {
if (error instanceof InteractionRequestError && ["revision_conflict", "delivery_resolved"].includes(error.code)) {
return await store.get(context, record.interaction_id) ?? record;
}
throw error;
}
}
+154
View File
@@ -0,0 +1,154 @@
import { createHash, randomUUID } from "node:crypto";
import { z } from "zod";
import { readJsonFile } from "../utils/fileStore.js";
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
import { SessionContextMismatchError } from "./metadataStore.js";
import { SessionWorkspaceStore } from "./workspace-store.js";
import { INTERACTION_VERSION, renderInteractionAnswer, interactionRequestSchema, interactionAnswersSchema, interactionBlocksTurn, interactionDraftInputSchema, interactionMutationInputSchema, interactionQuestionsSchema, interactionReplyInputSchema, type InteractionAnswer, type InteractionDelivery, type InteractionRequest, type InteractionResolvedAnswer } from "../chat/interaction-request-contract.js";
type Receipt = { operation: string; request_id: string; digest: string; result: InteractionRequest };
type State = { version: typeof INTERACTION_VERSION; requests: InteractionRequest[]; receipts: Receipt[] };
const filename = "interaction-requests.json";
export class InteractionRequestError extends Error {
constructor(readonly code: string, readonly status: number = 409, readonly current?: InteractionRequest) { super(code); }
}
const initial = (): State => ({ version: INTERACTION_VERSION, requests: [], receipts: [] });
const parseState = (value: unknown): State => {
if (value === undefined || value === null) return initial();
return z.object({ version: z.literal(INTERACTION_VERSION), requests: z.array(interactionRequestSchema).max(1000), receipts: z.array(z.object({ operation: z.string(), request_id: z.string().min(1).max(128), digest: z.string(), result: interactionRequestSchema }).strict()) }).strict().parse(value);
};
const copy = <T>(value: T): T => structuredClone(value);
const validateAnswers = (record: InteractionRequest, answers: InteractionAnswer[], complete: boolean): InteractionResolvedAnswer[] => {
const parsed = interactionAnswersSchema.parse(answers);
const result = parsed.map((answer) => {
const q = record.questions.find((q) => q.question_id === answer.question_id);
if (!q || (!q.allow_custom && answer.custom_text.length > 0) || (q.mode === "single" && answer.selected_option_ids.length > 1)) throw new InteractionRequestError("invalid_answer", 400);
const selected_options = answer.selected_option_ids.map((id) => {
const option = q.options.find((o) => o.id === id);
if (!option) throw new InteractionRequestError("invalid_option", 400);
return option;
});
return { ...answer, selected_options };
});
if (complete && record.questions.some((q) => {
const a = result.find((a) => a.question_id === q.question_id);
return !a || (q.required && !a.selected_option_ids.length && !a.custom_text.trim());
})) throw new InteractionRequestError("incomplete_answer", 400);
if (Buffer.byteLength(JSON.stringify(result)) > 16384 || result.reduce((n, a) => n + a.custom_text.length + a.selected_options.reduce((m, o) => m + o.label.length, 0), 0) > 10000) throw new InteractionRequestError("answer_too_large", 400);
return result;
};
export class InteractionRequestStore {
constructor(readonly workspaceStore: SessionWorkspaceStore) {}
private async read(context: SessionWorkspaceContext) {
if (!await this.workspaceStore.validate(context, filename)) throw new SessionContextMismatchError();
return parseState(await readJsonFile(this.workspaceStore.path(context, filename)));
}
private async mutate<T>(context: SessionWorkspaceContext, operation: (state: State) => T): Promise<T> {
return this.workspaceStore.updateRecord(context, filename, async ({ current }) => {
const state = parseState(current);
const result = operation(state);
state.receipts = state.receipts.slice(-32);
while (state.receipts.length && Buffer.byteLength(JSON.stringify(state.receipts)) > 128 * 1024) state.receipts.shift();
if (state.requests.length > 1000 || Buffer.byteLength(JSON.stringify(state, null, 2)) > 2 * 1024 * 1024) throw new InteractionRequestError("interaction_capacity_exceeded", 413);
return { result: copy(result), value: state };
});
}
async get(context: SessionWorkspaceContext, id: string) { return copy((await this.read(context)).requests.find((r) => r.interaction_id === id) ?? null); }
async forMessages(context: SessionWorkspaceContext, messageIds: ReadonlySet<string>) {
if (!messageIds.size) return [];
return copy((await this.read(context)).requests.filter((record) => record.delivery && messageIds.has(record.delivery.native_message_id)));
}
async list(context: SessionWorkspaceContext, options: { limit?: number; cursor?: string } = {}) {
const limit = z.number().int().min(1).max(50).parse(options.limit ?? 20);
const requests = [...(await this.read(context)).requests].reverse();
const start = options.cursor ? requests.findIndex((r) => r.interaction_id === options.cursor) + 1 : 0;
if (options.cursor && !start) throw new InteractionRequestError("invalid_cursor", 400);
const items = requests.slice(start, start + limit);
return copy({ items, next_cursor: start + limit < requests.length ? items.at(-1)?.interaction_id : undefined });
}
async getBlocking(context: SessionWorkspaceContext) { return copy((await this.read(context)).requests.find(interactionBlocksTurn) ?? null); }
async getForTurn(context: SessionWorkspaceContext, turnId: string) { return copy((await this.read(context)).requests.find((record) => record.turn_id === turnId) ?? null); }
async create(context: SessionWorkspaceContext, input: { message_id: string; turn_id: string; call_id: string; questions: unknown }) {
const parsed = z.object({ message_id: z.string().min(1).max(128), turn_id: z.string().min(1).max(128), call_id: z.string().min(1).max(128), questions: interactionQuestionsSchema }).strict().parse(input);
return this.mutate(context, (state) => {
const existing = state.requests.find((r) => r.turn_id === parsed.turn_id && r.call_id === parsed.call_id);
if (existing) {
if (JSON.stringify(existing.questions) !== JSON.stringify(parsed.questions) || existing.message_id !== parsed.message_id) throw new InteractionRequestError("idempotency_mismatch");
return existing;
}
if (state.requests.some(interactionBlocksTurn)) throw new InteractionRequestError("interaction_unresolved");
const record: InteractionRequest = { version: INTERACTION_VERSION, interaction_id: randomUUID(), ...parsed, status: "pending", revision: 0, draft_revision: 0, draft: [], created_at: new Date().toISOString() };
state.requests.push(record);
return record;
});
}
private async apply(context: SessionWorkspaceContext, id: string, operation: string, input: unknown, draft: boolean, change: (record: InteractionRequest, input: Record<string, unknown>) => void) {
const parsed = (draft ? interactionDraftInputSchema : operation === "reply" ? interactionReplyInputSchema : interactionMutationInputSchema).parse(input);
return this.mutate(context, (state) => {
const digest = createHash("sha256").update(JSON.stringify(parsed)).digest("hex");
const previous = state.receipts.find((r) => r.request_id === parsed.request_id);
if (previous) {
if (previous.operation !== `${id}:${operation}` || previous.digest !== digest) throw new InteractionRequestError("idempotency_mismatch");
return state.requests.find((r) => r.interaction_id === id) ?? previous.result;
}
const record = state.requests.find((r) => r.interaction_id === id);
if (!record) throw new InteractionRequestError("interaction_not_found", 404);
if (record.status !== "pending") throw new InteractionRequestError("interaction_resolved", 409, copy(record));
const expected = "expected_draft_revision" in parsed ? parsed.expected_draft_revision : parsed.expected_revision;
if (expected !== (draft ? record.draft_revision : record.revision)) throw new InteractionRequestError("revision_conflict", 409, copy(record));
change(record, parsed);
if (draft) record.draft_revision++; else { record.revision++; record.resolved_at = new Date().toISOString(); }
state.receipts.push({ operation: `${id}:${operation}`, request_id: parsed.request_id, digest, result: copy(record) });
return record;
});
}
updateDraft(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "draft", input, true, (r, p) => { validateAnswers(r, p.answers as InteractionAnswer[], false); r.draft = p.answers as InteractionAnswer[]; }); }
reply(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "reply", input, false, (r, p) => { r.answers = validateAnswers(r, p.answers as InteractionAnswer[], true); r.status = "answered";
try { renderInteractionAnswer(r); } catch { throw new InteractionRequestError("answer_too_large", 400); }
r.delivery = this.delivery(); }); }
skip(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "skip", input, false, (r) => { if (r.questions.some((q) => q.required)) throw new InteractionRequestError("required_question", 400); r.status = "skipped"; r.answers = []; r.delivery = this.delivery(); }); }
cancel(context: SessionWorkspaceContext, id: string, input: unknown) { return this.apply(context, id, "cancel", input, false, (r) => { r.status = "cancelled"; }); }
private delivery(): InteractionDelivery { return { native_message_id: `msg_${randomUUID().replaceAll("-", "")}`, revision: 0, status: "queued" }; }
async updateDelivery(context: SessionWorkspaceContext, id: string, input: { expected_revision: number; status: InteractionDelivery["status"]; response_id?: string; error_code?: string }) {
const parsed = z.object({ expected_revision: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER), status: z.enum(["queued", "dispatching", "delivered", "blocked", "cancelled"]), response_id: z.string().min(1).max(128).optional(), error_code: z.string().min(1).max(128).optional() }).strict().parse(input);
return this.mutate(context, (state) => {
const r = state.requests.find((r) => r.interaction_id === id);
if (!r?.delivery) throw new InteractionRequestError("delivery_not_found", 404);
if (r.delivery.revision !== parsed.expected_revision) throw new InteractionRequestError("revision_conflict", 409, copy(r));
if (["delivered", "cancelled"].includes(r.delivery.status)) throw new InteractionRequestError("delivery_resolved");
r.delivery = { native_message_id: r.delivery.native_message_id, revision: r.delivery.revision + 1, status: parsed.status, ...(parsed.response_id ? { response_id: parsed.response_id } : {}), ...(parsed.error_code ? { error_code: parsed.error_code } : {}) };
return r;
});
}
async resume(context: SessionWorkspaceContext, id: string, input: unknown) {
const parsed = interactionMutationInputSchema.parse(input);
return this.mutate(context, (state) => {
const digest = createHash("sha256").update(JSON.stringify(parsed)).digest("hex");
const previous = state.receipts.find((r) => r.request_id === parsed.request_id);
if (previous) {
if (previous.operation !== `${id}:resume` || previous.digest !== digest) throw new InteractionRequestError("idempotency_mismatch");
return state.requests.find((r) => r.interaction_id === id) ?? previous.result;
}
const record = state.requests.find((r) => r.interaction_id === id);
if (!record?.delivery) throw new InteractionRequestError("delivery_not_found", 404);
if (record.delivery.revision !== parsed.expected_revision) throw new InteractionRequestError("revision_conflict", 409, copy(record));
state.receipts.push({ operation: `${id}:resume`, request_id: parsed.request_id, digest, result: copy(record) });
return record;
});
}
claimDelivery(context: SessionWorkspaceContext, id: string, input: { expected_revision: number; response_id: string }) {
return this.updateDelivery(context, id, { ...input, status: "dispatching" });
}
async invalidate(context: SessionWorkspaceContext, id: string, input: unknown) {
return this.apply(context, id, "invalidate", input, false, (r) => { r.status = "invalidated"; });
}
async cancelPending(context: SessionWorkspaceContext) {
return this.mutate(context, (state) => {
for (const r of state.requests) {
if (r.status === "pending") { r.status = "cancelled"; r.revision++; r.resolved_at = new Date().toISOString(); }
if (r.delivery && ["queued", "blocked"].includes(r.delivery.status)) { r.delivery.status = "cancelled"; r.delivery.revision++; }
}
return state.requests;
});
}
}
+13
View File
@@ -46,6 +46,19 @@ export class SessionUiStateStore {
return state;
}
async update(context: SessionUiStateContext, change: (state: SessionUiStateRecord) => SessionUiStateRecord) {
if (this.workspaces) return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => {
const state = current as SessionUiStateRecord | null;
if (!state || state.sessionId !== context.sessionId || !Array.isArray(state.messages)) throw new Error("chat state unavailable");
const next = change(state);
if (next.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
return { result: next, value: next };
});
const state = await this.read(context);
if (!state) throw new Error("chat state unavailable");
return this.write(context, change(state));
}
async remove(context: SessionUiStateContext) {
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
await removeFileIfExists(this.filePath(context));
+18 -1
View File
@@ -9,7 +9,7 @@ import {
import { atomicWriteJson, listJsonFiles, readJsonFile } from "../utils/fileStore.js";
import { SessionContextMismatchError, type SessionRecord } from "./metadataStore.js";
type ChatRecordName = "messages.json" | "transcript.json" | "learning.json";
type ChatRecordName = "messages.json" | "transcript.json" | "learning.json" | "interaction-requests.json";
type LegacySources = { metadataDir: string; uiStateDir: string; transcriptDir: string; learningStateDir?: string };
const exists = async (path: string) => {
try { await lstat(path); return true; }
@@ -104,6 +104,23 @@ export class SessionWorkspaceStore {
});
}
/** Read, compare and replace under the same queue as message writes and deletion. */
async updateRecord<T>(
context: SessionWorkspaceContext,
name: ChatRecordName,
operation: (records: { messages: unknown; current: unknown }) => Promise<{ result: T; value?: object }>,
): Promise<T> {
return this.serialize(context, async () => {
if (!await this.validate(context, "messages.json") || !await this.validate(context, name)) throw new SessionContextMismatchError();
const messages = await readJsonFile<unknown>(this.path(context, "messages.json"));
const current = await readJsonFile<unknown>(this.path(context, name));
const update = await operation({ messages, current });
if (!await this.validate(context, "messages.json") || !await this.validate(context, name)) throw new SessionContextMismatchError();
if (update.value !== undefined) await atomicWriteJson(this.path(context, name), update.value as Record<string, unknown>, 0o600);
return update.result;
});
}
async markDeleted(context: SessionWorkspaceContext) {
return this.serialize(context, async () => {
const directory = await this.ensure(context, true);