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("forwards opencode permission requests as SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.asked", sessionId: "runtime-session-1", request: { id: "perm-1", action: "shell", resources: ["rm *"], metadata: { command: "rm tmp.txt" }, save: ["rm *"], }, }, { type: "session.execution.succeeded", 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: "shell", patterns: ["rm *"], target: "rm tmp.txt", always: ["rm *"], } satisfies Partial); }); it("auto replies always when approval mode is always", async () => { const replies: Array> = []; const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.asked", sessionId: "runtime-session-1", request: { id: "perm-1", action: "shell", resources: ["npm test"], metadata: { command: "npm test" }, save: ["npm test"], }, }, { type: "session.execution.succeeded", 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-1", sessionId: "runtime-session-1", reply: "always", }, ]); 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: "always", }); }); it("forwards external-directory V2 permission requests as SSE payloads", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "permission.asked", sessionId: "runtime-session-1", request: { id: "perm-v2-1", action: "external_directory", resources: ["/tmp"], save: ["/tmp"], metadata: { path: "/tmp" }, }, }, { type: "session.execution.succeeded", 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", sessionId: "runtime-session-1", request: { id: "question-1", questions: [ { header: "范围", question: "选择分析范围", options: [{ label: "城区", description: "中心城区" }], multiple: false, custom: true, }, ], }, }, { type: "question.replied", sessionId: "runtime-session-1", requestId: "question-1", answers: [["城区", "补充说明"]], }, { type: "session.execution.succeeded", 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("waits for the Form event instead of exposing the question tool call ID", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "tool.updated", sessionId: "runtime-session-1", part: { id: "tool-part-1", messageID: "message-1", callID: "call-1", tool: "question", state: { status: "running", input: { questions: [ { question: "你觉得这个 question 工具好用吗?", header: "测试问题", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], }, }, }, }, { type: "question.asked", sessionId: "runtime-session-1", request: { id: "frm_1", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], }, }, { type: "session.execution.succeeded", 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: "frm_1", questions: [ { header: "测试问题", question: "你觉得这个 question 工具好用吗?", options: [ { label: "非常好用", description: "交互清晰,选项方便", }, ], }, ], }); expect( events.some( (item) => item.event === "question_request" && item.data.request_id === "call-1", ), ).toBe(false); expect( events.some( (item) => item.event === "tool_call" && item.data.tool === "question", ), ).toBe(false); }); it("forwards normalized V2 text and reasoning deltas", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "reasoning.updated", sessionId: "runtime-session-1", partId: "reasoning-1", delta: "分析中", completed: false, }, { type: "reasoning.updated", sessionId: "runtime-session-1", partId: "reasoning-1", completed: true, }, { type: "text.delta", sessionId: "runtime-session-1", partId: "text-1", delta: "完成", }, { type: "session.execution.succeeded", 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 === "token")?.data).toEqual({ session_id: "client-session-1", content: "完成", }); expect( events.find( (item) => item.event === "progress" && item.data.id === "reasoning-1" && item.data.status === "completed", )?.data, ).toMatchObject({ phase: "planning", title: "分析规划完成", }); }); it("retains the first runtime event when prompt resolves before delivery", async () => { const runtime = { subscribeEvents: async () => ({ async *[Symbol.asyncIterator]() { await Promise.resolve(); yield { type: "text.delta", sessionId: "runtime-session-1", partId: "text-1", delta: "首个事件", }; yield { type: "session.execution.succeeded", 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: "race", write: (event, data) => events.push({ event, data }), }); expect(events.find((item) => item.event === "token")?.data.content).toBe("首个事件"); expect(events.find((item) => item.event === "done")?.data.session_id).toBe( "client-session-1", ); }); it("terminates the stream when V2 reports execution failure", async () => { const runtime = { subscribeEvents: async () => createEventStream([ { type: "session.execution.failed", sessionId: "runtime-session-1", error: { name: "ExecutionError", data: { message: "执行失败" } }, }, ]), prompt: async () => undefined, messages: async () => [], } 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: "fail", write: (event, data) => events.push({ event, data }), }); expect(events.find((item) => item.event === "error")?.data.message).toBe("执行失败"); expect(result.failed).toBe(true); }); });