Add ACP smoke test with mock agent
Spawns a stdio mock that speaks the Agent Client Protocol and drives the real AcpClient through initialize -> session/new -> session/prompt, with streaming agent_message_chunk, tool_call, and an inbound fs/read_text_file round-trip. Wired into CI and into npm run test:smoke. Also widens contentText to accept string, single content object, or an array of content blocks, so minor variations in agent payload shape do not silently drop streamed output. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EUvyohLND97Xxew23jN2d
paydii committed
Jun 25, 2026 at 17:39 UTC
51732161eee3572e4e0c3c947e224fa0cd3be307
6 files changed
+254
-1
.github/workflows/ci.yml
+3
@@ -29,3 +29,6 @@ jobs:
29
30
- name: Build
31
run: npm run build
32
+
33
+ - name: Smoke test (ACP round-trip)
34
+ run: npm run test:smoke
.vscodeignore
+1
@@ -1,6 +1,7 @@
1
.vscode/**
2
.github/**
3
src/**
4
+test/**
5
node_modules/**
6
**/*.ts
7
**/*.map
package.json
+1
@@ -153,6 +153,7 @@
153
"watch": "node esbuild.js --watch",
154
"compile": "tsc --noEmit",
155
"lint": "eslint src --ext ts",
156
+ "test:smoke": "node test/smoke.mjs",
157
"package": "vsce package"
158
},
159
"devDependencies": {
src/chatView.ts
+4
-1
@@ -271,9 +271,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider {
271
if (typeof content === "string") {
272
return content;
273
}
274
+ if (Array.isArray(content)) {
275
+ return content.map((c) => this.contentText(c)).join("");
276
+ }
277
if (content && typeof content === "object") {
278
const c = content as { type?: string; text?: string };
276
- if (c.type === "text" && typeof c.text === "string") {
279
+ if (typeof c.text === "string") {
280
return c.text;
281
}
282
}
test/mock-agent/mock-agent.mjs
new
+148
@@ -0,0 +1,148 @@
1
+#!/usr/bin/env node
2
+// A minimal Agent Client Protocol agent used by the smoke test.
3
+// Reads newline-delimited JSON-RPC 2.0 from stdin and writes the same on stdout.
4
+// Implements just enough of the protocol to exercise the extension's client:
5
+// - initialize → returns protocolVersion + agent capabilities
6
+// - session/new → returns a session id
7
+// - session/prompt → streams two agent_message_chunk updates, then a
8
+// tool_call update, then resolves with stopReason
9
+// - session/cancel → no-op (notification)
10
+// Also exercises inbound agent→client calls: fs/read_text_file before responding.
11
+
12
+import readline from "node:readline";
13
+
14
+const rl = readline.createInterface({ input: process.stdin });
15
+
16
+let sessionCounter = 0;
17
+let nextId = 1;
18
+const pending = new Map();
19
+
20
+function send(obj) {
21
+ process.stdout.write(JSON.stringify(obj) + "\n");
22
+}
23
+
24
+function notify(method, params) {
25
+ send({ jsonrpc: "2.0", method, params });
26
+}
27
+
28
+function request(method, params) {
29
+ const id = nextId++;
30
+ return new Promise((resolve, reject) => {
31
+ pending.set(id, { resolve, reject });
32
+ send({ jsonrpc: "2.0", id, method, params });
33
+ });
34
+}
35
+
36
+async function handlePrompt(params) {
37
+ const { sessionId, prompt } = params;
38
+ const userText = (prompt?.[0]?.text ?? "").trim();
39
+
40
+ // Demonstrate an inbound agent→client request (fs/read_text_file).
41
+ try {
42
+ await request("fs/read_text_file", { path: "package.json" });
43
+ } catch {
44
+ // ignore — smoke test cares about the round-trip, not the contents
45
+ }
46
+
47
+ notify("session/update", {
48
+ sessionId,
49
+ update: {
50
+ sessionUpdate: "agent_message_chunk",
51
+ content: { type: "text", text: `echo: ${userText}` }
52
+ }
53
+ });
54
+
55
+ notify("session/update", {
56
+ sessionId,
57
+ update: {
58
+ sessionUpdate: "agent_message_chunk",
59
+ content: { type: "text", text: " (done)" }
60
+ }
61
+ });
62
+
63
+ notify("session/update", {
64
+ sessionId,
65
+ update: {
66
+ sessionUpdate: "tool_call",
67
+ toolCallId: "tc_1",
68
+ title: "noop_tool",
69
+ status: "completed"
70
+ }
71
+ });
72
+
73
+ return { stopReason: "end_turn" };
74
+}
75
+
76
+const requestHandlers = {
77
+ initialize: () => ({
78
+ protocolVersion: 1,
79
+ agentCapabilities: { promptCapabilities: { embeddedContext: true } }
80
+ }),
81
+ "session/new": () => {
82
+ sessionCounter += 1;
83
+ return { sessionId: `sess_${sessionCounter}` };
84
+ },
85
+ "session/prompt": handlePrompt
86
+};
87
+
88
+const notificationHandlers = {
89
+ "session/cancel": () => {
90
+ // accepted; no-op for the smoke test
91
+ }
92
+};
93
+
94
+rl.on("line", async (line) => {
95
+ const text = line.trim();
96
+ if (!text) {
97
+ return;
98
+ }
99
+ let msg;
100
+ try {
101
+ msg = JSON.parse(text);
102
+ } catch {
103
+ return;
104
+ }
105
+
106
+ // Response to one of our outbound requests
107
+ if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
108
+ const pendingEntry = pending.get(msg.id);
109
+ if (pendingEntry) {
110
+ pending.delete(msg.id);
111
+ if (msg.error) {
112
+ pendingEntry.reject(new Error(msg.error.message));
113
+ } else {
114
+ pendingEntry.resolve(msg.result);
115
+ }
116
+ }
117
+ return;
118
+ }
119
+
120
+ // Inbound request
121
+ if (msg.id !== undefined && msg.method) {
122
+ const handler = requestHandlers[msg.method];
123
+ if (!handler) {
124
+ send({
125
+ jsonrpc: "2.0",
126
+ id: msg.id,
127
+ error: { code: -32601, message: `Method not found: ${msg.method}` }
128
+ });
129
+ return;
130
+ }
131
+ try {
132
+ const result = await handler(msg.params);
133
+ send({ jsonrpc: "2.0", id: msg.id, result });
134
+ } catch (err) {
135
+ send({
136
+ jsonrpc: "2.0",
137
+ id: msg.id,
138
+ error: { code: -32603, message: err.message ?? "Internal error" }
139
+ });
140
+ }
141
+ return;
142
+ }
143
+
144
+ // Inbound notification
145
+ if (msg.method) {
146
+ notificationHandlers[msg.method]?.(msg.params);
147
+ }
148
+});
test/smoke.mjs
new
+97
@@ -0,0 +1,97 @@
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
+}