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((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((resolve) => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); } });