fix: preserve interaction replies across chat recovery
This commit is contained in:
@@ -1,8 +1,35 @@
|
||||
import { expect, it, mock } from "bun:test";
|
||||
import { ResponseInputTracker, type ResponseInput } from "../../src/chat/response-inputs.js";
|
||||
import { ResponseInputTracker, WaitingResponseInputQueue, type ResponseInput } from "../../src/chat/response-inputs.js";
|
||||
|
||||
const input = (id: number): ResponseInput => ({ id: `msg_${String(id).padStart(32, "0")}`, text: `Input ${id}`, delivery: "steer", status: "pending" });
|
||||
|
||||
it("serializes waiting input admission, deduplicates retries and enforces capacity without resuming", async () => {
|
||||
const pending: ResponseInput[] = [];
|
||||
const enqueue = mock(async (_session: string, item: ResponseInput, options?: { resume?: boolean }) => {
|
||||
expect(options?.resume).toBe(false);
|
||||
pending.push(item);
|
||||
});
|
||||
const queue = new WaitingResponseInputQueue({ listPendingUserInputs: async () => pending,
|
||||
agentMessageState: async () => "missing", enqueueUserInput: enqueue });
|
||||
await Promise.all([queue.submit("s", input(1)), queue.submit("s", input(1))]);
|
||||
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||
await expect(queue.submit("s", { ...input(1), text: "different" })).rejects.toMatchObject({ status: 409 });
|
||||
const results = await Promise.allSettled(Array.from({ length: 20 }, (_, i) => queue.submit("s", input(i + 2))));
|
||||
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(19);
|
||||
expect(pending).toHaveLength(20);
|
||||
expect(enqueue).toHaveBeenCalledTimes(20);
|
||||
});
|
||||
|
||||
it("does not reinject a consumed waiting input or infer absence after a native read failure", async () => {
|
||||
const enqueue = mock(async () => {});
|
||||
const runtime = { listPendingUserInputs: async () => [], agentMessageState: async () => "delivered" as const, enqueueUserInput: enqueue };
|
||||
await expect(new WaitingResponseInputQueue(runtime).submit("s", input(1))).resolves.toMatchObject({ status: "delivered" });
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
const unavailable = { ...runtime, agentMessageState: async () => { throw new Error("native unavailable"); } };
|
||||
await expect(new WaitingResponseInputQueue(unavailable).submit("s", input(2))).rejects.toThrow("native unavailable");
|
||||
expect(enqueue).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for admission at completion, deduplicates concurrent retries and closes only after native consumption", async () => {
|
||||
let release = () => {};
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import express from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { registerChatInteractionBridge } from "../../src/routes/chat-interaction-bridge.js";
|
||||
import { setRuntimeSessionContext, removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
import { InteractionRequestStore } from "../../src/sessions/interaction-request-store.js";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
|
||||
test("trusted gate binds host identity, rejects parallel tools and persists waiting before tool leave/idle", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "interaction-bridge-"));
|
||||
const context = { sessionId: "bridge-session", clientSessionId: "bridge-session", actorKey: "trusted-actor", projectKey: "trusted-project", traceId: "test", hostTurnId: "host-turn", hostMessageId: "host-message", capabilities: ["host-question@1"], purpose: "ordinary_chat" as const };
|
||||
const workspace = new SessionWorkspaceStore(root); await workspace.ensure(context);
|
||||
const interactions = new InteractionRequestStore(workspace);
|
||||
setRuntimeSessionContext(context);
|
||||
const app = express(); app.use(express.json()); registerChatInteractionBridge(app, { internalToken: "token", interactions });
|
||||
const server = app.listen(0, "127.0.0.1"); await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
const post = (operation: string, body: unknown, headers: Record<string, string> = {}) => fetch(`${base}/internal/chat-interactions/${operation}`, { method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "token", ...headers }, body: JSON.stringify({ session_id: context.sessionId, ...body as object }) });
|
||||
const ask = { tool: "ask_user", call_id: "ask" };
|
||||
const query = { tool: "mcp_query", call_id: "query" };
|
||||
const questions = [{ question_id: "q", title: "Title", body: "Question", options: [], mode: "single", allow_custom: true, required: true }];
|
||||
try {
|
||||
expect((await post("enter", ask, { "x-agent-internal-token": "wrong" })).status).toBe(403);
|
||||
expect((await post("enter", ask, { Origin: "https://browser.test" })).status).toBe(403);
|
||||
expect((await post("create", { ...ask, questions, actorKey: "forged" })).status).toBe(400);
|
||||
expect((await post("create", { ...ask, questions })).status).toBe(409);
|
||||
expect((await post("enter", query)).status).toBe(200);
|
||||
expect((await post("enter", ask)).status).toBe(409);
|
||||
await post("leave", query);
|
||||
expect((await post("enter", ask)).status).toBe(200);
|
||||
expect((await post("enter", query)).status).toBe(409);
|
||||
const response = await post("create", { ...ask, questions });
|
||||
expect(response.status).toBe(200);
|
||||
const record = await response.json();
|
||||
expect(record).toMatchObject({ message_id: "host-message", turn_id: "host-turn", call_id: "ask", status: "pending" });
|
||||
expect(await interactions.get(context, record.interaction_id)).toEqual(record);
|
||||
expect(await (await post("policy", {})).json()).toEqual({ enabled: true, waiting: true });
|
||||
await post("leave", ask);
|
||||
expect((await post("enter", query)).status).toBe(409);
|
||||
expect((await post("enter", { tool: "question", call_id: "native" })).status).toBe(409);
|
||||
await interactions.cancelPending(context);
|
||||
expect((await post("enter", query)).status).toBe(409);
|
||||
setRuntimeSessionContext({ ...context, hostTurnId: "next-turn" });
|
||||
expect((await post("enter", query)).status).toBe(200);
|
||||
} finally {
|
||||
removeRuntimeSessionContext(context.sessionId);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import { streamPromptResponse } from "../../src/routes/chatStream.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
const questionEvent = { type: "message.part.updated", properties: { sessionID: "native", part: { id: "part", type: "tool", tool: "ask_user", sessionID: "native", messageID: "assistant", callID: "call", state: { status: "completed", input: {}, output: JSON.stringify({ interaction_id: "question" }), time: { start: 1, end: 2 } } } } };
|
||||
const stream = (events: unknown[]) => ({ async *[Symbol.asyncIterator]() { yield* events; } });
|
||||
|
||||
it("reserves ask_user running events so completed questions stop the turn exactly once", async () => {
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
const calls: string[] = [];
|
||||
const running = structuredClone(questionEvent);
|
||||
Object.assign(running.properties.part.state, { status: "running", input: { questions: [{ question_id: "q" }] }, output: undefined });
|
||||
const runtime = {
|
||||
subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() {
|
||||
yield running;
|
||||
yield questionEvent;
|
||||
yield questionEvent;
|
||||
throw new Error("must stop observing after the question");
|
||||
} }),
|
||||
prompt: async () => {},
|
||||
abortSession: async () => { calls.push("interrupt"); },
|
||||
waitForSessionIdle: async () => { calls.push("idle"); },
|
||||
messages: async () => [],
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const result = await streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", message: "question", onHostQuestion: async (id) => { calls.push("question"); return { interaction_id: id, status: "pending" }; }, write: (event, data) => events.push({ event, data }) });
|
||||
expect(result).toMatchObject({ failed: false, aborted: false, waitingInput: true });
|
||||
expect(calls).toEqual(["question", "interrupt", "idle"]);
|
||||
expect(events.filter((event) => event.event === "interaction_request")).toHaveLength(1);
|
||||
expect(events.filter((event) => event.event === "done").map((event) => event.data.reason)).toEqual(["waiting_input"]);
|
||||
expect(events.some((event) => ["tool_call", "final_answer", "error"].includes(event.event))).toBeFalse();
|
||||
});
|
||||
|
||||
it("ends ask_user at idle as waiting_input, without fabricating a final answer or failed run", async () => {
|
||||
const order: string[] = [];
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () => { order.push("subscribe"); return stream([questionEvent, questionEvent]); },
|
||||
prompt: async () => { order.push("prompt"); },
|
||||
abortSession: async () => { order.push("interrupt"); },
|
||||
waitForSessionIdle: async () => { order.push("idle"); },
|
||||
messages: async () => { throw new Error("waiting must not read a fallback final answer"); },
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const result = await streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", message: "question", onHostQuestion: async (id) => { expect(id).toBe("question"); order.push("persisted"); return { interaction_id: id, status: "pending" }; }, write: (event, data) => events.push({ event, data }) });
|
||||
expect(result).toMatchObject({ failed: false, aborted: false, waitingInput: true });
|
||||
expect(order).toEqual(["subscribe", "prompt", "persisted", "interrupt", "idle"]);
|
||||
expect(events.filter((e) => e.event === "interaction_request")).toHaveLength(1);
|
||||
expect(events.filter((e) => e.event === "done")).toEqual([{ event: "done", data: { session_id: "client", reason: "waiting_input" } }]);
|
||||
expect(events.some((e) => e.event === "final_answer" || e.event === "error")).toBeFalse();
|
||||
});
|
||||
|
||||
it("refuses an unowned or unpersisted question without publishing an actionable card", async () => {
|
||||
let interrupts = 0;
|
||||
const events: string[] = [];
|
||||
const runtime = { subscribeEvents: async () => stream([questionEvent]), prompt: async () => {}, abortSession: async () => { interrupts++; } } as unknown as OpencodeRuntimeAdapter;
|
||||
await expect(streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", message: "question", onHostQuestion: async () => { throw new Error("Question does not belong to this turn"); }, write: (event) => events.push(event) })).rejects.toThrow("Question does not belong");
|
||||
expect(events).not.toContain("interaction_request");
|
||||
expect(events).not.toContain("done");
|
||||
expect(interrupts).toBe(0);
|
||||
});
|
||||
|
||||
it("rejects a completed question with malformed output instead of silently finishing", async () => {
|
||||
const malformed = structuredClone(questionEvent);
|
||||
malformed.properties.part.state.output = "invalid JSON";
|
||||
const events: string[] = [];
|
||||
const runtime = { subscribeEvents: async () => stream([malformed]), prompt: async () => {} } as unknown as OpencodeRuntimeAdapter;
|
||||
await expect(streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", message: "question", onHostQuestion: async () => ({}), write: (event) => events.push(event) })).rejects.toThrow("valid persisted interaction");
|
||||
expect(events).not.toContain("done");
|
||||
expect(events).not.toContain("interaction_request");
|
||||
});
|
||||
|
||||
it("recovers an already completed fixed-ID answer without another prompt or waiting for new native events", async () => {
|
||||
const calls: string[] = [];
|
||||
const events: Array<{ event: string; data: Record<string, unknown> }> = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () => { calls.push("subscribe"); return { [Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<never>>(() => {}), return: async () => ({ done: true as const, value: undefined }) }) }; },
|
||||
continueInteraction: async (...args: unknown[]) => { calls.push(`continue:${args[0]}:${args[1]}`); return "completed"; },
|
||||
prompt: async () => { throw new Error("must not reinject a consumed answer"); },
|
||||
readInteractionResult: async (sessionId: string, messageId: string) => { expect([sessionId, messageId]).toEqual(["native", "fixed"]); return "Recovered answer"; },
|
||||
messages: async () => { throw new Error("generic history may contain an unrelated old answer"); },
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const result = await streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", interactionMessageId: "fixed", message: "saved answer", write: (event, data) => events.push({ event, data }) });
|
||||
expect(result).toMatchObject({ failed: false, aborted: false });
|
||||
expect(calls).toEqual(["subscribe", "continue:native:fixed"]);
|
||||
expect(events.filter((e) => e.event === "token").map((e) => e.data.content).join("")).toBe("Recovered answer");
|
||||
expect(events.filter((e) => e.event === "done")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("observes an admitted fixed-ID answer without issuing an ordinary prompt", async () => {
|
||||
const runtime = {
|
||||
subscribeEvents: async () => stream([{ type: "message.part.updated", properties: { sessionID: "native", part: { id: "text", type: "text", text: "", sessionID: "native", messageID: "assistant" } } }, { type: "message.part.delta", properties: { sessionID: "native", partID: "text", field: "text", delta: "Continued" } }, { type: "session.idle", properties: { sessionID: "native" } }]),
|
||||
continueInteraction: async () => "observing",
|
||||
prompt: async () => { throw new Error("duplicate prompt"); },
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const tokens: unknown[] = [];
|
||||
const result = await streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", interactionMessageId: "fixed", message: "saved answer", write: (event, data) => { if (event === "token") tokens.push(data.content); } });
|
||||
expect(result.failed).toBeFalse(); expect(tokens.join("")).toBe("Continued");
|
||||
});
|
||||
|
||||
it("uses authenticated /responses for saved answers and rejects bypass and foreign actors while retaining the fixed native ID", async () => {
|
||||
const { mkdtemp, rm } = await import("node:fs/promises");
|
||||
const { tmpdir } = await import("node:os");
|
||||
const { join } = await import("node:path");
|
||||
const { default: express } = await import("express");
|
||||
const { buildChatRouter } = await import("../../src/routes/chat.js");
|
||||
const { ChatSessionBridge } = await import("../../src/chat/sessionBridge.js");
|
||||
const { SessionWorkspaceStore } = await import("../../src/sessions/workspace-store.js");
|
||||
const { SessionMetadataStore } = await import("../../src/sessions/metadataStore.js");
|
||||
const { SessionUiStateStore } = await import("../../src/sessions/uiStateStore.js");
|
||||
const { InteractionRequestStore } = await import("../../src/sessions/interaction-request-store.js");
|
||||
const { toActorKey, toProjectKey } = await import("../../src/utils/fileStore.js");
|
||||
const { removeRuntimeSessionContext } = await import("../../src/runtime/sessionContext.js");
|
||||
const root = await mkdtemp(join(tmpdir(), "host-stream-"));
|
||||
const http = globalThis.fetch;
|
||||
const project = "10000000-0000-4000-8000-000000000001";
|
||||
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("Authorization") === "Bearer owner" ? "owner" : "other", project_id: project, network: "fixture" })) as typeof fetch;
|
||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
|
||||
const interactions = new InteractionRequestStore(workspaces);
|
||||
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
|
||||
await sessions.ensure(context);
|
||||
await states.write(context, { sessionId: "owned", isTitleManuallyEdited: true, messages: [] });
|
||||
const question = await interactions.create(context, { message_id: "assistant", turn_id: "old", call_id: "call", questions: [{ question_id: "q", title: "Choose", body: "Which?", options: [{ id: "a", label: "Accepted option" }], mode: "single", allow_custom: false, required: true }] });
|
||||
const nativeIds: string[] = [];
|
||||
let admissionCount = 0;
|
||||
let releaseContinuation: () => void = () => {};
|
||||
const continuationGate = new Promise<void>((resolve) => { releaseContinuation = resolve; });
|
||||
const runtime = {
|
||||
messages: async () => [], prompt: async () => { throw new Error("ordinary prompt must not bypass a question"); },
|
||||
listPendingUserInputs: async () => [], cancelPendingUserInputs: async () => {},
|
||||
continueInteraction: async (_session: string, id: string, text: string) => { nativeIds.push(id); expect(text).toContain("Accepted option"); expect(text).not.toContain("must not replace saved answer"); admissionCount++; await continuationGate; return "observing"; },
|
||||
agentMessageState: async () => "delivered",
|
||||
subscribeEvents: async () => stream([{ type: "message.part.updated", properties: { sessionID: "owned", part: { id: "text", type: "text", text: "", sessionID: "owned", messageID: "assistant" } } }, { type: "message.part.delta", properties: { sessionID: "owned", partID: "text", field: "text", delta: "Done" } }, { type: "session.idle", properties: { sessionID: "owned" } }]),
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
const bridge = new ChatSessionBridge(runtime);
|
||||
const app = express(); app.use(express.json());
|
||||
app.use("/api/v1", buildChatRouter({ runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states, interactionRequestStore: interactions, sessionTranscriptStore: { getRecentTurns: async () => [] } as never, memoryStore: { buildPromptSnapshot: async () => "" } as never, learningOrchestrator: { onTurnCompleted: async () => {} } as never, frontendActionCoordinator: { cancelSession: () => {} } as never }));
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
const call = (body: unknown, token = "owner") => http(`${base}/api/v1/responses`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Project-Id": project, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000) });
|
||||
try {
|
||||
expect((await call({ input: "bypass", conversation: "owned" })).status).toBe(409);
|
||||
const saved = await interactions.reply(context, question.interaction_id, { version: "agent-interaction-request@1", expected_revision: 0, request_id: "answer", answers: [{ question_id: "q", selected_option_ids: ["a"], custom_text: "" }] });
|
||||
expect((await call({ input: "bypass saved delivery", conversation: "owned" })).status).toBe(409);
|
||||
const request = { input: "must not replace saved answer", conversation: "owned", tjwater: { interaction_id: question.interaction_id } };
|
||||
expect((await call(request, "other")).status).toBe(404);
|
||||
const firstRequest = call(request);
|
||||
for (let i = 0; i < 100 && admissionCount === 0; i++) await Bun.sleep(5);
|
||||
expect(admissionCount).toBe(1);
|
||||
expect((await call(request)).status).toBe(409);
|
||||
releaseContinuation();
|
||||
const response = await firstRequest;
|
||||
const body = await response.json();
|
||||
expect(response.status).toBe(200); expect(body).toMatchObject({ status: "completed" });
|
||||
expect(nativeIds).toEqual([saved.delivery!.native_message_id]);
|
||||
expect((await interactions.get(context, question.interaction_id))?.delivery?.status).toBe("delivered");
|
||||
expect(admissionCount).toBe(1);
|
||||
const restored = await states.read(context);
|
||||
const replyMessage = (restored?.messages as Array<{ id: string; parts: unknown[] }>).find((message) => message.id === saved.delivery!.native_message_id);
|
||||
expect(replyMessage?.parts).toContainEqual({ type: "text", text: "Choose: Accepted option" });
|
||||
expect(replyMessage?.parts).toContainEqual(expect.objectContaining({ type: "data-interaction_reply" }));
|
||||
// Legacy persisted turns contain the model-facing prefix. All read paths project
|
||||
// the same accepted answer, even before the separate interactions request returns.
|
||||
const legacy = [{ id: saved.delivery!.native_message_id, role: "user", parts: [{ type: "text", text: "用户提交的提问回复:\nChoose: Accepted option" }] }];
|
||||
await states.write(context, { sessionId: "owned", isTitleManuallyEdited: true, messages: legacy });
|
||||
const read = (path: string, token = "owner") => http(`${base}/api/v1/session/owned${path}`, { headers: { Authorization: `Bearer ${token}`, "X-Project-Id": project } });
|
||||
for (const path of ["", "/messages?limit=1"]) {
|
||||
const history = await (await read(path)).json();
|
||||
expect(history.messages[0].parts[0]).toEqual({ type: "text", text: "Choose: Accepted option" });
|
||||
expect(history.messages[0].parts[1]).toMatchObject({ type: "data-interaction_reply", data: { status: "answered", delivery: { status: "delivered" } } });
|
||||
}
|
||||
const streamState = await (await read("/stream")).text();
|
||||
expect(streamState).toContain("data-interaction_reply");
|
||||
expect(streamState).not.toContain("用户提交的提问回复");
|
||||
expect((await read("", "other")).status).toBe(404);
|
||||
expect((await states.read(context))?.messages).toEqual(legacy);
|
||||
} finally {
|
||||
releaseContinuation(); globalThis.fetch = http; removeRuntimeSessionContext("owned"); server.closeAllConnections();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
it("fails consumed-answer recovery when no completed matching answer exists instead of showing old history", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () => ({ [Symbol.asyncIterator]: () => ({ next: () => new Promise<IteratorResult<never>>(() => {}), return: async () => ({ done: true as const, value: undefined }) }) }),
|
||||
continueInteraction: async () => "completed",
|
||||
readInteractionResult: async () => { throw new Error("Consumed answer has no completed matching response"); },
|
||||
messages: async () => [{ info: { role: "assistant" }, parts: [{ type: "text", text: "OLD UNRELATED ANSWER" }] }],
|
||||
prompt: async () => { throw new Error("must not reinject consumed answer"); },
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
await expect(streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", interactionMessageId: "fixed", message: "saved answer", write: (event) => events.push(event) })).rejects.toThrow("no completed matching response");
|
||||
expect(events).not.toContain("token"); expect(events).not.toContain("done");
|
||||
});
|
||||
|
||||
it("does not report waiting_input until the interrupted question turn is confirmed idle", async () => {
|
||||
const events: string[] = [];
|
||||
const runtime = {
|
||||
subscribeEvents: async () => stream([questionEvent]), prompt: async () => {}, abortSession: async () => {},
|
||||
waitForSessionIdle: async () => { throw new Error("native turn still active"); },
|
||||
} as unknown as OpencodeRuntimeAdapter;
|
||||
await expect(streamPromptResponse({ runtime, sessionId: "native", clientSessionId: "client", message: "question", onHostQuestion: async () => ({ interaction_id: "question" }), write: (event) => events.push(event) })).rejects.toThrow("native turn still active");
|
||||
expect(events).not.toContain("done");
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getLocalAgentContext } from "../../src/context/localContext.js";
|
||||
import { INTERACTION_VERSION } from "../../src/chat/interaction-request-contract.js";
|
||||
import { registerInteractionRequestRoutes } from "../../src/routes/interaction-request-routes.js";
|
||||
import { InteractionRequestStore } from "../../src/sessions/interaction-request-store.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
|
||||
it("authorizes session ownership, validates CAS, persists drafts and answers without inference", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "interaction-routes-"));
|
||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||
const interactions = new InteractionRequestStore(workspaces);
|
||||
const context = { ...getLocalAgentContext(), sessionId: "session" };
|
||||
await sessions.ensure(context);
|
||||
const record = await interactions.create(context, { message_id: "message", turn_id: "turn", call_id: "call", questions: [{ question_id: "q", title: "Choose", body: "Which one?", options: [{ id: "a", label: "A" }], mode: "single", allow_custom: false, required: true }] });
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
registerInteractionRequestRoutes(app, { sessions, interactions });
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}/session/session/interactions`;
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
const send = (path: string, body: unknown, method = "POST", override = headers) => fetch(`${base}/${record.interaction_id}${path}`, { method, headers: override, body: JSON.stringify(body) });
|
||||
const answers = [{ question_id: "q", selected_option_ids: ["a"], custom_text: "" }];
|
||||
const mutation = { version: INTERACTION_VERSION, request_id: "reply", expected_revision: 0, answers };
|
||||
try {
|
||||
for (const bad of [{ ...headers, "X-Agent-Local-User": "other" }, { ...headers, "X-Agent-Local-Project": "other" }]) {
|
||||
expect((await fetch(base, { headers: bad })).status).toBe(404);
|
||||
expect((await send("/reply", mutation, "POST", bad)).status).toBe(404);
|
||||
}
|
||||
const list = await fetch(`${base}?limit=1`);
|
||||
expect(list.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(await list.json()).toMatchObject({ items: [{ interaction_id: record.interaction_id }] });
|
||||
for (const query of ["limit=51", "limit=1.5", "limit=01", "unexpected=1", "cursor=unknown"]) expect((await fetch(`${base}?${query}`)).status).toBe(400);
|
||||
expect((await send("/draft", { version: INTERACTION_VERSION, request_id: "draft", expected_draft_revision: 0, answers }, "PUT")).status).toBe(200);
|
||||
expect((await send("/reply", { ...mutation, expected_revision: Number.MAX_SAFE_INTEGER + 1 })).status).toBe(400);
|
||||
expect((await send("/reply", { ...mutation, unexpected: true })).status).toBe(400);
|
||||
expect((await send("/skip", { version: INTERACTION_VERSION, request_id: "skip-required", expected_revision: 0 })).status).toBe(400);
|
||||
const reply = await send("/reply", mutation);
|
||||
expect(reply.status).toBe(200);
|
||||
const saved = await reply.json() as { delivery: { native_message_id: string; revision: number } };
|
||||
expect(saved).toMatchObject({ status: "answered", delivery: { status: "queued" } });
|
||||
expect(await (await send("/reply", mutation)).json()).toEqual(saved);
|
||||
expect((await send("/reply", { ...mutation, request_id: "competing" })).status).toBe(409);
|
||||
expect((await send("/draft", { version: INTERACTION_VERSION, request_id: "late", expected_draft_revision: 1, answers: [] }, "PUT")).status).toBe(409);
|
||||
const resume = await send("/resume", { version: INTERACTION_VERSION, request_id: "resume", expected_revision: saved.delivery.revision });
|
||||
expect(resume.status).toBe(200);
|
||||
expect(await resume.json()).toMatchObject({ delivery: { native_message_id: saved.delivery.native_message_id } });
|
||||
const second = await interactions.create(context, { message_id: "second", turn_id: "second", call_id: "second", questions: record.questions.map((q) => ({ ...q, required: false })) }).catch(() => null);
|
||||
// Saved queued replies block creation until their delivery is settled.
|
||||
expect(second).toBeNull();
|
||||
const originalGet = interactions.get.bind(interactions);
|
||||
interactions.get = async () => { throw new Error(`${root}/private-answer`); };
|
||||
const unavailable = await fetch(`${base}/${record.interaction_id}`);
|
||||
expect(unavailable.status).toBe(503);
|
||||
expect(await unavailable.json()).toEqual({ code: "INTERACTION_UNAVAILABLE", detail: "interaction unavailable" });
|
||||
interactions.get = originalGet;
|
||||
await sessions.remove((await sessions.get(context, "session"))!);
|
||||
expect((await send("/reply", mutation)).status).toBe(404);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import express from "express";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { getLocalAgentContext } from "../../src/context/localContext.js";
|
||||
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
||||
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
|
||||
it("reconciles persisted legacy forms and replies after the active run was lost", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "legacy-question-routes-"));
|
||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
|
||||
const context = { ...getLocalAgentContext(), sessionId: "session" };
|
||||
await sessions.ensure(context);
|
||||
await states.write(context, { sessionId: "session", messages: [{ id: "old", role: "assistant", questions: [{ requestId: "form", status: "pending", tool: { callID: "call" } }] }, { id: "new", role: "assistant", text: "later message", parts: [{ type: "data-question_request", data: { request_id: "form2", session_id: "session", questions: [], tool: { callID: "call2" } } }, { type: "data-question_request", data: { request_id: "form3", session_id: "session", questions: [] } }, { type: "data-question_request", data: { request_id: "form4", session_id: "session", questions: [] } }] }] });
|
||||
let status: "pending" | "answered" | "cancelled" | "missing" = "pending";
|
||||
let unavailable = false;
|
||||
let reads = 0;
|
||||
let replies = 0;
|
||||
const runtime = { getQuestionStatus: async () => { reads++; if (unavailable) throw new Error("private-native-path"); return status; }, replyQuestion: async () => { replies++; status = "answered"; }, rejectQuestion: async () => { status = "cancelled"; } } as unknown as OpencodeRuntimeAdapter;
|
||||
const app = express(); app.use(express.json());
|
||||
registerChatInteractionRoutes(app, { activeRuns: new Map(), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
|
||||
const server = app.listen(0, "127.0.0.1");
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
|
||||
const get = (id = "form", headers = {}) => fetch(`${base}/session/session/questions/${id}`, { headers });
|
||||
const reply = (id = "form") => fetch(`${base}/question/${id}/reply`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session_id: "session", answers: [["A"]] }) });
|
||||
try {
|
||||
expect((await get("unknown")).status).toBe(404); expect(reads).toBe(0);
|
||||
expect((await get("form", { "X-Agent-Local-User": "other" })).status).toBe(404); expect(reads).toBe(0);
|
||||
expect(await (await get("call")).json()).toMatchObject({ request_id: "form", status: "pending" });
|
||||
unavailable = true;
|
||||
const failed = await get(); expect(failed.status).toBe(503); expect(await failed.text()).not.toContain("private-native-path");
|
||||
expect((await states.read(context))?.messages[0]).toMatchObject({ questions: [{ status: "pending" }] });
|
||||
unavailable = false;
|
||||
expect((await reply()).status).toBe(202); expect(replies).toBe(1);
|
||||
expect((await states.read(context))?.messages[0]).toMatchObject({ questions: [{ status: "answered", answers: [["A"]] }] });
|
||||
expect((await states.read(context))?.messages[1]).toMatchObject({ text: "later message" });
|
||||
expect((await reply()).status).toBe(409); expect(replies).toBe(1);
|
||||
status = "missing"; expect(await (await get()).json()).toMatchObject({ status: "answered" });
|
||||
status = "cancelled"; expect(await (await get("call2")).json()).toMatchObject({ request_id: "form2", status: "rejected" });
|
||||
expect((await states.read(context))?.messages[1]).toMatchObject({ parts: [{ type: "data-question_request" }, { type: "data-question_request" }, { type: "data-question_request" }, { type: "data-question_response", data: { request_id: "form2", status: "rejected" } }] });
|
||||
status = "missing"; expect(await (await get("form2")).json()).toMatchObject({ status: "rejected" });
|
||||
expect(await (await get("form3")).json()).toMatchObject({ status: "invalidated" });
|
||||
status = "pending";
|
||||
expect((await reply("form4")).status).toBe(202);
|
||||
const canonical = (await states.read(context))?.messages[1] as { parts: Array<{ type: string; data?: unknown }> };
|
||||
expect(canonical.parts.find((part) => part.type === "data-question_response" && (part.data as { request_id: string }).request_id === "form4")).toMatchObject({ data: { request_id: "form4", status: "answered", answers: [["A"]] } });
|
||||
status = "missing"; expect(await (await get("form4")).json()).toMatchObject({ status: "answered" });
|
||||
await sessions.remove((await sessions.get(context, "session"))!);
|
||||
expect((await get()).status).toBe(404);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -7,6 +7,15 @@ import {
|
||||
} from "../../src/routes/chatStreamRoute.js";
|
||||
|
||||
describe("task collaboration history", () => {
|
||||
it("keeps accepted reply snapshots with both text and rich history", () => {
|
||||
const reply = { type: "data-interaction_reply" as const, data: { interaction_id: "accepted-question" } };
|
||||
for (const rich of [false, true]) {
|
||||
const parts: UIMessage["parts"] = [{ type: "text", text: "核查范围: 北侧管网" }, reply,
|
||||
...(rich ? [{ type: "data-final_answer" as const, data: { content: "核查范围: 北侧管网" } }] : [])];
|
||||
const messages = [{ id: "reply", role: "user", parts: [...parts, { type: "data-progress", data: { title: "执行记录" } }] }] as unknown as UIMessage[];
|
||||
expect(toCollaborationMessages(messages)).toEqual([{ id: "reply", role: "user", parts }]);
|
||||
}
|
||||
});
|
||||
it("persists only user and assistant text instead of execution trajectory", () => {
|
||||
const messages = [
|
||||
{ id: "user-1", role: "user", parts: [{ type: "text", text: "为什么这样判断?" }] },
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
|
||||
function runtime(state: "missing" | "enqueued" | "delivered", active = false) {
|
||||
const calls: unknown[] = [];
|
||||
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({ session: { active: async () => active ? { s: {} } : {}, interrupt: async (input: unknown) => { calls.push(input); } } }),
|
||||
agentMessageState: async () => state,
|
||||
prompt: async (...args: unknown[]) => { calls.push(args); },
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
return { adapter, calls };
|
||||
}
|
||||
test("host reply continuation injects fixed ID only when absent; queued and consumed IDs never reinject", async () => {
|
||||
const missing = runtime("missing");
|
||||
expect(await missing.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("started");
|
||||
expect(missing.calls).toEqual([["s", "answer", undefined, "msg_fixed"]]);
|
||||
const busy = runtime("missing", true);
|
||||
await expect(busy.adapter.continueInteraction("s", "msg_fixed", "answer")).rejects.toThrow("still active");
|
||||
expect(busy.calls).toEqual([]);
|
||||
const enqueued = runtime("enqueued");
|
||||
expect(await enqueued.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("observing");
|
||||
expect(enqueued.calls).toEqual([{ sessionID: "s", resume: true }]);
|
||||
const consumed = runtime("delivered");
|
||||
expect(await consumed.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("completed");
|
||||
expect(consumed.calls).toEqual([]);
|
||||
const observing = runtime("delivered", true);
|
||||
expect(await observing.adapter.continueInteraction("s", "msg_fixed", "answer")).toBe("observing");
|
||||
expect(observing.calls).toEqual([]);
|
||||
});
|
||||
test("native question lookup distinguishes missing form from unavailable service", async () => {
|
||||
let failure: unknown = { _tag: "FormNotFoundError" };
|
||||
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({ session: { form: { get: async () => { if (failure) throw failure; return { state: { status: "answered" } }; } } } }),
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("missing");
|
||||
failure = new Error("network unavailable");
|
||||
await expect(adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).rejects.toThrow("network unavailable");
|
||||
failure = undefined;
|
||||
expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("answered");
|
||||
});
|
||||
test("message admission lookup fails closed on inbox and message transport errors", async () => {
|
||||
let inboxFailure = true;
|
||||
let messageFailure: unknown = { status: 404 };
|
||||
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({ session: { inbox: { list: async () => { if (inboxFailure) throw new Error("inbox unavailable"); return []; } }, message: { get: async () => { throw messageFailure; } } } }),
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
await expect(adapter.agentMessageState("s", "msg_fixed")).rejects.toThrow("inbox unavailable");
|
||||
inboxFailure = false;
|
||||
expect(await adapter.agentMessageState("s", "msg_fixed")).toBe("missing");
|
||||
messageFailure = new Error("message unavailable");
|
||||
await expect(adapter.agentMessageState("s", "msg_fixed")).rejects.toThrow("message unavailable");
|
||||
});
|
||||
test("stop serializes behind in-flight answer admission and cancels its exact inbox identity", async () => {
|
||||
let release!: () => void;
|
||||
let admitted!: () => void;
|
||||
const entered = new Promise<void>((resolve) => { admitted = resolve; });
|
||||
const barrier = new Promise<void>((resolve) => { release = resolve; });
|
||||
let state: "missing" | "enqueued" = "missing";
|
||||
const calls: string[] = [];
|
||||
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({ session: { active: async () => ({}), inbox: { cancel: async (input: { inboxID: string }) => { calls.push(`cancel:${input.inboxID}`); state = "missing"; } } } }),
|
||||
agentMessageState: async () => state,
|
||||
prompt: async (_sessionId: string, _text: string, _model: unknown, messageId: string) => { calls.push(`admit:${messageId}`); admitted(); await barrier; state = "enqueued"; },
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
const continuation = adapter.continueInteraction("s", "msg_fixed", "answer");
|
||||
await entered;
|
||||
const stopping = adapter.stopInteractionInput("s", "msg_fixed");
|
||||
await Promise.resolve();
|
||||
expect(calls).toEqual(["admit:msg_fixed"]);
|
||||
release();
|
||||
expect(await continuation).toBe("started");
|
||||
expect(await stopping).toBe("cancelled");
|
||||
expect(calls).toEqual(["admit:msg_fixed", "cancel:msg_fixed"]);
|
||||
});
|
||||
test("completed-answer recovery uses the matching user boundary and refuses missing final evidence", async () => {
|
||||
let messages: unknown[] = [
|
||||
{ id: "later-answer", type: "assistant", time: { completed: 1 }, finish: "stop", content: [{ type: "text", text: "Unrelated answer" }] },
|
||||
{ id: "later-user", type: "user" },
|
||||
{ id: "answer", type: "assistant", time: { completed: 1 }, finish: "stop", content: [{ type: "text", text: "Saved answer result" }] },
|
||||
{ id: "msg_fixed", type: "user" },
|
||||
];
|
||||
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({ message: { list: async () => ({ data: messages }) } }),
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
expect(await adapter.readInteractionResult("s", "msg_fixed")).toBe("Saved answer result");
|
||||
messages = [{ id: "partial", type: "assistant", time: {}, content: [{ type: "text", text: "Partial output" }] }, { id: "msg_fixed", type: "user" }];
|
||||
await expect(adapter.readInteractionResult("s", "msg_fixed")).rejects.toThrow("do not resend");
|
||||
await expect(adapter.readInteractionResult("s", "outside-window")).rejects.toThrow("outside the recovery window");
|
||||
});
|
||||
test("strict waiting boundary requires verified idle and rejects an exhausted deadline", async () => {
|
||||
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||
ensureClient: async () => ({ session: { active: async () => ({}) } }),
|
||||
}) as OpencodeRuntimeAdapter;
|
||||
await expect(adapter.waitForSessionIdle("s", 100, true)).resolves.toBeUndefined();
|
||||
await expect(adapter.waitForSessionIdle("s", 0, true)).rejects.toThrow("waiting boundary");
|
||||
});
|
||||
@@ -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