| 1 | #!/usr/bin/env node |
| 2 | // End-to-end smoke test for the ACP client. |
| 3 | // |
| 4 | // Spawns the mock ACP agent and drives the real AcpClient through the full |
| 5 | // handshake → session → prompt round-trip. Verifies that streaming updates |
| 6 | // reach the host, the inbound fs/read_text_file call is honored, and the |
| 7 | // prompt resolves with the agent's stop reason. |
| 8 | // |
| 9 | // Run: node test/smoke.mjs |
| 10 | |
| 11 | import { fileURLToPath } from "node:url"; |
| 12 | import { dirname, join } from "node:path"; |
| 13 | import { build } from "esbuild"; |
| 14 | import { createRequire } from "node:module"; |
| 15 | |
| 16 | const __dirname = dirname(fileURLToPath(import.meta.url)); |
| 17 | const repoRoot = join(__dirname, ".."); |
| 18 | const mockAgent = join(__dirname, "mock-agent", "mock-agent.mjs"); |
| 19 | |
| 20 | // Bundle the AcpClient on the fly so we can import the TypeScript source |
| 21 | // without a separate build step. |
| 22 | const bundle = await build({ |
| 23 | entryPoints: [join(repoRoot, "src", "acp", "client.ts")], |
| 24 | bundle: true, |
| 25 | write: false, |
| 26 | format: "cjs", |
| 27 | platform: "node", |
| 28 | target: "node18", |
| 29 | external: ["vscode"], |
| 30 | logLevel: "silent" |
| 31 | }); |
| 32 | |
| 33 | const code = bundle.outputFiles[0].text; |
| 34 | const requireFromTest = createRequire(import.meta.url); |
| 35 | |
| 36 | // Evaluate the bundle in a fresh module context. |
| 37 | const Module = requireFromTest("node:module"); |
| 38 | const wrapped = Module.wrap(code); |
| 39 | const compiled = eval(wrapped); |
| 40 | const moduleObj = { exports: {} }; |
| 41 | compiled(moduleObj.exports, requireFromTest, moduleObj, "client.bundle.js", repoRoot); |
| 42 | const { AcpClient } = moduleObj.exports; |
| 43 | |
| 44 | function assert(cond, msg) { |
| 45 | if (!cond) { |
| 46 | console.error(`FAIL: ${msg}`); |
| 47 | process.exit(1); |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | const events = []; |
| 52 | let fsReadCalled = false; |
| 53 | |
| 54 | const client = new AcpClient({ |
| 55 | requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), |
| 56 | readTextFile: async (params) => { |
| 57 | fsReadCalled = true; |
| 58 | return `// pretend contents of ${params.path}\n`; |
| 59 | }, |
| 60 | writeTextFile: async () => { |
| 61 | // no writes in this scenario |
| 62 | } |
| 63 | }); |
| 64 | |
| 65 | client.on("update", (u) => events.push(u)); |
| 66 | client.on("error", (err) => { |
| 67 | console.error("client error:", err.message); |
| 68 | process.exit(1); |
| 69 | }); |
| 70 | |
| 71 | client.spawn({ command: process.execPath, args: [mockAgent], cwd: repoRoot, env: {} }); |
| 72 | |
| 73 | try { |
| 74 | const sessionId = await client.initialize(repoRoot); |
| 75 | assert(typeof sessionId === "string" && sessionId.startsWith("sess_"), "got sessionId"); |
| 76 | console.log(`OK initialize → session ${sessionId}`); |
| 77 | |
| 78 | const stopReason = await client.prompt("hello world"); |
| 79 | assert(stopReason === "end_turn", `stopReason was "${stopReason}"`); |
| 80 | console.log(`OK prompt → stopReason ${stopReason}`); |
| 81 | |
| 82 | assert(fsReadCalled, "agent's fs/read_text_file request reached the host"); |
| 83 | console.log("OK inbound fs/read_text_file round-tripped"); |
| 84 | |
| 85 | const chunks = events.filter((e) => e.update?.sessionUpdate === "agent_message_chunk"); |
| 86 | const text = chunks.map((c) => c.update.content.text).join(""); |
| 87 | assert(text === "echo: hello world (done)", `streamed text was "${text}"`); |
| 88 | console.log(`OK streamed ${chunks.length} message chunks → "${text}"`); |
| 89 | |
| 90 | const tools = events.filter((e) => e.update?.sessionUpdate === "tool_call"); |
| 91 | assert(tools.length === 1, `expected 1 tool_call update, got ${tools.length}`); |
| 92 | console.log(`OK received tool_call update`); |
| 93 | |
| 94 | console.log("\nALL SMOKE CHECKS PASSED"); |
| 95 | } finally { |
| 96 | client.dispose(); |
| 97 | } |