Files
next-tjwater-agent/tests/sessions/interactionRequest.test.ts
T

111 lines
9.9 KiB
TypeScript

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