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