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 }> = []; 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 }> = []; 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 }> = []; const runtime = { subscribeEvents: async () => { calls.push("subscribe"); return { [Symbol.asyncIterator]: () => ({ next: () => new Promise>(() => {}), 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((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((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((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>(() => {}), 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"); });