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(); });