import { afterEach, beforeEach, expect, it } from "bun:test"; import express from "express"; import { mkdtemp, rm } from "node:fs/promises"; import type { Server } from "node:http"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { buildChatRouter } from "../../src/routes/chat.js"; import { ChatSessionBridge } from "../../src/chat/sessionBridge.js"; import { SessionMetadataStore } from "../../src/sessions/metadataStore.js"; import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js"; import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js"; import { removeRuntimeSessionContext } from "../../src/runtime/sessionContext.js"; import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js"; import type { ResponseInput } from "../../src/chat/response-inputs.js"; const http = globalThis.fetch; let root: string; let server: Server; let base: string; let fail = false; let prompted: string[]; let pendingInputs: ResponseInput[]; let aborts: number; let gate: Promise | undefined; let release: () => void = () => {}; let bridge: ChatSessionBridge; const project = "10000000-0000-4000-8000-000000000001"; beforeEach(async () => { fail = false; prompted = []; pendingInputs = []; aborts = 0; gate = undefined; root = await mkdtemp(join(tmpdir(), "tjwater-responses-")); 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, "ui"), workspaces); await sessions.initialize(); 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 runtime = { prompt: async (_id: string, prompt: string) => { prompted.push(prompt); if (fail) throw new Error("fixture execution failed"); }, messages: async () => [], abortSession: async () => { aborts++; }, waitForSessionIdle: async () => {}, listPendingUserInputs: async () => pendingInputs.map((input) => ({ ...input })), cancelPendingUserInputs: async () => { pendingInputs = []; }, enqueueUserInput: async (_sessionId: string, input: ResponseInput) => { pendingInputs.push({ ...input }); }, updatePendingUserInput: async (_sessionId: string, id: string, delivery: "steer" | "queue") => { const item = pendingInputs.find((input) => input.id === id); if (!item) throw Object.assign(new Error("Consumed"), { status: 409 }); item.delivery = delivery; return { ...item }; }, subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { if (gate) await gate; yield { type: "message.part.updated", properties: { sessionID: "owned", part: { id: "answer", type: "text", text: "", sessionID: "owned", messageID: "native-answer" } } }; yield { type: "message.part.delta", properties: { sessionID: "owned", partID: "answer", field: "text", delta: "压力待核验" } }; for (const item of pendingInputs.splice(0)) { yield { type: "session.inbox.delivered", properties: { sessionID: "owned", inboxID: item.id } }; } yield { type: "session.idle", properties: { sessionID: "owned" } }; } }), } as never; const app = express(); app.use(express.json()); bridge = new ChatSessionBridge(runtime); app.use("/api/v1", buildChatRouter({ runtime, sessionBridge: bridge, sessionMetadataStore: sessions, sessionUiStateStore: states, sessionTranscriptStore: { getRecentTurns: async () => [] } as never, memoryStore: { buildPromptSnapshot: async () => "" } as never, learningOrchestrator: { onTurnCompleted: async () => {} } as never, frontendActionCoordinator: { cancelSession: () => {} } as never, })); server = app.listen(0, "127.0.0.1"); await new Promise((resolve) => server.once("listening", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("missing address"); base = `http://127.0.0.1:${address.port}`; }); afterEach(async () => { release(); for (let i = 0; i < 100 && bridge.count(); i++) await Bun.sleep(10); globalThis.fetch = http; removeRuntimeSessionContext("owned"); server?.closeAllConnections(); if (server) await new Promise((resolve) => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); }); const call = (body: unknown, token = "owner", path = "/api/v1/responses") => http(`${base}${path}`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "X-Project-Id": project, "Content-Type": "application/json" }, body: JSON.stringify(body), signal: AbortSignal.timeout(5000), }); it("serves Responses SSE through the real HTTP route and preserves the owned conversation", async () => { const response = await call({ input: [{ role: "user", content: [{ type: "input_text", text: "核查压力" }] }], conversation: "owned", stream: true }); const text = await response.text(); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("text/event-stream"); expect(response.headers.has("x-vercel-ai-ui-message-stream")).toBeFalse(); expect(text).toContain("event: response.created"); expect(text).toContain("event: response.output_text.delta"); expect(text).toContain("event: response.completed"); expect(text).toContain('"conversation":{"id":"owned"}'); expect(prompted).toHaveLength(1); expect(prompted[0]).toContain("核查压力"); }); it("returns a Response object when streaming is omitted", async () => { const response = await call({ input: "核查压力", conversation: { id: "owned" } }); expect(await response.json()).toMatchObject({ object: "response", status: "completed", conversation: { id: "owned" }, output: [{ type: "message", content: [{ type: "output_text", text: "压力待核验" }] }] }); }); it("admits steer/queue input without interrupting, edits pending delivery and persists consumed user messages", async () => { gate = new Promise((resolve) => { release = resolve; }); const initial = await call({ input: "原始问题", conversation: "owned", stream: true }); const first = `msg_${"1".repeat(32)}`; const second = `msg_${"2".repeat(32)}`; const baselineAborts = aborts; for (const [id, text, delivery] of [[first, "补充压力测点", "steer"], [second, "之后检查流量", "queue"]] as const) { const result = await call({ input: text, conversation: "owned", tjwater: { delivery, input_id: id } }); expect(result.status).toBe(202); expect(await result.json()).toMatchObject({ id, text, delivery, status: "pending" }); } expect((await call({ input: "不带方式", conversation: "owned" })).status).toBe(409); expect(aborts).toBe(baselineAborts); expect(prompted).toHaveLength(1); const headers = { Authorization: "Bearer owner", "X-Project-Id": project, "Content-Type": "application/json" }; const changed = await http(`${base}/api/v1/session/owned/inputs/${second}`, { method: "PATCH", headers, body: JSON.stringify({ delivery: "steer" }) }); expect(changed.status).toBe(200); expect(await changed.json()).toMatchObject({ id: second, delivery: "steer" }); const list = await http(`${base}/api/v1/session/owned/inputs`, { headers }); expect((await list.json()).items).toHaveLength(2); const foreign = await http(`${base}/api/v1/session/owned/inputs/${first}`, { method: "PATCH", headers: { ...headers, Authorization: "Bearer other" }, body: JSON.stringify({ delivery: "queue" }) }); expect(foreign.status).toBe(404); expect(aborts).toBe(baselineAborts); release(); const output = await initial.text(); expect(output).toContain("event: response.completed"); expect(output).toContain("tjwater.user_input"); const history = await http(`${base}/api/v1/session/owned`, { headers }); expect(JSON.stringify((await history.json()).messages)).toContain("补充压力测点"); const consumed = await http(`${base}/api/v1/session/owned/inputs/${second}`, { method: "PATCH", headers, body: JSON.stringify({ delivery: "queue" }) }); expect(consumed.status).toBe(409); }); it("rejects cross-user conversations, legacy requests and retired URLs before generation", async () => { expect((await call({ input: "核查压力", conversation: "owned" }, "other")).status).toBe(404); expect((await call({ message: "legacy", session_id: "owned" })).status).toBe(400); expect((await call({ input: "核查压力" }, "owner", "/api/v1/agent/chat/stream")).status).toBe(404); expect(prompted).toEqual([]); }); it("returns response.failed when the runtime fails", async () => { fail = true; const response = await call({ input: "核查压力", conversation: "owned", stream: true }); const text = await response.text(); expect(text).toContain("event: response.failed"); expect(text).not.toContain("event: response.completed"); }); it("keeps generating and saving the owned history after the browser disconnects", async () => { gate = new Promise((resolve) => { release = resolve; }); const response = await call({ input: "核查压力", conversation: "owned", stream: true }); const reader = response.body!.getReader(); await reader.read(); await reader.cancel(); release(); let loaded: { is_streaming?: boolean; messages?: unknown[] } = {}; for (let i = 0; i < 50; i++) { await Bun.sleep(10); const state = await http(`${base}/api/v1/session/owned`, { headers: { Authorization: "Bearer owner", "X-Project-Id": project } }); loaded = await state.json() as typeof loaded; if (!loaded.is_streaming && JSON.stringify(loaded.messages).includes("压力待核验")) break; } expect(loaded.is_streaming).toBeFalse(); expect(JSON.stringify(loaded.messages)).toContain("压力待核验"); }); it("marks an explicitly aborted response incomplete and preserves scoped history", async () => { gate = new Promise((resolve) => { release = resolve; }); const response = await call({ input: "核查压力", conversation: "owned", stream: true }); const queued = await call({ input: "等待后续处理", conversation: "owned", tjwater: { delivery: "queue", input_id: `msg_${"3".repeat(32)}` } }); expect(queued.status).toBe(202); const cancelled = await call({ session_id: "owned" }, "owner", "/api/v1/abort"); release(); expect(cancelled.ok).toBeTrue(); expect(pendingInputs).toEqual([]); const text = await response.text(); expect(text).toContain("event: response.incomplete"); expect(text).not.toContain("event: response.completed"); });