|
| 1 | +import { mkdtempSync, rmSync } from "node:fs"; |
| 2 | +import { tmpdir } from "node:os"; |
| 3 | +import { join } from "node:path"; |
| 4 | +import { PassThrough, Readable, Writable } from "node:stream"; |
| 5 | +import { fileURLToPath } from "node:url"; |
| 6 | +import * as acp from "@agentclientprotocol/sdk"; |
| 7 | +import type { Model } from "@earendil-works/pi-ai"; |
| 8 | +import { fauxAssistantMessage, fauxToolCall, registerFauxProvider } from "@earendil-works/pi-ai"; |
| 9 | +import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 10 | +import { runAcpServer } from "./server.js"; |
| 11 | + |
| 12 | +const MOCK_MCP = fileURLToPath(new URL("../mcp/__test__/mock-server.mjs", import.meta.url)); |
| 13 | + |
| 14 | +describe("runAcpServer", () => { |
| 15 | + let faux: ReturnType<typeof registerFauxProvider>; |
| 16 | + let model: Model<string>; |
| 17 | + let home: string; |
| 18 | + let cwd: string; |
| 19 | + let previousHome: string | undefined; |
| 20 | + |
| 21 | + beforeEach(() => { |
| 22 | + previousHome = process.env.HOME; |
| 23 | + home = mkdtempSync(join(tmpdir(), "acp-home-")); |
| 24 | + cwd = mkdtempSync(join(tmpdir(), "acp-cwd-")); |
| 25 | + process.env.HOME = home; |
| 26 | + process.env.CODEBASE_NO_AUTO_MEMORY = "1"; |
| 27 | + faux = registerFauxProvider({ |
| 28 | + models: [ |
| 29 | + { |
| 30 | + id: "test-model", |
| 31 | + name: "Test Model", |
| 32 | + reasoning: false, |
| 33 | + input: ["text"], |
| 34 | + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, |
| 35 | + contextWindow: 100_000, |
| 36 | + maxTokens: 4096, |
| 37 | + }, |
| 38 | + ], |
| 39 | + tokenSize: { min: 1, max: 2 }, |
| 40 | + }); |
| 41 | + model = faux.models[0] as Model<string>; |
| 42 | + }); |
| 43 | + |
| 44 | + afterEach(() => { |
| 45 | + faux.unregister(); |
| 46 | + rmSync(home, { recursive: true, force: true }); |
| 47 | + rmSync(cwd, { recursive: true, force: true }); |
| 48 | + if (previousHome === undefined) delete process.env.HOME; |
| 49 | + else process.env.HOME = previousHome; |
| 50 | + delete process.env.CODEBASE_NO_AUTO_MEMORY; |
| 51 | + }); |
| 52 | + |
| 53 | + it("runs a Buzz-compatible ACP turn with injected MCP tools and streamed updates", async () => { |
| 54 | + faux.setResponses([ |
| 55 | + fauxAssistantMessage([fauxToolCall("mcp__buzz__echo", { text: "from Buzz" })], { |
| 56 | + stopReason: "toolUse", |
| 57 | + }), |
| 58 | + fauxAssistantMessage("posted through Buzz"), |
| 59 | + ]); |
| 60 | + |
| 61 | + const toAgent = new PassThrough(); |
| 62 | + const fromAgent = new PassThrough(); |
| 63 | + const serverDone = runAcpServer({ |
| 64 | + stdin: toAgent, |
| 65 | + stdout: fromAgent, |
| 66 | + configOverride: { model, apiKey: "faux-key", source: "byok" }, |
| 67 | + }); |
| 68 | + const updates: acp.SessionNotification[] = []; |
| 69 | + const permissionRequests: acp.RequestPermissionRequest[] = []; |
| 70 | + const stream = acp.ndJsonStream(Writable.toWeb(toAgent), Readable.toWeb(fromAgent) as ReadableStream<Uint8Array>); |
| 71 | + |
| 72 | + const result = await acp |
| 73 | + .client({ name: "buzz-test" }) |
| 74 | + .onNotification(acp.methods.client.session.update, (ctx) => { |
| 75 | + updates.push(ctx.params); |
| 76 | + }) |
| 77 | + .onRequest(acp.methods.client.session.requestPermission, (ctx) => { |
| 78 | + permissionRequests.push(ctx.params); |
| 79 | + const allow = ctx.params.options.find((option) => option.kind === "allow_once"); |
| 80 | + if (!allow) return { outcome: { outcome: "cancelled" as const } }; |
| 81 | + return { outcome: { outcome: "selected" as const, optionId: allow.optionId } }; |
| 82 | + }) |
| 83 | + .connectWith(stream, async (client) => { |
| 84 | + const initialized = await client.request(acp.methods.agent.initialize, { |
| 85 | + protocolVersion: acp.PROTOCOL_VERSION, |
| 86 | + clientInfo: { name: "buzz-acp", version: "test" }, |
| 87 | + }); |
| 88 | + expect(initialized.protocolVersion).toBe(acp.PROTOCOL_VERSION); |
| 89 | + expect(initialized.agentInfo?.name).toBe("Codebase"); |
| 90 | + |
| 91 | + const session = await client.request(acp.methods.agent.session.new, { |
| 92 | + cwd, |
| 93 | + mcpServers: [ |
| 94 | + { |
| 95 | + name: "buzz", |
| 96 | + command: process.execPath, |
| 97 | + args: [MOCK_MCP], |
| 98 | + env: [], |
| 99 | + }, |
| 100 | + ], |
| 101 | + }); |
| 102 | + expect(session.configOptions?.[0]).toMatchObject({ |
| 103 | + id: "model", |
| 104 | + category: "model", |
| 105 | + currentValue: "test-model", |
| 106 | + }); |
| 107 | + await client.request(acp.methods.agent.session.setConfigOption, { |
| 108 | + sessionId: session.sessionId, |
| 109 | + configId: "model", |
| 110 | + value: "test-model", |
| 111 | + }); |
| 112 | + return client.request(acp.methods.agent.session.prompt, { |
| 113 | + sessionId: session.sessionId, |
| 114 | + prompt: [{ type: "text", text: "Reply through the Buzz tool." }], |
| 115 | + }); |
| 116 | + }); |
| 117 | + |
| 118 | + expect(result.stopReason).toBe("end_turn"); |
| 119 | + expect(permissionRequests).toHaveLength(1); |
| 120 | + expect(permissionRequests[0]?.toolCall.name).toBe("mcp__buzz__echo"); |
| 121 | + expect(updates.some((event) => event.update.sessionUpdate === "tool_call")).toBe(true); |
| 122 | + expect(updates.some((event) => event.update.sessionUpdate === "tool_call_update")).toBe(true); |
| 123 | + const text = updates |
| 124 | + .filter( |
| 125 | + ( |
| 126 | + event, |
| 127 | + ): event is acp.SessionNotification & { |
| 128 | + update: { sessionUpdate: "agent_message_chunk"; content: { type: "text"; text: string } }; |
| 129 | + } => event.update.sessionUpdate === "agent_message_chunk" && event.update.content.type === "text", |
| 130 | + ) |
| 131 | + .map((event) => event.update.content.text) |
| 132 | + .join(""); |
| 133 | + expect(text).toContain("posted through Buzz"); |
| 134 | + |
| 135 | + toAgent.end(); |
| 136 | + await serverDone; |
| 137 | + }); |
| 138 | +}); |
0 commit comments