import { describe, expect, it } from "bun:test"; import { streamPromptResponse, type PermissionRequestPayload, } from "../../src/routes/chatStream.js"; import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js"; const createEventStream = (events: unknown[]) => ({ async *[Symbol.asyncIterator]() { for (const event of events) { yield event; } }, }); describe("streamPromptResponse", () => { it("emits only the final assistant text after tool-driven intermediate messages", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "message.part.delta", properties: { sessionID: "runtime-session-1", messageID: "assistant-intermediate", partID: "text-part-intermediate", field: "text", delta: "正在加载工作流并尝试分页参数。", }, }, { type: "message.part.updated", properties: { sessionID: "runtime-session-1", part: { id: "text-part-intermediate", sessionID: "runtime-session-1", messageID: "assistant-intermediate", type: "text", text: "正在加载工作流并尝试分页参数。", time: { start: 1, end: 2 }, }, time: 2, }, }, { type: "message.part.delta", properties: { sessionID: "runtime-session-1", messageID: "assistant-final", partID: "text-part-final", field: "text", delta: "共识别 56 条瓶颈管段,建议优先改造 Top 5。", }, }, { type: "message.part.updated", properties: { sessionID: "runtime-session-1", part: { id: "text-part-final", sessionID: "runtime-session-1", messageID: "assistant-final", type: "text", text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。", time: { start: 3, end: 4 }, }, time: 4, }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1" }, }, ]), prompt: async () => undefined, messages: async () => [ { info: { id: "assistant-intermediate", role: "assistant" }, parts: [ { id: "text-part-intermediate", sessionID: "runtime-session-1", messageID: "assistant-intermediate", type: "text", text: "正在加载工作流并尝试分页参数。", }, ], }, { info: { id: "assistant-final", role: "assistant" }, parts: [ { id: "text-part-final", sessionID: "runtime-session-1", messageID: "assistant-final", type: "text", text: "共识别 56 条瓶颈管段,建议优先改造 Top 5。", }, ], }, ], } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "分析管网瓶颈", write: (event, data) => events.push({ event, data }), }); expect( events .filter((item) => item.event === "token") .map((item) => item.data.content) .join(""), ).toBe("共识别 56 条瓶颈管段,建议优先改造 Top 5。"); }); it("uses the final text event cache when the messages lookup fails", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "message.part.updated", properties: { sessionID: "runtime-session-1", part: { id: "text-part-final", sessionID: "runtime-session-1", messageID: "assistant-final", type: "text", text: "最终分析结果。", time: { start: 1, end: 2 }, }, time: 2, }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1" }, }, ]), prompt: async () => undefined, messages: async () => { throw new Error("transient messages lookup failure"); }, } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; const result = await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "分析管网瓶颈", write: (event, data) => events.push({ event, data }), }); expect(result.failed).toBe(false); expect( events .filter((item) => item.event === "token") .map((item) => item.data.content) .join(""), ).toBe("最终分析结果。"); }); it("keeps reasoning generic while preserving tool execution details", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "message.part.updated", properties: { sessionID: "runtime-session-1", part: { id: "reasoning-part-1", sessionID: "runtime-session-1", messageID: "assistant-1", type: "reasoning", text: "内部推理:尝试 limit=5000 并读取临时路径。", time: { start: 1, end: 2 }, }, time: 2, }, }, { type: "message.part.delta", properties: { sessionID: "runtime-session-1", messageID: "assistant-1", partID: "reasoning-part-1", field: "text", delta: "内部推理:尝试 limit=5000 并读取临时路径。", }, }, { type: "message.part.updated", properties: { sessionID: "runtime-session-1", part: { id: "tool-part-1", sessionID: "runtime-session-1", messageID: "assistant-1", type: "tool", callID: "call-1", tool: "tjwater_cli", state: { status: "error", input: { command: "network get-all-pipes-properties --limit 5000", reason: "尝试突破分页限制", }, error: "HTTP_422 raw backend payload with trace_id=secret-trace", time: { start: 1, end: 2 }, }, }, time: 2, }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1" }, }, ]), prompt: async () => undefined, messages: async () => [], } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "分析管网瓶颈", write: (event, data) => events.push({ event, data }), }); const reasoningProgress = events.find( (item) => item.event === "progress" && item.data.id === "reasoning-part-1", ); const toolProgress = events.find( (item) => item.event === "progress" && item.data.id === "tool-part-1", ); expect(reasoningProgress?.data.detail).toBe("分析步骤已整理完成。"); expect(toolProgress?.data.detail).toBe( "tjwater_cli 调用失败;调用原因:尝试突破分页限制;关键参数:command=network get-all-pipes-properties --limit 5000;错误:HTTP_422 raw backend payload with trace_id=secret-trace", ); }); it("forwards opencode permission requests as SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.asked", properties: { id: "perm-1", sessionID: "runtime-session-1", permission: "bash", patterns: ["rm *"], metadata: { command: "rm tmp.txt" }, always: ["rm *"], }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1", }, }, ]), prompt: async () => undefined, messages: async () => [], } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "delete temp", write: (event, data) => events.push({ event, data }), }); const permissionEvent = events.find((item) => item.event === "permission_request"); expect(permissionEvent?.data).toMatchObject({ session_id: "client-session-1", request_id: "perm-1", permission: "bash", patterns: ["rm *"], target: "rm tmp.txt", always: ["rm *"], } satisfies Partial); }); it("auto approves an allowlisted low-risk permission once", async () => { const replies: Array> = []; const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.asked", properties: { id: "perm-1", sessionID: "runtime-session-1", permission: "show_chart", patterns: ["*"], metadata: {}, always: ["*"], }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1", }, }, ]), prompt: async () => undefined, messages: async () => [], replyPermission: async (options: Record) => { replies.push(options); }, } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "run tests", approvalMode: "auto", write: (event, data) => events.push({ event, data }), }); expect(replies).toEqual([ { requestId: "perm-1", sessionId: "runtime-session-1", reply: "once", }, ]); expect(events.some((item) => item.event === "permission_request")).toBe(false); expect(events.find((item) => item.event === "permission_response")?.data).toEqual({ session_id: "client-session-1", request_id: "perm-1", reply: "once", }); }); it("keeps high-risk permissions interactive in auto mode", async () => { const replies: Array> = []; const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.asked", properties: { id: "perm-auto-bash", sessionID: "runtime-session-1", permission: "bash", patterns: ["npm test"], metadata: { command: "npm test" }, always: ["npm test"], }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1" } }, ]), prompt: async () => undefined, messages: async () => [], replyPermission: async (options: Record) => replies.push(options), } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "run tests", approvalMode: "auto", write: (event, data) => events.push({ event, data }), }); expect(replies).toEqual([]); expect(events.find((item) => item.event === "permission_request")?.data).toMatchObject({ request_id: "perm-auto-bash", permission: "bash", }); }); it("approves every OpenCode ask once in always mode", async () => { const replies: Array> = []; const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.asked", properties: { id: "perm-always-bash", sessionID: "runtime-session-1", permission: "bash", patterns: ["npm test"], metadata: { command: "npm test" }, always: ["npm test"], }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1" } }, ]), prompt: async () => undefined, messages: async () => [], replyPermission: async (options: Record) => replies.push(options), } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "run tests", approvalMode: "always", write: (event, data) => events.push({ event, data }), }); expect(replies).toEqual([ { requestId: "perm-always-bash", sessionId: "runtime-session-1", reply: "once", }, ]); expect(events.some((item) => item.event === "permission_request")).toBe(false); }); it("forwards opencode v2 permission requests as SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.v2.asked", properties: { id: "perm-v2-1", sessionID: "runtime-session-1", action: "external_directory", resources: ["/tmp"], save: ["/tmp"], metadata: { path: "/tmp" }, }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1", }, }, ]), prompt: async () => undefined, messages: async () => [], } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "read /tmp", write: (event, data) => events.push({ event, data }), }); const permissionEvent = events.find((item) => item.event === "permission_request"); expect(permissionEvent?.data).toMatchObject({ session_id: "client-session-1", request_id: "perm-v2-1", permission: "external_directory", patterns: ["/tmp"], target: "/tmp", always: ["/tmp"], } satisfies Partial); }); it("forwards opencode question requests and replies as SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "question.asked", properties: { id: "question-1", sessionID: "runtime-session-1", questions: [ { header: "范围", question: "选择分析范围", options: [{ label: "城区", description: "中心城区" }], multiple: false, custom: true, }, ], }, }, { type: "question.replied", properties: { sessionID: "runtime-session-1", requestID: "question-1", answers: [["城区", "补充说明"]], }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1", }, }, ]), prompt: async () => undefined, messages: async () => [], } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "ask", write: (event, data) => events.push({ event, data }), }); expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({ session_id: "client-session-1", request_id: "question-1", questions: [ { header: "范围", question: "选择分析范围", options: [{ label: "城区", description: "中心城区" }], multiple: false, custom: true, }, ], }); expect(events.find((item) => item.event === "question_response")?.data).toEqual({ session_id: "client-session-1", request_id: "question-1", answers: [["城区", "补充说明"]], }); }); it("converts question tool parts into question request SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "message.part.updated", properties: { sessionID: "runtime-session-1", part: { id: "tool-part-1", sessionID: "runtime-session-1", messageID: "message-1", type: "tool", callID: "call-1", tool: "question", state: { status: "running", input: { questions: [ { question: "你觉得这个 question 工具好用吗?", header: "测试问题", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], }, time: { start: Date.now() }, }, }, time: Date.now(), }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1", }, }, ]), prompt: async () => undefined, messages: async () => [], } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "ask", write: (event, data) => events.push({ event, data }), }); expect(events.find((item) => item.event === "question_request")?.data).toMatchObject({ session_id: "client-session-1", request_id: "call-1", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], tool: { messageID: "message-1", callID: "call-1", }, }); expect( events.some( (item) => item.event === "tool_call" && item.data.tool === "question", ), ).toBe(false); }); it("forwards todo updates as structured SSE payloads and progress", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "todo.updated", properties: { sessionID: "runtime-session-1", todos: [ { content: "分析水位", status: "completed", priority: "high" }, { content: "生成建议", status: "in_progress", priority: "medium" }, ], }, }, { type: "session.idle", properties: { sessionID: "runtime-session-1", }, }, ]), prompt: async () => undefined, messages: async () => [], } as unknown as OpencodeRuntimeAdapter; const events: Array<{ event: string; data: Record }> = []; await streamPromptResponse({ runtime, sessionId: "runtime-session-1", clientSessionId: "client-session-1", message: "plan", write: (event, data) => events.push({ event, data }), }); expect( events.find( (item) => item.event === "progress" && item.data.id === "todo-progress", )?.data, ).toMatchObject({ id: "todo-progress", phase: "planning", title: "计划进度 1/2", }); expect(events.find((item) => item.event === "todo_update")?.data).toMatchObject({ session_id: "client-session-1", todos: [ expect.objectContaining({ content: "分析水位", status: "completed", priority: "high", }), expect.objectContaining({ content: "生成建议", status: "in_progress", priority: "medium", }), ], }); }); });