sandbox-agent/sdks/acp-http-client/tests/smoke.test.ts
Nathan Flurry 76586f409f
Add ACP permission mode support to the SDK (#224)
* chore: recover hamburg workspace state

* chore: drop workspace context files

* refactor: generalize permissions example

* refactor: parse permissions example flags

* docs: clarify why fs and terminal stay native

* feat: add interactive permission prompt UI to Inspector

Add permission request handling to the Inspector UI so users can
Allow, Always Allow, or Reject tool calls that require permissions
instead of having them auto-cancelled. Wires up SDK
onPermissionRequest/respondPermission through App → ChatPanel →
ChatMessages with proper toolCallId-to-pendingId mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent permission reply from silently escalating "once" to "always"

Remove allow_always from the fallback chain when the user replies "once",
aligning with the ACP spec which says "map by option kind first" with no
fallback for allow_once. Also fix Inspector to use rawSend, revert
hydration guard to accept empty configOptions, and handle respondPermission
errors by rejecting the pending promise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-10 21:52:43 -07:00

193 lines
5.2 KiB
TypeScript

import { describe, expect, it, beforeAll, afterAll } from "vitest";
import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { tmpdir } from "node:os";
import { AcpHttpClient, type SessionNotification } from "../src/index.ts";
import { spawnSandboxAgent, type SandboxAgentSpawnHandle } from "../../typescript/src/spawn.ts";
import { prepareMockAgentDataHome } from "../../typescript/tests/helpers/mock-agent.ts";
const __dirname = dirname(fileURLToPath(import.meta.url));
function findBinary(): string | null {
if (process.env.SANDBOX_AGENT_BIN) {
return process.env.SANDBOX_AGENT_BIN;
}
const cargoPaths = [
resolve(__dirname, "../../../target/debug/sandbox-agent"),
resolve(__dirname, "../../../target/release/sandbox-agent"),
];
for (const p of cargoPaths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
const BINARY_PATH = findBinary();
if (!BINARY_PATH) {
throw new Error(
"sandbox-agent binary not found. Build it (cargo build -p sandbox-agent) or set SANDBOX_AGENT_BIN.",
);
}
if (!process.env.SANDBOX_AGENT_BIN) {
process.env.SANDBOX_AGENT_BIN = BINARY_PATH;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function waitFor<T>(
fn: () => T | undefined | null,
timeoutMs = 5000,
stepMs = 25,
): Promise<T> {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const value = fn();
if (value !== undefined && value !== null) {
return value;
}
await sleep(stepMs);
}
throw new Error("timed out waiting for condition");
}
describe("AcpHttpClient integration", () => {
let handle: SandboxAgentSpawnHandle;
let baseUrl: string;
let token: string;
let dataHome: string;
beforeAll(async () => {
dataHome = mkdtempSync(join(tmpdir(), "acp-http-client-"));
prepareMockAgentDataHome(dataHome);
handle = await spawnSandboxAgent({
enabled: true,
log: "silent",
timeoutMs: 30000,
env: {
XDG_DATA_HOME: dataHome,
HOME: dataHome,
USERPROFILE: dataHome,
APPDATA: join(dataHome, "AppData", "Roaming"),
LOCALAPPDATA: join(dataHome, "AppData", "Local"),
},
});
baseUrl = handle.baseUrl;
token = handle.token;
});
afterAll(async () => {
await handle.dispose();
rmSync(dataHome, { recursive: true, force: true });
});
it("runs initialize/newSession/prompt against real /v1/acp/{server_id}", async () => {
const updates: SessionNotification[] = [];
const serverId = `acp-http-client-${Date.now().toString(36)}`;
const client = new AcpHttpClient({
baseUrl,
token,
transport: {
path: `/v1/acp/${encodeURIComponent(serverId)}`,
bootstrapQuery: { agent: "mock" },
},
client: {
sessionUpdate: async (notification) => {
updates.push(notification);
},
},
});
const initialize = await client.initialize();
expect(initialize.protocolVersion).toBeTruthy();
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
expect(session.sessionId).toBeTruthy();
const prompt = await client.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "acp package integration" }],
});
expect(prompt.stopReason).toBe("end_turn");
await waitFor(() => {
const text = updates
.flatMap((entry) => {
if (entry.update.sessionUpdate !== "agent_message_chunk") {
return [];
}
const content = entry.update.content;
if (content.type !== "text") {
return [];
}
return [content.text];
})
.join("");
return text.includes("mock: acp package integration") ? text : undefined;
});
await client.disconnect();
});
it("answers session/request_permission while session/prompt is still in flight", async () => {
const permissionRequests: Array<{ sessionId: string; title?: string | null }> = [];
const serverId = `acp-http-client-permissions-${Date.now().toString(36)}`;
const client = new AcpHttpClient({
baseUrl,
token,
transport: {
path: `/v1/acp/${encodeURIComponent(serverId)}`,
bootstrapQuery: { agent: "mock" },
},
client: {
requestPermission: async (request) => {
permissionRequests.push({
sessionId: request.sessionId,
title: request.toolCall.title,
});
return {
outcome: {
outcome: "selected",
optionId: "reject-once",
},
};
},
},
});
await client.initialize();
const session = await client.newSession({
cwd: process.cwd(),
mcpServers: [],
});
const prompt = await client.prompt({
sessionId: session.sessionId,
prompt: [{ type: "text", text: "please trigger permission" }],
});
expect(prompt.stopReason).toBe("end_turn");
expect(permissionRequests).toEqual([
{
sessionId: session.sessionId,
title: "Write mock.txt",
},
]);
await client.disconnect();
});
});