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();
+}