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", 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 *"], metadata: { command: "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", properties: { id: "perm-1", 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-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 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"], metadata: { path: "/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", }), ], }); }); });