fix: preserve interaction replies across chat recovery
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
import { expect, it, mock } from "bun:test";
|
||||
import type { InteractionRequest } from "../../src/chat/interaction-request-contract.js";
|
||||
import { reconcileInteractionDelivery } from "../../src/sessions/interaction-delivery-reconciliation.js";
|
||||
import { InteractionRequestError } from "../../src/sessions/interaction-request-store.js";
|
||||
|
||||
const context = { actorKey: "actor", projectKey: "project", sessionId: "session" };
|
||||
const record = { interaction_id: "question", status: "answered", delivery: { native_message_id: "native", revision: 2, status: "blocked", response_id: "response" } } as InteractionRequest;
|
||||
|
||||
it("recovers a lost acknowledgement only from consumed input and completed output without inference", async () => {
|
||||
const recovered = { ...record, delivery: { ...record.delivery!, revision: 3, status: "delivered" as const } };
|
||||
const updateDelivery = mock(async () => recovered);
|
||||
const store = { updateDelivery } as never;
|
||||
const runtime = { agentMessageState: mock(async () => "delivered" as const), readInteractionResult: mock(async () => "complete result") };
|
||||
expect(await reconcileInteractionDelivery(context, record, store, runtime)).toEqual(recovered);
|
||||
expect(updateDelivery).toHaveBeenCalledWith(context, "question", { expected_revision: 2, status: "delivered", response_id: "response" });
|
||||
});
|
||||
|
||||
it("retains waiting, running, incomplete and unavailable native state", async () => {
|
||||
const updateDelivery = mock(async () => record);
|
||||
const store = { updateDelivery } as never;
|
||||
const complete = { agentMessageState: async () => "delivered" as const, readInteractionResult: async () => "result" };
|
||||
for (const runtime of [
|
||||
{ ...complete, agentMessageState: async () => "enqueued" as const },
|
||||
{ ...complete, agentMessageState: async () => "missing" as const },
|
||||
{ ...complete, readInteractionResult: async () => { throw new Error("partial output"); } },
|
||||
{ ...complete, agentMessageState: async () => { throw new Error("offline"); } }
|
||||
]) expect(await reconcileInteractionDelivery(context, record, store, runtime)).toEqual(record);
|
||||
expect(await reconcileInteractionDelivery(context, record, store, complete, true)).toEqual(record);
|
||||
expect(updateDelivery).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps concurrent cancellation instead of overwriting a newer delivery revision", async () => {
|
||||
const cancelled = { ...record, delivery: { ...record.delivery!, revision: 3, status: "cancelled" as const } };
|
||||
const store = { updateDelivery: async () => { throw new InteractionRequestError("revision_conflict"); }, get: async () => cancelled } as never;
|
||||
expect(await reconcileInteractionDelivery(context, record, store, { agentMessageState: async () => "delivered", readInteractionResult: async () => "result" })).toEqual(cancelled);
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
import { InteractionRequestStore } from "../../src/sessions/interaction-request-store.js";
|
||||
import { INTERACTION_VERSION } from "../../src/chat/interaction-request-contract.js";
|
||||
import { createInteractionReplyMessage, projectInteractionReplyMessages } from "../../src/chat/interaction-message-presentation.js";
|
||||
import { renderInteractionAnswer } from "../../src/chat/interaction-request-contract.js";
|
||||
const roots: string[] = [];
|
||||
afterEach(async () => { await Promise.all(roots.splice(0).map((p) => rm(p, { recursive: true, force: true }))); });
|
||||
const context = { sessionId: "session-a", actorKey: "actor-a", projectKey: "project-a" };
|
||||
const questions = [{ question_id: "q", title: "Title", body: "Question", options: [{ id: "a", label: "Answer" }], mode: "single" as const, allow_custom: true, required: true }];
|
||||
async function setup() {
|
||||
const root = await mkdtemp(join(tmpdir(), "interaction-test-")); roots.push(root);
|
||||
const workspace = new SessionWorkspaceStore(root); await workspace.ensure(context);
|
||||
const store = new InteractionRequestStore(workspace);
|
||||
const request = await store.create(context, { message_id: "message", turn_id: "turn", call_id: "call", questions });
|
||||
return { store, request, workspace };
|
||||
}
|
||||
test("draft has independent CAS and atomic reply keeps one stable outbox across retries and reload", async () => {
|
||||
const { store, request, workspace } = await setup();
|
||||
const answers = [{ question_id: "q", selected_option_ids: ["a"], custom_text: "" }];
|
||||
await store.updateDraft(context, request.interaction_id, { version: INTERACTION_VERSION, request_id: "draft", expected_draft_revision: 0, answers });
|
||||
const input = { version: INTERACTION_VERSION, request_id: "reply", expected_revision: 0, answers };
|
||||
const results = await Promise.allSettled([store.reply(context, request.interaction_id, input), store.reply(context, request.interaction_id, { ...input, request_id: "competing" })]);
|
||||
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||
const record = await store.reply(context, request.interaction_id, input);
|
||||
expect(record.delivery?.native_message_id).toMatch(/^msg_[a-f0-9]{32}$/);
|
||||
expect(record.answers?.[0]?.selected_options[0]?.label).toBe("Answer");
|
||||
expect(await new InteractionRequestStore(workspace).get(context, request.interaction_id)).toEqual(record);
|
||||
await expect(store.updateDraft(context, request.interaction_id, { version: INTERACTION_VERSION, request_id: "late", expected_draft_revision: 1, answers })).rejects.toMatchObject({ code: "interaction_resolved" });
|
||||
await expect(store.create(context, { message_id: "new", turn_id: "new", call_id: "new", questions })).rejects.toMatchObject({ code: "interaction_unresolved" });
|
||||
});
|
||||
test("restores accepted replies from scoped delivery IDs without rewriting ordinary text or stored history", async () => {
|
||||
const { store, request, workspace } = await setup();
|
||||
const record = await store.reply(context, request.interaction_id, { version: INTERACTION_VERSION, request_id: "reply", expected_revision: 0,
|
||||
answers: [{ question_id: "q", selected_option_ids: ["a"], custom_text: "现场确认" }] });
|
||||
const reply = createInteractionReplyMessage(record);
|
||||
expect(reply.parts[0]).toEqual({ type: "text", text: "Title: Answer;现场确认" });
|
||||
expect(renderInteractionAnswer(record)).toBe("用户提交的提问回复:\nTitle: Answer;现场确认");
|
||||
const ordinary = { id: "ordinary", role: "user", parts: [{ type: "text", text: "用户提交的提问回复:手动输入" }] };
|
||||
const original = [ordinary, { id: reply.id, role: "user", content: renderInteractionAnswer(record), parts: [
|
||||
{ type: "text", text: renderInteractionAnswer(record) }, { type: "data-task_context", data: { task_id: "task" } }
|
||||
] }, { id: reply.id, role: "assistant", parts: [{ type: "text", text: "原回复" }] }];
|
||||
const before = JSON.stringify(original);
|
||||
const records = await store.forMessages(context, new Set([reply.id]));
|
||||
const projected = projectInteractionReplyMessages(original, records) as typeof original;
|
||||
expect(projected[0]).toBe(ordinary);
|
||||
expect(projected[1]).toMatchObject({ content: "Title: Answer;现场确认", parts: [...reply.parts, original[1]!.parts[1]!] });
|
||||
expect(projected[2]).toBe(original[2]);
|
||||
expect(JSON.stringify(original)).toBe(before);
|
||||
expect(await store.forMessages(context, new Set(["other-message"]))).toEqual([]);
|
||||
const otherContext = { ...context, sessionId: "other-session" };
|
||||
await workspace.ensure(otherContext);
|
||||
expect(await store.forMessages(otherContext, new Set([reply.id]))).toEqual([]);
|
||||
await expect(store.forMessages({ ...context, actorKey: "other-actor" }, new Set([reply.id]))).rejects.toThrow();
|
||||
});
|
||||
test("strict definitions, answers, deduplication, and deletion boundary", async () => {
|
||||
const { store, request, workspace } = await setup();
|
||||
await expect(store.create(context, { message_id: "unanswerable", turn_id: "bad", call_id: "bad", questions: [{ ...questions[0], options: [], allow_custom: false }] })).rejects.toThrow("question must offer an answer input");
|
||||
expect(await store.create(context, { message_id: "message", turn_id: "turn", call_id: "call", questions })).toEqual(request);
|
||||
await expect(store.reply(context, request.interaction_id, { version: INTERACTION_VERSION, request_id: "bad", expected_revision: 0, answers: [{ question_id: "q", selected_option_ids: ["unknown"], custom_text: "" }] })).rejects.toMatchObject({ code: "invalid_option" });
|
||||
await expect(store.skip(context, request.interaction_id, { version: INTERACTION_VERSION, request_id: "skip", expected_revision: 0 })).rejects.toMatchObject({ code: "required_question" });
|
||||
await workspace.markDeleted(context);
|
||||
await expect(store.get(context, request.interaction_id)).rejects.toThrow();
|
||||
await expect(store.cancelPending(context)).rejects.toThrow();
|
||||
});
|
||||
test("delivery CAS preserves native identity and terminal delivery cannot be overwritten", async () => {
|
||||
const { store, request } = await setup();
|
||||
const record = await store.reply(context, request.interaction_id, { version: INTERACTION_VERSION, request_id: "reply", expected_revision: 0, answers: [{ question_id: "q", selected_option_ids: [], custom_text: "custom" }] });
|
||||
const claimed = await store.claimDelivery(context, request.interaction_id, { expected_revision: 0, response_id: "response" });
|
||||
expect(claimed.delivery?.native_message_id).toBe(record.delivery?.native_message_id);
|
||||
await expect(store.claimDelivery(context, request.interaction_id, { expected_revision: 0, response_id: "second" })).rejects.toMatchObject({ code: "revision_conflict" });
|
||||
await store.updateDelivery(context, request.interaction_id, { expected_revision: 1, status: "delivered", response_id: "response" });
|
||||
expect(await store.getBlocking(context)).toBeNull();
|
||||
await expect(store.updateDelivery(context, request.interaction_id, { expected_revision: 2, status: "queued" })).rejects.toMatchObject({ code: "delivery_resolved" });
|
||||
const resume = { version: INTERACTION_VERSION, request_id: "resume", expected_revision: 2 };
|
||||
expect(await store.resume(context, request.interaction_id, resume)).toEqual(await store.resume(context, request.interaction_id, resume));
|
||||
});
|
||||
test("bounded draft receipts do not resurrect pending state and newest page restores active request", async () => {
|
||||
const { store, request, workspace } = await setup();
|
||||
const draft = { version: INTERACTION_VERSION, request_id: "draft-0", expected_draft_revision: 0, answers: [] };
|
||||
await store.updateDraft(context, request.interaction_id, draft);
|
||||
const cancel = { version: INTERACTION_VERSION, request_id: "cancel", expected_revision: 0 };
|
||||
await store.cancel(context, request.interaction_id, cancel);
|
||||
expect((await store.updateDraft(context, request.interaction_id, draft)).status).toBe("cancelled");
|
||||
let newest = request;
|
||||
for (let i = 0; i < 22; i++) {
|
||||
newest = await store.create(context, { message_id: `m${i}`, turn_id: `t${i}`, call_id: `c${i}`, questions });
|
||||
if (i < 21) await store.cancel(context, newest.interaction_id, { ...cancel, request_id: `cancel-${i}` });
|
||||
}
|
||||
const page = await store.list(context);
|
||||
expect(page.items[0]?.interaction_id).toBe(newest.interaction_id);
|
||||
expect((await store.list(context, { cursor: page.next_cursor })).items).toHaveLength(3);
|
||||
for (let i = 0; i < 40; i++) await store.updateDraft(context, newest.interaction_id, { ...draft, request_id: `d${i}`, expected_draft_revision: i, answers: [{ question_id: "q", selected_option_ids: [], custom_text: "x".repeat(2000) }] });
|
||||
const state = await Bun.file(workspace.path(context, "interaction-requests.json")).json();
|
||||
expect(state.receipts.length).toBeLessThanOrEqual(32);
|
||||
expect(Buffer.byteLength(JSON.stringify(state.receipts))).toBeLessThanOrEqual(128 * 1024);
|
||||
}, 20000);
|
||||
test("complete oversized reply projection is rejected atomically instead of truncated", async () => {
|
||||
const { store } = await setup();
|
||||
await store.cancelPending(context);
|
||||
const qs = Array.from({ length: 3 }, (_, i) => ({ question_id: `q${i}`, title: "T".repeat(200), body: "?", options: Array.from({ length: 12 }, (_, j) => ({ id: `o${j}`, label: "L".repeat(200) })), mode: "multiple" as const, allow_custom: true, required: true }));
|
||||
const record = await store.create(context, { message_id: "large", turn_id: "large", call_id: "large", questions: qs });
|
||||
await expect(store.reply(context, record.interaction_id, { version: INTERACTION_VERSION, request_id: "large", expected_revision: 0, answers: qs.map((q) => ({ question_id: q.question_id, selected_option_ids: q.options.map((o) => o.id), custom_text: "C".repeat(900) })) })).rejects.toMatchObject({ code: "answer_too_large" });
|
||||
const unchanged = await store.get(context, record.interaction_id);
|
||||
expect(unchanged?.status).toBe("pending");
|
||||
expect(unchanged?.delivery).toBeUndefined();
|
||||
});
|
||||
Reference in New Issue
Block a user