|
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
|
} |