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
index dacb7be..d17febc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,3 +29,6 @@ jobs: - name: Build run: npm run build + + - name: Smoke test (ACP round-trip) + run: npm run test:smoke
.vscodeignore
+1
index 52724f8..47e100c 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,6 +1,7 @@ .vscode/** .github/** src/** +test/** node_modules/** **/*.ts **/*.map
package.json
+1
index 426b669..1af5ea1 100644 --- a/package.json +++ b/package.json @@ -153,6 +153,7 @@ "watch": "node esbuild.js --watch", "compile": "tsc --noEmit", "lint": "eslint src --ext ts", + "test:smoke": "node test/smoke.mjs", "package": "vsce package" }, "devDependencies": {
src/chatView.ts
+4 -1
index 4b68d04..7798993 100644 --- a/src/chatView.ts +++ b/src/chatView.ts @@ -271,9 +271,12 @@ export class ChatViewProvider implements vscode.WebviewViewProvider { if (typeof content === "string") { return content; } + if (Array.isArray(content)) { + return content.map((c) => this.contentText(c)).join(""); + } if (content && typeof content === "object") { const c = content as { type?: string; text?: string }; - if (c.type === "text" && typeof c.text === "string") { + if (typeof c.text === "string") { return c.text; } }
test/mock-agent/mock-agent.mjs
+148
new file mode 100644 index 0000000..cb72115 --- /dev/null +++ b/test/mock-agent/mock-agent.mjs @@ -0,0 +1,148 @@ +#!/usr/bin/env node +// A minimal Agent Client Protocol agent used by the smoke test. +// Reads newline-delimited JSON-RPC 2.0 from stdin and writes the same on stdout. +// Implements just enough of the protocol to exercise the extension's client: +// - initialize → returns protocolVersion + agent capabilities +// - session/new → returns a session id +// - session/prompt → streams two agent_message_chunk updates, then a +// tool_call update, then resolves with stopReason +// - session/cancel → no-op (notification) +// Also exercises inbound agent→client calls: fs/read_text_file before responding. + +import readline from "node:readline"; + +const rl = readline.createInterface({ input: process.stdin }); + +let sessionCounter = 0; +let nextId = 1; +const pending = new Map(); + +function send(obj) { + process.stdout.write(JSON.stringify(obj) + "\n"); +} + +function notify(method, params) { + send({ jsonrpc: "2.0", method, params }); +} + +function request(method, params) { + const id = nextId++; + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + send({ jsonrpc: "2.0", id, method, params }); + }); +} + +async function handlePrompt(params) { + const { sessionId, prompt } = params; + const userText = (prompt?.[0]?.text ?? "").trim(); + + // Demonstrate an inbound agent→client request (fs/read_text_file). + try { + await request("fs/read_text_file", { path: "package.json" }); + } catch { + // ignore — smoke test cares about the round-trip, not the contents + } + + notify("session/update", { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `echo: ${userText}` } + } + }); + + notify("session/update", { + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: " (done)" } + } + }); + + notify("session/update", { + sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "tc_1", + title: "noop_tool", + status: "completed" + } + }); + + return { stopReason: "end_turn" }; +} + +const requestHandlers = { + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { promptCapabilities: { embeddedContext: true } } + }), + "session/new": () => { + sessionCounter += 1; + return { sessionId: `sess_${sessionCounter}` }; + }, + "session/prompt": handlePrompt +}; + +const notificationHandlers = { + "session/cancel": () => { + // accepted; no-op for the smoke test + } +}; + +rl.on("line", async (line) => { + const text = line.trim(); + if (!text) { + return; + } + let msg; + try { + msg = JSON.parse(text); + } catch { + return; + } + + // Response to one of our outbound requests + if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) { + const pendingEntry = pending.get(msg.id); + if (pendingEntry) { + pending.delete(msg.id); + if (msg.error) { + pendingEntry.reject(new Error(msg.error.message)); + } else { + pendingEntry.resolve(msg.result); + } + } + return; + } + + // Inbound request + if (msg.id !== undefined && msg.method) { + const handler = requestHandlers[msg.method]; + if (!handler) { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32601, message: `Method not found: ${msg.method}` } + }); + return; + } + try { + const result = await handler(msg.params); + send({ jsonrpc: "2.0", id: msg.id, result }); + } catch (err) { + send({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32603, message: err.message ?? "Internal error" } + }); + } + return; + } + + // Inbound notification + if (msg.method) { + notificationHandlers[msg.method]?.(msg.params); + } +});
test/smoke.mjs
+97
new file mode 100644 index 0000000..ef10851 --- /dev/null +++ b/test/smoke.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +// End-to-end smoke test for the ACP client. +// +// Spawns the mock ACP agent and drives the real AcpClient through the full +// handshake → session → prompt round-trip. Verifies that streaming updates +// reach the host, the inbound fs/read_text_file call is honored, and the +// prompt resolves with the agent's stop reason. +// +// Run: node test/smoke.mjs + +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; +import { build } from "esbuild"; +import { createRequire } from "node:module"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, ".."); +const mockAgent = join(__dirname, "mock-agent", "mock-agent.mjs"); + +// Bundle the AcpClient on the fly so we can import the TypeScript source +// without a separate build step. +const bundle = await build({ + entryPoints: [join(repoRoot, "src", "acp", "client.ts")], + bundle: true, + write: false, + format: "cjs", + platform: "node", + target: "node18", + external: ["vscode"], + logLevel: "silent" +}); + +const code = bundle.outputFiles[0].text; +const requireFromTest = createRequire(import.meta.url); + +// Evaluate the bundle in a fresh module context. +const Module = requireFromTest("node:module"); +const wrapped = Module.wrap(code); +const compiled = eval(wrapped); +const moduleObj = { exports: {} }; +compiled(moduleObj.exports, requireFromTest, moduleObj, "client.bundle.js", repoRoot); +const { AcpClient } = moduleObj.exports; + +function assert(cond, msg) { + if (!cond) { + console.error(`FAIL: ${msg}`); + process.exit(1); + } +} + +const events = []; +let fsReadCalled = false; + +const client = new AcpClient({ + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async (params) => { + fsReadCalled = true; + return `// pretend contents of ${params.path}\n`; + }, + writeTextFile: async () => { + // no writes in this scenario + } +}); + +client.on("update", (u) => events.push(u)); +client.on("error", (err) => { + console.error("client error:", err.message); + process.exit(1); +}); + +client.spawn({ command: process.execPath, args: [mockAgent], cwd: repoRoot, env: {} }); + +try { + const sessionId = await client.initialize(repoRoot); + assert(typeof sessionId === "string" && sessionId.startsWith("sess_"), "got sessionId"); + console.log(`OK initialize → session ${sessionId}`); + + const stopReason = await client.prompt("hello world"); + assert(stopReason === "end_turn", `stopReason was "${stopReason}"`); + console.log(`OK prompt → stopReason ${stopReason}`); + + assert(fsReadCalled, "agent's fs/read_text_file request reached the host"); + console.log("OK inbound fs/read_text_file round-tripped"); + + const chunks = events.filter((e) => e.update?.sessionUpdate === "agent_message_chunk"); + const text = chunks.map((c) => c.update.content.text).join(""); + assert(text === "echo: hello world (done)", `streamed text was "${text}"`); + console.log(`OK streamed ${chunks.length} message chunks → "${text}"`); + + const tools = events.filter((e) => e.update?.sessionUpdate === "tool_call"); + assert(tools.length === 1, `expected 1 tool_call update, got ${tools.length}`); + console.log(`OK received tool_call update`); + + console.log("\nALL SMOKE CHECKS PASSED"); +} finally { + client.dispose(); +}