diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bb9b8a37..052e78db2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Added `agent-device acp`: a deterministic stdio ACP (Agent Client Protocol) agent for Zed and other ACP clients. Prompt lines are agent-device commands in CLI syntax, executed through the same client path as MCP tools, with tool-call streaming, sticky per-session target flags, and inline screenshot images. + ## 0.15.0 - Breaking: `apps` discovery and public app-list helpers now default to user-installed apps. Use `--all` or `filter: 'all'` to include system/OEM apps. diff --git a/CONTEXT.md b/CONTEXT.md index 738622e58..bd5dc64f2 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -42,7 +42,8 @@ start-time/command reads, process listing, process-tree expansion, PID de-duplication, and best-effort signaling. It must not own domain cleanup policy such as browser ownership markers, runner lease reclamation, daemon takeover checks, or app-log PID metadata verification. -- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, and batch entrypoints. +- Command surface: catalog of public command identity, interface exposure, adapter policy, and shared command metadata across CLI, Node.js, MCP, ACP, and batch entrypoints. +- MCP/ACP adapters: MCP is the structured-tool projection of the command surface; ACP is the deterministic prompt-line projection for ACP clients. Both reuse command metadata and `AgentDeviceClient` execution, and neither owns daemon route policy. - Daemon command registry: daemon-side source of truth for command route ownership and request-policy traits, including admission exemptions, session locking, selector validation, replay-scoped actions, recording invalidation, Android dialog guards, and request provider device resolution. - Runner command traits: per-command-type classification for iOS/macOS runner lifecycle behavior, distinct from the public command surface and daemon command registry. The Swift runner traits classify interaction, read-only, and runner-lifecycle axes for XCTest execution; Swift resolves the alert command as read-only only for its `get` action. The TypeScript runner command traits classify daemon-side runner send/recovery policy such as read-only retry routing, readiness probes, and recent-healthy-mutation preflight skips; the TypeScript table is command-type keyed and currently classifies alert as read-only for daemon retry policy. Each side keeps one source of truth keyed by runner command type. - Coordinate-first resolved element activation: iOS/macOS runner interaction pattern where a selector or text query resolves the semantic `XCUIElement`, then activation uses the element's resolved center coordinate when a frame is available. This keeps target selection semantic while avoiding `XCUIElement.tap()` post-action element re-resolution after normal navigation. tvOS remains focus/remote-driven. @@ -69,8 +70,8 @@ The perfect-shape refactor is complete and merged. Its end-state: - Two derivation registries. One `CommandDescriptor` per command (`src/core/command-descriptor/registry.ts`) is the single declaration site from which the public - catalog, capability matrix, daemon command registry, batch allowlist, MCP tools, CLI schema, and - the Node client surface are *derived* by parity-tested projection; the dispatch `switch` became a + catalog, capability matrix, daemon command registry, batch allowlist, MCP tools, ACP available + commands, CLI schema, and the Node client surface are *derived* by parity-tested projection; the dispatch `switch` became a total map keyed on the command-name union (a missing handler is a compile error). One `PlatformPlugin` per platform family (`src/core/platform-plugin/`) stops core/daemon from branching on platform, with the Apple plugin the first instance. See diff --git a/README.md b/README.md index c5d46442d..39bebe0c7 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ Snapshots come from the app's accessibility tree, so high-quality labels, roles, ## Next Steps -- **Set up your agent**: run the CLI from Cursor, Codex, Claude Code, Windsurf, or another agent terminal. For skills, rules, direct MCP tools, and client-specific setup, see [AI Agent Setup](https://oss.callstack.com/agent-device/docs/agent-setup). +- **Set up your agent**: run the CLI from Cursor, Codex, Claude Code, Windsurf, or another agent terminal. For skills, rules, direct MCP tools (`agent-device mcp`), the ACP agent for Zed and other ACP clients (`agent-device acp`), and client-specific setup, see [AI Agent Setup](https://oss.callstack.com/agent-device/docs/agent-setup). - **Try the sample app**: clone the repo and run the bundled Expo fixture when you want a guided first dogfood run with screenshots, replay, and performance evidence. See [Quick Start](https://oss.callstack.com/agent-device/docs/quick-start). - **Go deeper**: use [Commands](https://oss.callstack.com/agent-device/docs/commands), [Replay & E2E](https://oss.callstack.com/agent-device/docs/replay-e2e), and [Debugging & Profiling](https://oss.callstack.com/agent-device/docs/debugging-profiling) for production workflows. diff --git a/docs/adr/0003-daemon-command-registry.md b/docs/adr/0003-daemon-command-registry.md index db927c1a3..fce128998 100644 --- a/docs/adr/0003-daemon-command-registry.md +++ b/docs/adr/0003-daemon-command-registry.md @@ -27,8 +27,8 @@ registry instead of recreating command string sets. Handler modules own executio not export duplicate coverage tables to prove route membership. The daemon registry is internal-only. It must not define CLI grammar, Node.js client options, MCP -schemas, user-facing help, or platform capability support. Those remain owned by the command -contract, projection, help, and capability modules. +schemas, ACP slash/prompt-line behavior, user-facing help, or platform capability support. Those +remain owned by the command contract, projection, help, and capability modules. ## Alternatives Considered @@ -57,9 +57,9 @@ owns the rationale so future changes do not need to infer it from agent instruct ## Update (2026-06): single-declaration / derivation model A later proposal (the `CommandDescriptor` direction, now [ADR 0008](0008-command-descriptor-registry.md)) unifies a command's -declarations so the public catalog, capability matrix, CLI/MCP projections, batch allowlist, and this -daemon registry are *derived* from one registration site, to remove the cross-table drift that several -of these surfaces are kept aligned against by convention. +declarations so the public catalog, capability matrix, CLI/MCP/ACP projections, batch allowlist, and +this daemon registry are *derived* from one registration site, to remove the cross-table drift that +several of these surfaces are kept aligned against by convention. **This ADR's decision stands.** Its boundary is about *ownership* and the *predicate interface*, not about the physical file a trait is typed in. "Separate source of truth" means separately owned and @@ -75,9 +75,10 @@ derived daemon registry is therefore permitted **only if** it preserves all of t predicates (`getDaemonCommandRoute`, `isLeaseAdmissionExempt`, `shouldLockSessionExecution`, …). The daemon registry remains their sole exposer; derivation changes how the backing table is *built*, not how it is *read*. -3. **No leakage into public projections.** The catalog/CLI/MCP/help/capability projections must be - type-prevented from reading daemon-only traits, and the daemon registry must still not define CLI - grammar, Node.js options, MCP schemas, user-facing help, or capability support. +3. **No leakage into public projections.** The catalog/CLI/MCP/ACP/help/capability projections must + be type-prevented from reading daemon-only traits, and the daemon registry must still not define + CLI grammar, Node.js options, MCP schemas, ACP prompt-line behavior, user-facing help, or + capability support. 4. **One declaration per concern, enforced by types.** The single registration site must make a missing or duplicated daemon trait a *compile error* — replacing today's "aligned by convention". This is the structural improvement that justifies derivation over a separately hand-authored table. diff --git a/docs/adr/0008-command-descriptor-registry.md b/docs/adr/0008-command-descriptor-registry.md index ceb041ad1..617953ac9 100644 --- a/docs/adr/0008-command-descriptor-registry.md +++ b/docs/adr/0008-command-descriptor-registry.md @@ -10,14 +10,16 @@ A command's identity is restated, by hand, across roughly ten tables that must s convention: `PUBLIC_COMMANDS` (`src/command-catalog.ts`), the per-command metadata and family facets (`src/commands/**`), the capability matrix (`src/core/capabilities.ts`), the daemon command registry (`src/daemon/daemon-command-registry.ts`, ADR 0003), the structured-batch allowlist -(`src/batch-policy.ts`), the MCP exposure sets, the Node client interface and impl (`src/client-types.ts`, -`src/client.ts`), and the generic-dispatch `switch` (`src/core/dispatch.ts`, whose `default: throw` makes a -missing or renamed command a runtime error, not a compile error). Adding one command touches ~24 files, the -argument shape is (de)serialized ~4 times, and the gesture set is retyped in three places. +(`src/batch-policy.ts`), the MCP exposure sets, ACP available-command projection, the Node client +interface and impl (`src/client-types.ts`, `src/client.ts`), and the generic-dispatch `switch` +(`src/core/dispatch.ts`, whose `default: throw` makes a missing or renamed command a runtime error, +not a compile error). Adding one command touches ~24 files, the argument shape is (de)serialized +~4 times, and the gesture set is retyped in three places. The codebase already proves the cure works for part of this: the `CommandFamilyFacet` -(`src/commands/family/`) derives the MCP tools, the CLI schema, and the batch writer from a single array. -It simply stops at the command-surface boundary; everything past it is hand-maintained. +(`src/commands/family/`) derives the MCP tools, ACP runnable command list, CLI schema, and the batch +writer from a single array. It simply stops at the command-surface boundary; everything past it is +hand-maintained. ADR 0003 deliberately separated daemon route/policy into its own internally-owned registry with a small predicate interface, and its 2026-06 update set four invariants that any single-declaration/derivation @@ -28,16 +30,23 @@ model must preserve. This ADR is that model. Introduce one `CommandDescriptor` per command that **composes facets owned by their domains** and from which every consumer table is **derived** by pure, parity-tested projection: -- The descriptor composes a `surface` facet (owned by `src/commands/**`: identity, CLI schema/reader, MCP), - a `capability` facet (owned by `src/core/capabilities`), and a `daemon` facet (route + request-policy - traits, **owned under `src/daemon/`** per ADR 0003), plus a typed result. -- The public catalog, capability matrix, daemon command registry, batch allowlist, MCP tool list, CLI - schema, and the Node client surface become pure projections of the descriptor set. The +- The descriptor composes a `surface` facet (owned by `src/commands/**`: identity, CLI schema/reader, + MCP, and ACP prompt-line exposure), a `capability` facet (owned by `src/core/capabilities`), and a + `daemon` facet (route + request-policy traits, **owned under `src/daemon/`** per ADR 0003), plus a + typed result. +- The public catalog, capability matrix, daemon command registry, batch allowlist, MCP tool list, ACP + available-command list, CLI schema, and the Node client surface become pure projections of the + descriptor set. The `src/core/dispatch.ts` `switch` is replaced by a total map keyed on the command-name union, so a missing handler is a compile error. - The cross-process `invoke` (client) and in-daemon `execute` seams stay distinct; the process boundary is never collapsed. +ACP is a consumer of the command surface, not a new command registry. It advertises the same runnable +commands through `available_commands_update` and parses prompt text into CLI-shaped command lines +before executing through the CLI command reader, existing command contracts, and `AgentDeviceClient`. +Its slash-command syntax is an ACP transport affordance, not a separate command identity. + This **composes with**, and is bound by, ADR 0003's four invariants: daemon-owned declaration (never inlined into the public surface), the predicate interface unchanged, no leakage of daemon-only traits into public projections, and one declaration per concern enforced by the type system. diff --git a/docs/adr/README.md b/docs/adr/README.md index e8bbedc58..60a163d89 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -9,7 +9,7 @@ | [0005 iOS Runner Interaction Lifecycle](0005-ios-runner-interaction-lifecycle.md) | XCTest runner sessions, leases, adoption, idle-stop | | [0006 Daemon RPC Protocol Version](0006-daemon-rpc-protocol-version.md) | remote daemon HTTP/JSON-RPC compatibility | | [0007 Remote Device Leases](0007-remote-device-leases.md) | leases, tenancy, provider-owned devices | -| [0008 Command Descriptor Registry](0008-command-descriptor-registry.md) | adding/changing a command, any surface projection (CLI/MCP/client/batch), timeout policy | +| [0008 Command Descriptor Registry](0008-command-descriptor-registry.md) | adding/changing a command, any surface projection (CLI/MCP/ACP/client/batch), timeout policy | | [0009 Apple Platform Consolidation](0009-apple-platform-consolidation.md) | Apple platform family, apple/appleOs axes, the apple-leak guard | | [0010 Error system conventions](0010-error-system.md) | error codes, hints, normalizeError, typed error signals | | [0011 Interaction Guarantee Contract](0011-interaction-guarantee-contract.md) | interaction dispatch paths, fast paths, guards, the guarantee matrix, parity tables | diff --git a/src/acp/__tests__/prompt-lines.test.ts b/src/acp/__tests__/prompt-lines.test.ts new file mode 100644 index 000000000..b616c1b9f --- /dev/null +++ b/src/acp/__tests__/prompt-lines.test.ts @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { test } from 'vitest'; +import { applyStickyFlags, parsePromptCommandLines, type RunnableLine } from '../prompt-lines.ts'; + +const CWD = tmpdir(); + +function parseOne(text: string): ReturnType[number] { + const lines = parsePromptCommandLines(text, { cwd: CWD }); + assert.equal(lines.length, 1); + return lines[0]!; +} + +test('splits prompt text into command lines, skipping blanks and comments', () => { + const lines = parsePromptCommandLines('\n# comment\ndevices\n\nsnapshot -i\n', { cwd: CWD }); + assert.deepEqual( + lines.map((line) => (line.kind === 'command' ? line.command : line.error)), + ['devices', 'snapshot'], + ); +}); + +test('strips an optional leading agent-device token', () => { + const line = parseOne('agent-device devices'); + assert.equal(line.kind, 'command'); + assert.equal((line as RunnableLine).command, 'devices'); +}); + +test('accepts ACP slash commands for advertised commands', () => { + const lines = parsePromptCommandLines('/devices\n/snapshot -i', { cwd: CWD }); + assert.deepEqual( + lines.map((line) => { + assert.equal(line.kind, 'command'); + return (line as RunnableLine).command; + }), + ['devices', 'snapshot'], + ); + assert.equal((lines[1] as RunnableLine).flags.snapshotInteractiveOnly, true); +}); + +test('tokenizes quoted arguments with spaces like replay scripts', () => { + const line = parseOne('fill "id:search" "hello world" --platform ios'); + assert.equal(line.kind, 'command'); + const runnable = line as RunnableLine; + assert.deepEqual(runnable.positionals, ['id:search', 'hello world']); + assert.equal(runnable.flags.platform, 'ios'); +}); + +test('rejects unknown commands with per-line guidance', () => { + const line = parseOne('frobnicate'); + assert.equal(line.kind, 'error'); + assert.match((line as { error: string }).error, /Unknown command: frobnicate/); +}); + +test('rejects CLI-only commands that are not automatable through ACP', () => { + for (const command of ['mcp', 'acp', 'connect', 'auth']) { + const line = parseOne(command); + assert.equal(line.kind, 'error', `expected ${command} to be rejected`); + } +}); + +test('sticky flags fill in target selection and explicit flags win', () => { + const bare = parseOne('snapshot -i') as RunnableLine; + assert.equal(bare.kind, 'command'); + assert.deepEqual(bare.providedSticky, {}); + const merged = applyStickyFlags(bare, { platform: 'ios', session: 'demo' }); + assert.equal(merged.platform, 'ios'); + assert.equal(merged.session, 'demo'); + + const explicit = parseOne('snapshot -i --platform android') as RunnableLine; + assert.equal(explicit.kind, 'command'); + assert.deepEqual(explicit.providedSticky, { platform: 'android' }); + assert.equal(applyStickyFlags(explicit, { platform: 'ios' }).platform, 'android'); +}); diff --git a/src/acp/__tests__/prompt-runner.test.ts b/src/acp/__tests__/prompt-runner.test.ts new file mode 100644 index 000000000..8b3b82ee8 --- /dev/null +++ b/src/acp/__tests__/prompt-runner.test.ts @@ -0,0 +1,268 @@ +import assert from 'node:assert/strict'; +import { tmpdir } from 'node:os'; +import { test } from 'vitest'; +import type { AgentDeviceClient } from '../../client/client-types.ts'; +import type { RequestProgressSink } from '../../daemon/request-progress.ts'; +import { AppError } from '../../kernel/errors.ts'; +import type { AcpSessionUpdate } from '../contracts.ts'; +import { createPromptRunner, type PromptRunnerDeps } from '../prompt-runner.ts'; +import type { AcpSessionState } from '../session-state.ts'; + +const FAKE_CLIENT = {} as AgentDeviceClient; + +function makeSession(sticky = {}): AcpSessionState { + return { + id: 'sess-1', + cwd: tmpdir(), + sticky, + cancelled: false, + promptActive: true, + toolCallCounter: 0, + }; +} + +function collectUpdates(): { + updates: AcpSessionUpdate[]; + sendUpdate: (u: AcpSessionUpdate) => void; +} { + const updates: AcpSessionUpdate[] = []; + return { updates, sendUpdate: (update) => updates.push(update) }; +} + +function runnerWith(deps: Partial) { + return createPromptRunner({ + createClient: async () => FAKE_CLIENT, + readImageFile: () => undefined, + ...deps, + }); +} + +test('runs each prompt line as a tool call and summarizes the turn', async () => { + const executed: string[] = []; + const runner = runnerWith({ + runCommandLine: async ({ command }) => { + executed.push(command); + return { result: { ok: true }, cliOutput: { data: {}, text: `${command} done` } }; + }, + }); + const { updates, sendUpdate } = collectUpdates(); + + const stopReason = await runner.runPrompt({ + session: makeSession(), + promptText: 'devices\nsnapshot -i', + sendUpdate, + }); + + assert.equal(stopReason, 'end_turn'); + assert.deepEqual(executed, ['devices', 'snapshot']); + const kinds = updates.map((update) => update.sessionUpdate); + assert.deepEqual(kinds, [ + 'tool_call', + 'tool_call_update', + 'tool_call', + 'tool_call_update', + 'agent_message_chunk', + ]); + const firstCall = updates[0] as Extract; + assert.equal(firstCall.title, 'devices'); + assert.equal(firstCall.status, 'in_progress'); + assert.equal(firstCall.kind, 'read'); + const firstDone = updates[1] as Extract; + assert.equal(firstDone.toolCallId, firstCall.toolCallId); + assert.equal(firstDone.status, 'completed'); + assert.deepEqual(firstDone.rawOutput, { ok: true }); + assert.match(JSON.stringify(firstDone.content), /devices done/); + const summary = updates.at(-1) as Extract< + AcpSessionUpdate, + { sessionUpdate: 'agent_message_chunk' } + >; + assert.match(summary.content.type === 'text' ? summary.content.text : '', /✓ devices/); +}); + +test('command failures become failed tool calls with the full error contract', async () => { + const runner = runnerWith({ + runCommandLine: async () => { + throw new AppError('DEVICE_NOT_FOUND', 'No booted device.', { hint: 'Boot one first.' }); + }, + }); + const { updates, sendUpdate } = collectUpdates(); + + const stopReason = await runner.runPrompt({ + session: makeSession(), + promptText: 'devices', + sendUpdate, + }); + + assert.equal(stopReason, 'end_turn'); + const failed = updates[1] as Extract; + assert.equal(failed.status, 'failed'); + const text = JSON.stringify(failed.content); + assert.match(text, /Error \(DEVICE_NOT_FOUND\): No booted device\./); + assert.match(text, /Hint: Boot one first\./); +}); + +test('prompts with no runnable command lines are refused without touching the daemon', async () => { + let clientCreated = false; + const runner = runnerWith({ + createClient: async () => { + clientCreated = true; + return FAKE_CLIENT; + }, + runCommandLine: async () => { + throw new Error('must not run'); + }, + }); + const { updates, sendUpdate } = collectUpdates(); + + const stopReason = await runner.runPrompt({ + session: makeSession(), + promptText: 'please tap the login button', + sendUpdate, + }); + + assert.equal(stopReason, 'refusal'); + assert.equal(clientCreated, false); + assert.equal(updates.length, 1); + assert.equal(updates[0]!.sessionUpdate, 'agent_message_chunk'); +}); + +test('unparseable lines fail individually while runnable lines still execute', async () => { + const executed: string[] = []; + const runner = runnerWith({ + runCommandLine: async ({ command }) => { + executed.push(command); + return { result: {}, cliOutput: { data: {}, text: 'ok' } }; + }, + }); + const { updates, sendUpdate } = collectUpdates(); + + const stopReason = await runner.runPrompt({ + session: makeSession(), + promptText: 'frobnicate\ndevices', + sendUpdate, + }); + + assert.equal(stopReason, 'end_turn'); + assert.deepEqual(executed, ['devices']); + const failedCall = updates[0] as Extract; + assert.equal(failedCall.status, 'in_progress'); + const failedUpdate = updates[1] as Extract< + AcpSessionUpdate, + { sessionUpdate: 'tool_call_update' } + >; + assert.equal(failedUpdate.toolCallId, failedCall.toolCallId); + assert.equal(failedUpdate.status, 'failed'); + assert.match(JSON.stringify(failedUpdate.content), /Unknown command: frobnicate/); +}); + +test('sticky flags from one line carry into later lines of the same session', async () => { + const platforms: Array = []; + const session = makeSession(); + const runner = runnerWith({ + runCommandLine: async ({ flags }) => { + platforms.push(flags.platform); + return { result: {}, cliOutput: { data: {}, text: 'ok' } }; + }, + }); + const { sendUpdate } = collectUpdates(); + + await runner.runPrompt({ + session, + promptText: 'apps --platform ios\nsnapshot -i', + sendUpdate, + }); + + assert.deepEqual(platforms, ['ios', 'ios']); + assert.equal(session.sticky.platform, 'ios'); +}); + +test('cancellation between lines stops the turn with the cancelled stop reason', async () => { + const session = makeSession(); + const executed: string[] = []; + const runner = runnerWith({ + runCommandLine: async ({ command }) => { + executed.push(command); + session.cancelled = true; + return { result: {}, cliOutput: { data: {}, text: 'ok' } }; + }, + }); + const { sendUpdate } = collectUpdates(); + + const stopReason = await runner.runPrompt({ + session, + promptText: 'devices\nsnapshot -i', + sendUpdate, + }); + + assert.equal(stopReason, 'cancelled'); + assert.deepEqual(executed, ['devices']); +}); + +test('screenshot results attach an inline image when the artifact is readable', async () => { + const runner = runnerWith({ + runCommandLine: async () => ({ + result: { path: '/artifacts/screen.png' }, + cliOutput: { data: {}, text: 'Saved screenshot' }, + }), + readImageFile: (path) => + path === '/artifacts/screen.png' + ? { type: 'image', data: 'aGk=', mimeType: 'image/png' } + : undefined, + }); + const { updates, sendUpdate } = collectUpdates(); + + await runner.runPrompt({ session: makeSession(), promptText: 'screenshot', sendUpdate }); + + const done = updates[1] as Extract; + assert.equal(done.status, 'completed'); + const image = done.content?.find((entry) => entry.content.type === 'image'); + assert.ok(image, 'expected an inline image content block'); +}); + +test('large ref-issuing command results omit rawOutput from completed tool updates', async () => { + const rawOutputs: unknown[] = []; + const runner = runnerWith({ + runCommandLine: async ({ command }) => ({ + result: + command === 'snapshot' + ? { nodes: [{ ref: 'e1', label: 'Checkout' }], refsGeneration: 7 } + : { ref: '@e1', label: 'Checkout', refsGeneration: 7 }, + cliOutput: { data: {}, text: `${command} done` }, + }), + }); + const { updates, sendUpdate } = collectUpdates(); + + await runner.runPrompt({ + session: makeSession(), + promptText: 'snapshot -i\nfind Checkout', + sendUpdate, + }); + + for (const update of updates) { + if (update.sessionUpdate === 'tool_call_update' && update.status === 'completed') { + rawOutputs.push(update.rawOutput); + } + } + assert.deepEqual(rawOutputs, [undefined, undefined]); +}); + +test('daemon progress events stream as in_progress tool call updates', async () => { + let progressSink: RequestProgressSink | undefined; + const runner = runnerWith({ + createClient: async (_config, onProgress) => { + progressSink = onProgress; + return FAKE_CLIENT; + }, + runCommandLine: async () => { + progressSink?.({ type: 'command', status: 'progress', message: 'Booting simulator…' }); + return { result: {}, cliOutput: { data: {}, text: 'ok' } }; + }, + }); + const { updates, sendUpdate } = collectUpdates(); + + await runner.runPrompt({ session: makeSession(), promptText: 'boot', sendUpdate }); + + const progress = updates[1] as Extract; + assert.equal(progress.status, 'in_progress'); + assert.match(JSON.stringify(progress.content), /Booting simulator…/); +}); diff --git a/src/acp/__tests__/router.test.ts b/src/acp/__tests__/router.test.ts new file mode 100644 index 000000000..9fb7fa014 --- /dev/null +++ b/src/acp/__tests__/router.test.ts @@ -0,0 +1,254 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { listMcpExposedCommandNames } from '../../command-catalog.ts'; +import type { AcpStopReason } from '../contracts.ts'; +import type { PromptRunner } from '../prompt-runner.ts'; +import { createAcpRouter } from '../router.ts'; + +type RouterHarness = { + router: ReturnType; + written: unknown[]; + scheduled: Array<() => void>; + flushScheduled: () => void; +}; + +function makeRouter(promptRunner?: PromptRunner): RouterHarness { + const written: unknown[] = []; + const scheduled: Array<() => void> = []; + const router = createAcpRouter({ + sendNotification: (message) => written.push(message), + promptRunner, + scheduleAfterResponse: (run) => scheduled.push(run), + }); + return { + router, + written, + scheduled, + flushScheduled: () => { + while (scheduled.length > 0) scheduled.shift()!(); + }, + }; +} + +function stubRunner(run: PromptRunner['runPrompt']): PromptRunner { + return { runPrompt: run }; +} + +async function newSessionId(harness: RouterHarness): Promise { + const response = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 1, + method: 'session/new', + params: { cwd: '/tmp', mcpServers: [] }, + })) as { result: { sessionId: string } }; + return response.result.sessionId; +} + +test('initialize advertises ACP v1 with no auth and text-only prompts', async () => { + const { router } = makeRouter(); + const response = (await router.handleAcpPayload({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: 1, clientCapabilities: {} }, + })) as { result: Record }; + + assert.equal(response.result.protocolVersion, 1); + assert.deepEqual(response.result.authMethods, []); + assert.deepEqual(response.result.agentCapabilities, { + loadSession: false, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + }); + const agentInfo = response.result.agentInfo as { name: string; version: string }; + assert.equal(agentInfo.name, 'agent-device'); + assert.match(agentInfo.version, /^\d+\.\d+\.\d+/); +}); + +test('session/new returns a sessionId before advertising available commands', async () => { + const harness = makeRouter(); + const sessionId = await newSessionId(harness); + + assert.ok(sessionId.length > 0); + // The available_commands_update must not be written before the response. + assert.equal(harness.written.length, 0); + harness.flushScheduled(); + assert.equal(harness.written.length, 1); + const notification = harness.written[0] as { + method: string; + params: { + sessionId: string; + update: { sessionUpdate: string; availableCommands: Array<{ name: string }> }; + }; + }; + assert.equal(notification.method, 'session/update'); + assert.equal(notification.params.sessionId, sessionId); + assert.equal(notification.params.update.sessionUpdate, 'available_commands_update'); + assert.deepEqual( + notification.params.update.availableCommands.map((command) => command.name).sort(), + listMcpExposedCommandNames().sort(), + ); +}); + +test('session/prompt forwards updates for the session and returns the stop reason', async () => { + const harness = makeRouter( + stubRunner(async ({ promptText, sendUpdate }) => { + assert.equal(promptText, 'devices\nsnapshot -i'); + sendUpdate({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hi' } }); + return 'end_turn'; + }), + ); + const sessionId = await newSessionId(harness); + + const response = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { + sessionId, + prompt: [ + { type: 'text', text: 'devices' }, + { type: 'image', data: 'aGk=', mimeType: 'image/png' }, + { type: 'text', text: 'snapshot -i' }, + ], + }, + })) as { result: { stopReason: AcpStopReason } }; + + assert.equal(response.result.stopReason, 'end_turn'); + const update = harness.written.at(-1) as { params: { sessionId: string } }; + assert.equal(update.params.sessionId, sessionId); +}); + +test('a second prompt on a busy session is rejected with invalid params', async () => { + let release: (() => void) | undefined; + const harness = makeRouter( + stubRunner( + () => + new Promise((resolve) => { + release = () => resolve('end_turn'); + }), + ), + ); + const sessionId = await newSessionId(harness); + + const first = harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId, prompt: [{ type: 'text', text: 'devices' }] }, + }); + const second = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 3, + method: 'session/prompt', + params: { sessionId, prompt: [{ type: 'text', text: 'devices' }] }, + })) as { error: { code: number } }; + + assert.equal(second.error.code, -32602); + release?.(); + await first; +}); + +test('session/cancel during a prompt turns the response into cancelled', async () => { + const harness = makeRouter( + stubRunner(async ({ session }) => { + // Simulate the out-of-band cancel interception landing mid-turn. + harness.router.markSessionCancelled({ + jsonrpc: '2.0', + method: 'session/cancel', + params: { sessionId: session.id }, + }); + return 'end_turn'; + }), + ); + const sessionId = await newSessionId(harness); + + const response = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId, prompt: [{ type: 'text', text: 'devices' }] }, + })) as { result: { stopReason: AcpStopReason } }; + + assert.equal(response.result.stopReason, 'cancelled'); +}); + +test('unknown methods, unknown sessions, and bad params return JSON-RPC errors', async () => { + const harness = makeRouter(); + + const unknownMethod = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 1, + method: 'session/load', + params: {}, + })) as { error: { code: number } }; + assert.equal(unknownMethod.error.code, -32601); + + const missingCwd = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: {}, + })) as { error: { code: number } }; + assert.equal(missingCwd.error.code, -32602); + + const unknownSession = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 3, + method: 'session/prompt', + params: { sessionId: 'nope', prompt: [{ type: 'text', text: 'devices' }] }, + })) as { error: { code: number } }; + assert.equal(unknownSession.error.code, -32602); +}); + +test('session/new requires protocol-shaped session params', async () => { + const harness = makeRouter(); + const cases: Array<{ name: string; params: unknown; message: RegExp }> = [ + { name: 'relative cwd', params: { cwd: 'relative', mcpServers: [] }, message: /absolute/ }, + { name: 'missing mcpServers', params: { cwd: '/tmp' }, message: /mcpServers/ }, + { name: 'non-array mcpServers', params: { cwd: '/tmp', mcpServers: {} }, message: /array/ }, + { + name: 'non-object mcpServers entry', + params: { cwd: '/tmp', mcpServers: [42] }, + message: /mcpServers\[0\]/, + }, + ]; + + for (const [index, entry] of cases.entries()) { + const response = (await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: index + 1, + method: 'session/new', + params: entry.params, + })) as { error: { code: number; message: string } }; + assert.equal(response.error.code, -32602, entry.name); + assert.match(response.error.message, entry.message, entry.name); + } +}); + +test('notifications get no response and cancel notifications mark the session', async () => { + let observedCancelled = false; + const harness = makeRouter( + stubRunner(async ({ session }) => { + observedCancelled = session.cancelled; + return session.cancelled ? 'cancelled' : 'end_turn'; + }), + ); + const sessionId = await newSessionId(harness); + + const cancelResult = await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + method: 'session/cancel', + params: { sessionId }, + }); + assert.equal(cancelResult, null); + + // A fresh prompt resets the cancel flag: the cancel above applied to no + // active turn, and each session/prompt starts a new one. + await harness.router.handleAcpPayload({ + jsonrpc: '2.0', + id: 2, + method: 'session/prompt', + params: { sessionId, prompt: [{ type: 'text', text: 'devices' }] }, + }); + assert.equal(observedCancelled, false); +}); diff --git a/src/acp/__tests__/server-validation.test.ts b/src/acp/__tests__/server-validation.test.ts new file mode 100644 index 000000000..6d72a382a --- /dev/null +++ b/src/acp/__tests__/server-validation.test.ts @@ -0,0 +1,88 @@ +import assert from 'node:assert/strict'; +import { setImmediate as flushEventLoop } from 'node:timers/promises'; +import { test } from 'vitest'; +import { createMcpPayloadQueue, McpMessageDecoder } from '../../mcp/server.ts'; +import { createAcpRouter } from '../router.ts'; +import { createAcpPayloadSink } from '../server.ts'; + +function makeQueueHarness() { + const written: unknown[] = []; + const router = createAcpRouter({ + sendNotification: (message) => written.push(message), + scheduleAfterResponse: (run) => run(), + }); + const queue = createMcpPayloadQueue({ + handlePayload: router.handleAcpPayload, + write: (message) => written.push(message), + }); + return { router, queue, written }; +} + +test('invalid JSON-RPC envelopes are rejected with -32600', async () => { + const { queue, written } = makeQueueHarness(); + queue.push(42); + queue.push([{ jsonrpc: '2.0', id: 1, method: 'initialize' }]); + await queue.idle(); + + assert.equal(written.length, 2); + for (const message of written) { + assert.equal((message as { error: { code: number } }).error.code, -32600); + } +}); + +test('the decoder rejects malformed JSON lines without emitting payloads', () => { + const payloads: unknown[] = []; + const decoder = new McpMessageDecoder((payload) => payloads.push(payload)); + assert.throws(() => decoder.push('{not json}\n')); + assert.equal(payloads.length, 0); +}); + +test('session/cancel bypasses the serialized queue while a prompt holds it', async () => { + const written: unknown[] = []; + const cancelled: string[] = []; + let releasePrompt: (() => void) | undefined; + const blockingHandler = async (payload: unknown): Promise => { + const method = (payload as { method?: string }).method; + if (method === 'session/prompt') { + await new Promise((resolve) => { + releasePrompt = resolve; + }); + return { jsonrpc: '2.0', id: (payload as { id: number }).id, result: {} }; + } + return null; + }; + const queue = createMcpPayloadQueue({ + handlePayload: blockingHandler, + write: (message) => written.push(message), + }); + const sink = createAcpPayloadSink( + { markSessionCancelled: (payload) => cancelled.push(JSON.stringify(payload)) }, + queue.push, + ); + + sink({ jsonrpc: '2.0', id: 1, method: 'session/prompt', params: { sessionId: 's' } }); + // Let the queue start the prompt handler so it is genuinely holding the queue. + await flushEventLoop(); + sink({ jsonrpc: '2.0', method: 'session/cancel', params: { sessionId: 's' } }); + + assert.equal(cancelled.length, 1, 'cancel notification must not wait behind the queue'); + assert.equal(written.length, 0); + assert.ok(releasePrompt, 'prompt handler should be running'); + releasePrompt?.(); + await queue.idle(); + assert.equal(written.length, 1); +}); + +test('a session/cancel request with an id is not intercepted as a notification', () => { + const pushed: unknown[] = []; + const cancelled: unknown[] = []; + const sink = createAcpPayloadSink( + { markSessionCancelled: (payload) => cancelled.push(payload) }, + (payload) => pushed.push(payload), + ); + + sink({ jsonrpc: '2.0', id: 7, method: 'session/cancel', params: { sessionId: 's' } }); + + assert.equal(cancelled.length, 0); + assert.equal(pushed.length, 1); +}); diff --git a/src/acp/__tests__/tool-kinds.test.ts b/src/acp/__tests__/tool-kinds.test.ts new file mode 100644 index 000000000..24359e674 --- /dev/null +++ b/src/acp/__tests__/tool-kinds.test.ts @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { listMcpExposedCommandNames } from '../../command-catalog.ts'; +import type { AcpToolKind } from '../contracts.ts'; +import { toolKindForCommand } from '../tool-kinds.ts'; + +const VALID_TOOL_KINDS: ReadonlySet = new Set([ + 'read', + 'edit', + 'delete', + 'move', + 'search', + 'execute', + 'think', + 'fetch', + 'switch_mode', + 'other', +]); + +test('every ACP-runnable command maps to a valid ACP tool kind', () => { + for (const command of listMcpExposedCommandNames()) { + const kind = toolKindForCommand(command); + assert.ok(VALID_TOOL_KINDS.has(kind), `command ${command} mapped to invalid tool kind ${kind}`); + } +}); + +test('tool kinds distinguish reads from actions for representative commands', () => { + assert.equal(toolKindForCommand('snapshot'), 'read'); + assert.equal(toolKindForCommand('screenshot'), 'read'); + assert.equal(toolKindForCommand('devices'), 'read'); + assert.equal(toolKindForCommand('find'), 'search'); + assert.equal(toolKindForCommand('press'), 'execute'); + assert.equal(toolKindForCommand('open'), 'execute'); + assert.equal(toolKindForCommand('install'), 'execute'); +}); diff --git a/src/acp/contracts.ts b/src/acp/contracts.ts new file mode 100644 index 000000000..b9de445d9 --- /dev/null +++ b/src/acp/contracts.ts @@ -0,0 +1,70 @@ +// Hand-rolled ACP (Agent Client Protocol) v1 wire types for the subset this +// deterministic agent implements: initialize, session/new, session/prompt, +// session/cancel, and outbound session/update notifications. Shapes follow the +// official v1 JSON schema (https://agentclientprotocol.com); content blocks are +// MCP-compatible by design. + +export const ACP_PROTOCOL_VERSION = 1; + +export type AcpTextContent = { type: 'text'; text: string }; +export type AcpImageContent = { type: 'image'; data: string; mimeType: string }; +export type AcpContentBlock = AcpTextContent | AcpImageContent; + +export type AcpToolKind = + | 'read' + | 'edit' + | 'delete' + | 'move' + | 'search' + | 'execute' + | 'think' + | 'fetch' + | 'switch_mode' + | 'other'; + +export type AcpToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; + +export type AcpStopReason = + | 'end_turn' + | 'max_tokens' + | 'max_turn_requests' + | 'refusal' + | 'cancelled'; + +export type AcpToolCallContent = { type: 'content'; content: AcpContentBlock }; + +export type AcpToolCallUpdateFields = { + toolCallId: string; + status?: AcpToolCallStatus; + title?: string; + kind?: AcpToolKind; + content?: AcpToolCallContent[]; + rawInput?: unknown; + rawOutput?: unknown; +}; + +export type AcpAvailableCommand = { name: string; description: string }; + +export type AcpSessionUpdate = + | { sessionUpdate: 'agent_message_chunk'; content: AcpContentBlock } + | ({ + sessionUpdate: 'tool_call'; + status: AcpToolCallStatus; + kind: AcpToolKind; + } & AcpToolCallUpdateFields) + | ({ sessionUpdate: 'tool_call_update' } & AcpToolCallUpdateFields) + | { sessionUpdate: 'available_commands_update'; availableCommands: AcpAvailableCommand[] }; + +export type AcpSessionNotification = { sessionId: string; update: AcpSessionUpdate }; + +export type AcpInitializeResponse = { + protocolVersion: number; + agentCapabilities: { + loadSession: boolean; + promptCapabilities: { image: boolean; audio: boolean; embeddedContext: boolean }; + }; + authMethods: never[]; + agentInfo: { name: string; version: string }; +}; + +export type AcpPromptResponse = { stopReason: AcpStopReason }; diff --git a/src/acp/prompt-lines.ts b/src/acp/prompt-lines.ts new file mode 100644 index 000000000..a2b260e72 --- /dev/null +++ b/src/acp/prompt-lines.ts @@ -0,0 +1,116 @@ +import { listMcpExposedCommandNames } from '../command-catalog.ts'; +import type { CliFlags } from '../cli/parser/cli-flags.ts'; +import type { CommandName } from '../commands/command-metadata.ts'; +import { tokenizeReplayLine } from '../replay/script.ts'; +import { resolveCliOptions } from '../utils/cli-options.ts'; +import { STICKY_FLAG_KEYS, type StickyFlags } from './session-state.ts'; + +export type RunnableLine = { + kind: 'command'; + line: string; + command: CommandName; + positionals: string[]; + flags: CliFlags; + providedSticky: StickyFlags; +}; + +export type UnrunnableLine = { + kind: 'error'; + line: string; + error: string; +}; + +export type PromptLine = RunnableLine | UnrunnableLine; + +/** + * Turn the text of an ACP prompt into agent-device command invocations: one + * command per non-blank, non-comment line, in the CLI/replay-script dialect + * (quote-aware tokens, optional leading `agent-device`). Sticky-flag merging + * happens at execution time in the prompt runner, so flags set by an earlier + * line of the same prompt reach later lines. + */ +export function parsePromptCommandLines(text: string, options: { cwd: string }): PromptLine[] { + return text + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('#')) + .map((line) => parsePromptCommandLine(line, options)); +} + +/** + * Fill in target selection (`--platform`, `--session`, …) remembered from + * earlier lines for the sticky keys this line did not restate. Explicit flags + * always win. + */ +export function applyStickyFlags(line: RunnableLine, sticky: StickyFlags): CliFlags { + const flags = { ...line.flags }; + for (const key of STICKY_FLAG_KEYS) { + if (line.providedSticky[key] === undefined && sticky[key] !== undefined) { + copyStickyFlag(flags, sticky, key); + } + } + return flags; +} + +function parsePromptCommandLine(line: string, options: { cwd: string }): PromptLine { + let tokens: string[]; + try { + tokens = tokenizeReplayLine(line); + } catch (error) { + return { kind: 'error', line, error: error instanceof Error ? error.message : String(error) }; + } + if (tokens[0] === 'agent-device') tokens = tokens.slice(1); + if (tokens[0]?.startsWith('/')) tokens = [tokens[0].slice(1), ...tokens.slice(1)]; + if (tokens.length === 0) { + return { kind: 'error', line, error: 'Empty command line.' }; + } + try { + return finalizePromptCommandLine(line, tokens, options); + } catch (error) { + return { kind: 'error', line, error: error instanceof Error ? error.message : String(error) }; + } +} + +function finalizePromptCommandLine( + line: string, + tokens: string[], + options: { cwd: string }, +): PromptLine { + const parsed = resolveCliOptions(tokens, { cwd: options.cwd }); + const command = parsed.command; + if (command === null || !isAcpRunnableCommand(command)) { + return { + kind: 'error', + line, + error: `Unknown command: ${command ?? tokens[0]}. Run one agent-device command per line (e.g. "devices", "snapshot -i", "press @e2").`, + }; + } + const providedKeys = new Set(parsed.providedFlags.map((entry) => entry.key)); + const flags = parsed.flags as CliFlags; + const providedSticky: StickyFlags = {}; + for (const key of STICKY_FLAG_KEYS) { + if (providedKeys.has(key)) copyStickyFlag(providedSticky, flags, key); + } + return { + kind: 'command', + line, + command: command as CommandName, + positionals: parsed.positionals, + flags, + providedSticky, + }; +} + +// One generic assignment site so the per-key value types stay aligned between +// the two sticky-flag containers without per-key casts. +function copyStickyFlag( + target: StickyFlags, + source: StickyFlags, + key: K, +): void { + target[key] = source[key]; +} + +function isAcpRunnableCommand(command: string): boolean { + return (listMcpExposedCommandNames() as readonly string[]).includes(command); +} diff --git a/src/acp/prompt-runner.ts b/src/acp/prompt-runner.ts new file mode 100644 index 000000000..5175c3046 --- /dev/null +++ b/src/acp/prompt-runner.ts @@ -0,0 +1,268 @@ +import { readFileSync, statSync } from 'node:fs'; +import { extname } from 'node:path'; +import type { AgentDeviceClient, AgentDeviceClientConfig } from '../client/client-types.ts'; +import type { CliFlags } from '../cli/parser/cli-flags.ts'; +import { runCliCommandWithOutput } from '../commands/cli-runner.ts'; +import type { RequestProgressSink } from '../daemon/request-progress.ts'; +import { formatToolError } from '../mcp/router.ts'; +import type { + AcpContentBlock, + AcpImageContent, + AcpSessionUpdate, + AcpStopReason, + AcpToolCallContent, +} from './contracts.ts'; +import { applyStickyFlags, parsePromptCommandLines, type RunnableLine } from './prompt-lines.ts'; +import { nextToolCallId, type AcpSessionState } from './session-state.ts'; +import { toolKindForCommand } from './tool-kinds.ts'; + +export type AcpUpdateSink = (update: AcpSessionUpdate) => void; + +type RunCommandLine = typeof runCliCommandWithOutput; + +export type PromptRunnerDeps = { + runCommandLine?: RunCommandLine; + createClient?: ( + config: AgentDeviceClientConfig, + onProgress: RequestProgressSink, + ) => Promise; + readImageFile?: (path: string) => AcpImageContent | undefined; +}; + +export type PromptRunner = { + runPrompt: (params: { + session: AcpSessionState; + promptText: string; + sendUpdate: AcpUpdateSink; + }) => Promise; +}; + +export function createPromptRunner(deps: PromptRunnerDeps = {}): PromptRunner { + const runCommandLine = deps.runCommandLine ?? runCliCommandWithOutput; + const createClient = deps.createClient ?? createProgressForwardingClient; + const readImageFile = deps.readImageFile ?? readLocalImageFile; + + return { + runPrompt: async ({ session, promptText, sendUpdate }) => { + const lines = parsePromptCommandLines(promptText, { cwd: session.cwd }); + if (!lines.some((line) => line.kind === 'command')) { + sendUpdate(agentMessage(refusalText(lines.length))); + return 'refusal'; + } + const outcomes: string[] = []; + for (const line of lines) { + if (session.cancelled) return 'cancelled'; + if (line.kind === 'error') { + sendParseErrorToolCall(session, line, sendUpdate); + outcomes.push(`✗ ${line.line} — ${line.error}`); + continue; + } + Object.assign(session.sticky, line.providedSticky); + outcomes.push( + await runCommandToolCall({ + session, + line, + sendUpdate, + deps: { + runCommandLine, + createClient, + readImageFile, + }, + }), + ); + if (session.cancelled) return 'cancelled'; + } + sendUpdate(agentMessage(outcomes.join('\n'))); + return 'end_turn'; + }, + }; +} + +async function runCommandToolCall(params: { + session: AcpSessionState; + line: RunnableLine; + sendUpdate: AcpUpdateSink; + deps: Required; +}): Promise { + const { session, line, sendUpdate, deps } = params; + const flags = applyStickyFlags(line, session.sticky); + const toolCallId = nextToolCallId(session); + sendUpdate({ + sessionUpdate: 'tool_call', + toolCallId, + title: line.line, + kind: toolKindForCommand(line.command), + status: 'in_progress', + rawInput: { command: line.command, positionals: line.positionals }, + }); + const onProgress: RequestProgressSink = (event) => { + if (event.type !== 'command') return; + sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'in_progress', + content: [textContent(event.message)], + }); + }; + try { + const client = await deps.createClient(clientConfigFromFlags(flags, session.cwd), onProgress); + const { result, cliOutput } = await deps.runCommandLine({ + client, + command: line.command, + positionals: line.positionals, + flags, + }); + sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'completed', + content: commandResultContent({ line, result, text: cliOutput?.text ?? undefined, deps }), + ...rawOutputUpdate(line, result), + }); + return `✓ ${line.line}`; + } catch (error) { + // Same code + hint + supportedOn rendering the MCP tool results use. + const message = formatToolError(error); + sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'failed', + content: [textContent(message)], + }); + return `✗ ${line.line} — ${firstLine(message)}`; + } +} + +function sendParseErrorToolCall( + session: AcpSessionState, + line: { line: string; error: string }, + sendUpdate: AcpUpdateSink, +): void { + const toolCallId = nextToolCallId(session); + sendUpdate({ + sessionUpdate: 'tool_call', + toolCallId, + title: line.line, + kind: 'other', + status: 'in_progress', + }); + sendUpdate({ + sessionUpdate: 'tool_call_update', + toolCallId, + status: 'failed', + content: [textContent(line.error)], + }); +} + +function rawOutputUpdate(line: RunnableLine, result: unknown): { rawOutput?: unknown } { + if (line.command === 'snapshot' || line.command === 'find') return {}; + return { rawOutput: result }; +} + +function commandResultContent(params: { + line: RunnableLine; + result: unknown; + text: string | undefined; + deps: Required; +}): AcpToolCallContent[] { + const text = params.text ?? JSON.stringify(params.result, null, 2); + const content: AcpToolCallContent[] = [textContent(text)]; + const image = screenshotImageContent(params.line, params.result, params.deps.readImageFile); + if (image) content.push({ type: 'content', content: image }); + return content; +} + +/** + * Best-effort inline preview for screenshots: attach the image when the + * result's artifact path is a readable local file. Remote daemons (path not + * local) and oversized captures silently fall back to the text path. + */ +function screenshotImageContent( + line: RunnableLine, + result: unknown, + readImageFile: (path: string) => AcpImageContent | undefined, +): AcpImageContent | undefined { + if (line.command !== 'screenshot') return undefined; + if (!result || typeof result !== 'object') return undefined; + const path = (result as Record).path; + if (typeof path !== 'string' || path.length === 0) return undefined; + return readImageFile(path); +} + +const MAX_INLINE_IMAGE_BYTES = 4 * 1024 * 1024; + +const IMAGE_MIME_TYPES: Readonly> = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', +}; + +function readLocalImageFile(path: string): AcpImageContent | undefined { + const mimeType = IMAGE_MIME_TYPES[extname(path).toLowerCase()]; + if (!mimeType) return undefined; + try { + if (statSync(path).size > MAX_INLINE_IMAGE_BYTES) return undefined; + return { type: 'image', data: readFileSync(path).toString('base64'), mimeType }; + } catch { + return undefined; + } +} + +async function createProgressForwardingClient( + config: AgentDeviceClientConfig, + onProgress: RequestProgressSink, +): Promise { + const [{ createAgentDeviceClient }, { sendToDaemon }] = await Promise.all([ + import('../client/client.ts'), + import('../daemon/client/daemon-client.ts'), + ]); + type DaemonSendRequest = Parameters[0]; + return createAgentDeviceClient(config, { + // Same client-request-to-daemon-request cast the CLI transport performs in + // sendClientRequestToCliTransport (src/cli.ts). + transport: async (req) => + await sendToDaemon( + { ...req, meta: { ...req.meta, requestProgress: 'command' } } as DaemonSendRequest, + { onProgress }, + ), + }); +} + +/** + * Lean per-line client config: session/target scoping plus the remote-daemon + * fields that config files can inject. Remote connection materialization and + * lock-policy binding stay CLI-only. + */ +function clientConfigFromFlags(flags: CliFlags, cwd: string): AgentDeviceClientConfig { + return { + session: flags.session, + stateDir: flags.stateDir, + daemonBaseUrl: flags.daemonBaseUrl, + daemonAuthToken: flags.daemonAuthToken, + daemonTransport: flags.daemonTransport, + daemonServerMode: flags.daemonServerMode, + tenant: flags.tenant, + cwd, + cost: flags.cost, + responseLevel: flags.responseLevel, + }; +} + +function refusalText(lineCount: number): string { + if (lineCount === 0) { + return 'No command lines found in the prompt. Send one agent-device command per line, e.g. "devices" or "snapshot -i --platform ios".'; + } + return 'No runnable agent-device command lines found in the prompt. Send one command per line, e.g. "devices" or "snapshot -i --platform ios".'; +} + +function agentMessage(text: string): AcpSessionUpdate { + return { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text } }; +} + +function textContent(text: string): AcpToolCallContent { + return { type: 'content', content: { type: 'text', text } satisfies AcpContentBlock }; +} + +function firstLine(text: string): string { + return text.split('\n', 1)[0] ?? text; +} diff --git a/src/acp/router.ts b/src/acp/router.ts new file mode 100644 index 000000000..c927e2622 --- /dev/null +++ b/src/acp/router.ts @@ -0,0 +1,226 @@ +import { isAbsolute } from 'node:path'; +import { listMcpCommandMetadata } from '../commands/command-metadata.ts'; +import { jsonRpcRequestSchema, type JsonRpcId } from '../kernel/contracts.ts'; +import { errorResponse, successResponse, type JsonRpcResponse } from '../mcp/router.ts'; +import { readVersion } from '../utils/version.ts'; +import { + ACP_PROTOCOL_VERSION, + type AcpContentBlock, + type AcpInitializeResponse, + type AcpPromptResponse, + type AcpSessionUpdate, +} from './contracts.ts'; +import { createPromptRunner, type PromptRunner } from './prompt-runner.ts'; +import { createAcpSessionStore, type AcpSessionState } from './session-state.ts'; + +export type AcpRouter = { + handleAcpPayload: (payload: unknown) => Promise; + markSessionCancelled: (payload: unknown) => void; +}; + +export function createAcpRouter(deps: { + sendNotification: (message: unknown) => void; + promptRunner?: PromptRunner; + // Injectable so router tests can pin the response-before-notification order + // without relying on real event-loop timing. + scheduleAfterResponse?: (run: () => void) => void; +}): AcpRouter { + const sessions = createAcpSessionStore(); + const promptRunner = deps.promptRunner ?? createPromptRunner(); + const scheduleAfterResponse = deps.scheduleAfterResponse ?? ((run) => setImmediate(run)); + + const sendSessionUpdate = (sessionId: string, update: AcpSessionUpdate): void => { + deps.sendNotification({ + jsonrpc: '2.0', + method: 'session/update', + params: { sessionId, update }, + }); + }; + + const handleRequest = async (method: string, params: unknown): Promise => { + switch (method) { + case 'initialize': + return initializeResponse(); + case 'session/new': + return newSession(params); + case 'session/prompt': + return await runPrompt(params); + default: + throw new AcpMethodNotFoundError(`Unsupported ACP method: ${method}`); + } + }; + + const newSession = (params: unknown): { sessionId: string } => { + const { cwd } = readSessionNewParams(params); + const session = sessions.create(cwd); + // The session/new response must reach the client before the first + // notification for that session; the response is written when this handler + // resolves (a microtask), so schedule the notification on a macrotask. + scheduleAfterResponse(() => { + sendSessionUpdate(session.id, { + sessionUpdate: 'available_commands_update', + availableCommands: listMcpCommandMetadata().map((definition) => ({ + name: definition.name, + description: definition.description, + })), + }); + }); + return { sessionId: session.id }; + }; + + const runPrompt = async (params: unknown): Promise => { + const record = asRecord(params); + const session = requireSession(sessions.get(stringField(record, 'sessionId'))); + if (session.promptActive) { + throw new AcpInvalidParamsError('A prompt is already running for this session.'); + } + session.promptActive = true; + session.cancelled = false; + try { + const stopReason = await promptRunner.runPrompt({ + session, + promptText: readPromptText(record.prompt), + sendUpdate: (update) => sendSessionUpdate(session.id, update), + }); + // A cancel that lands after the last command still owns the turn: the + // spec requires `cancelled` whenever the client sent session/cancel. + return { stopReason: session.cancelled ? 'cancelled' : stopReason }; + } finally { + session.promptActive = false; + } + }; + + return { + handleAcpPayload: async (payload) => { + if (Array.isArray(payload)) return invalidRequestResponse(null); + let message; + try { + message = jsonRpcRequestSchema.parse(payload); + } catch { + return invalidRequestResponse(bestEffortId(payload)); + } + if (message.jsonrpc !== '2.0' || typeof message.method !== 'string') { + return invalidRequestResponse(message.id ?? null); + } + if (message.id === undefined) { + // The server intercepts session/cancel before the queue; handling it + // here too keeps the router correct when driven directly. + if (message.method === 'session/cancel') + markCancelled(sessions.get.bind(sessions), message.params); + return null; + } + try { + return successResponse(message.id, await handleRequest(message.method, message.params)); + } catch (error) { + return errorResponse(message.id, errorCode(error), errorMessage(error)); + } + }, + markSessionCancelled: (payload) => { + const params = asOptionalRecord(payload)?.params; + markCancelled(sessions.get.bind(sessions), params); + }, + }; +} + +function readSessionNewParams(params: unknown): { cwd: string } { + const record = asRecord(params); + const cwd = stringField(record, 'cwd'); + if (!isAbsolute(cwd)) { + throw new AcpInvalidParamsError('Expected cwd to be an absolute path.'); + } + validateMcpServers(record.mcpServers); + return { cwd }; +} + +function validateMcpServers(value: unknown): void { + if (!Array.isArray(value)) { + throw new AcpInvalidParamsError('Expected mcpServers to be an array.'); + } + for (const [index, entry] of value.entries()) { + if (!asOptionalRecord(entry)) { + throw new AcpInvalidParamsError(`Expected mcpServers[${index}] to be an object.`); + } + } +} + +function markCancelled( + getSession: (id: string) => AcpSessionState | undefined, + params: unknown, +): void { + const sessionId = asOptionalRecord(params)?.sessionId; + if (typeof sessionId !== 'string') return; + const session = getSession(sessionId); + if (session) session.cancelled = true; +} + +function initializeResponse(): AcpInitializeResponse { + return { + protocolVersion: ACP_PROTOCOL_VERSION, + agentCapabilities: { + loadSession: false, + promptCapabilities: { image: false, audio: false, embeddedContext: false }, + }, + authMethods: [], + agentInfo: { name: 'agent-device', version: readVersion() }, + }; +} + +function readPromptText(prompt: unknown): string { + if (!Array.isArray(prompt)) { + throw new AcpInvalidParamsError('Expected prompt to be an array of content blocks.'); + } + return prompt + .filter( + (block): block is Extract => + asOptionalRecord(block)?.type === 'text' && + typeof asOptionalRecord(block)?.text === 'string', + ) + .map((block) => block.text) + .join('\n'); +} + +function requireSession(session: AcpSessionState | undefined): AcpSessionState { + if (!session) throw new AcpInvalidParamsError('Unknown sessionId.'); + return session; +} + +function errorCode(error: unknown): number { + if (error instanceof AcpMethodNotFoundError) return -32601; + if (error instanceof AcpInvalidParamsError) return -32602; + return -32603; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function invalidRequestResponse(id: JsonRpcId): JsonRpcResponse { + return errorResponse(id, -32600, 'Invalid JSON-RPC request.'); +} + +function asRecord(value: unknown): Record { + const record = asOptionalRecord(value); + if (!record) throw new AcpInvalidParamsError('Expected object parameters.'); + return record; +} + +function asOptionalRecord(value: unknown): Record | undefined { + if (!value || typeof value !== 'object' || Array.isArray(value)) return undefined; + return value as Record; +} + +function stringField(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new AcpInvalidParamsError(`Expected ${key} to be a non-empty string.`); + } + return value; +} + +function bestEffortId(value: unknown): JsonRpcId { + const id = asOptionalRecord(value)?.id; + return typeof id === 'string' || typeof id === 'number' || id === null ? id : null; +} + +class AcpMethodNotFoundError extends Error {} +class AcpInvalidParamsError extends Error {} diff --git a/src/acp/server.ts b/src/acp/server.ts new file mode 100644 index 000000000..23d8e8f49 --- /dev/null +++ b/src/acp/server.ts @@ -0,0 +1,45 @@ +import { + createMcpPayloadQueue, + runStdioJsonRpcServer, + writeJsonRpcMessage, +} from '../mcp/server.ts'; +import { createAcpRouter, type AcpRouter } from './router.ts'; + +/** + * Stdio ACP agent: newline-delimited JSON-RPC 2.0, the same framing (and the + * same decoder/serialized-queue transport) as the MCP server. The one ACP + * twist is that `session/cancel` must take effect while a `session/prompt` is + * holding the queue, so cancel notifications are intercepted at the decoder + * sink and never enter the queue. + */ +export async function runAgentDeviceAcpServer(): Promise { + const router = createAcpRouter({ sendNotification: writeJsonRpcMessage }); + const queue = createMcpPayloadQueue({ + handlePayload: router.handleAcpPayload, + write: writeJsonRpcMessage, + }); + await runStdioJsonRpcServer({ + sink: createAcpPayloadSink(router, queue.push), + write: writeJsonRpcMessage, + idle: queue.idle, + }); +} + +export function createAcpPayloadSink( + router: Pick, + push: (payload: unknown) => void, +): (payload: unknown) => void { + return (payload) => { + if (isSessionCancelNotification(payload)) { + router.markSessionCancelled(payload); + return; + } + push(payload); + }; +} + +function isSessionCancelNotification(payload: unknown): boolean { + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return false; + const record = payload as Record; + return record.method === 'session/cancel' && record.id === undefined; +} diff --git a/src/acp/session-state.ts b/src/acp/session-state.ts new file mode 100644 index 000000000..6b30b3f22 --- /dev/null +++ b/src/acp/session-state.ts @@ -0,0 +1,49 @@ +import { randomUUID } from 'node:crypto'; +import type { CliFlags } from '../cli/parser/cli-flags.ts'; + +/** + * Flags that persist across prompt lines within one ACP session so a target + * selected once (`open app --platform ios`) applies to later lines + * (`snapshot -i`). Explicitly provided flags always win over sticky values. + */ +export const STICKY_FLAG_KEYS = ['platform', 'device', 'udid', 'session', 'stateDir'] as const; +export type StickyFlagKey = (typeof STICKY_FLAG_KEYS)[number]; +export type StickyFlags = Partial>; + +export type AcpSessionState = { + id: string; + cwd: string; + sticky: StickyFlags; + cancelled: boolean; + promptActive: boolean; + toolCallCounter: number; +}; + +export type AcpSessionStore = { + create: (cwd: string) => AcpSessionState; + get: (id: string) => AcpSessionState | undefined; +}; + +export function createAcpSessionStore(): AcpSessionStore { + const sessions = new Map(); + return { + create: (cwd) => { + const session: AcpSessionState = { + id: randomUUID(), + cwd, + sticky: {}, + cancelled: false, + promptActive: false, + toolCallCounter: 0, + }; + sessions.set(session.id, session); + return session; + }, + get: (id) => sessions.get(id), + }; +} + +export function nextToolCallId(session: AcpSessionState): string { + session.toolCallCounter += 1; + return `call-${session.toolCallCounter}`; +} diff --git a/src/acp/tool-kinds.ts b/src/acp/tool-kinds.ts new file mode 100644 index 000000000..385e55958 --- /dev/null +++ b/src/acp/tool-kinds.ts @@ -0,0 +1,55 @@ +import { getDaemonCommandRoute, getSessionCommandKind } from '../daemon/daemon-command-registry.ts'; +import type { AcpToolKind } from './contracts.ts'; + +/** + * Presentation-only mapping from agent-device commands to ACP tool kinds so + * clients pick sensible icons. Derived from the daemon route/session-kind + * traits where they say enough; the override map covers commands whose route + * (`generic`, `session`+`state`, …) does not distinguish reads from actions. + */ +const TOOL_KIND_OVERRIDES: Readonly> = { + get: 'read', + is: 'read', + wait: 'read', + find: 'search', + screenshot: 'read', + diff: 'read', + perf: 'read', + audio: 'read', + viewport: 'read', + doctor: 'read', + artifacts: 'read', + network: 'read', + install: 'execute', + 'install-from-source': 'execute', + reinstall: 'execute', + push: 'execute', + boot: 'execute', + shutdown: 'execute', + open: 'execute', + close: 'execute', + prepare: 'execute', + replay: 'execute', + test: 'execute', + batch: 'execute', + record: 'execute', + trace: 'execute', + settings: 'execute', + 'trigger-app-event': 'execute', + debug: 'read', + metro: 'execute', + session: 'read', +}; + +export function toolKindForCommand(command: string): AcpToolKind { + const override = TOOL_KIND_OVERRIDES[command]; + if (override) return override; + const route = getDaemonCommandRoute(command); + if (route === 'snapshot' || route === 'find') return 'read'; + if (route === 'interaction') return 'execute'; + if (route === 'recordTrace' || route === 'reactNative') return 'execute'; + const sessionKind = getSessionCommandKind(command); + if (sessionKind === 'inventory' || sessionKind === 'observability') return 'read'; + if (sessionKind === 'state' || sessionKind === 'replay') return 'execute'; + return 'other'; +} diff --git a/src/bin.ts b/src/bin.ts index b8fc85e00..d7765a8c6 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -8,6 +8,10 @@ if (runFastPath(argv)) { import('./mcp/server.ts') .then(({ runAgentDeviceMcpServer }) => runAgentDeviceMcpServer()) .catch(handleStartupError); +} else if (argv[0] === 'acp' && !argv.includes('--help') && !argv.includes('-h')) { + import('./acp/server.ts') + .then(({ runAgentDeviceAcpServer }) => runAgentDeviceAcpServer()) + .catch(handleStartupError); } else { runCli(argv); } diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 843288e3a..79f9a1557 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -63,6 +63,7 @@ export const INTERNAL_COMMANDS = { } as const; const LOCAL_CLI_COMMANDS = { + acp: 'acp', cdp: 'cdp', auth: 'auth', connect: 'connect', @@ -99,6 +100,7 @@ export type ClientBackedCliCommandName = | typeof LOCAL_CLI_COMMANDS.session; const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( + LOCAL_CLI_COMMANDS.acp, LOCAL_CLI_COMMANDS.auth, LOCAL_CLI_COMMANDS.cdp, LOCAL_CLI_COMMANDS.connect, @@ -112,6 +114,7 @@ const MCP_UNEXPOSED_CLI_COMMANDS = commandSet( ); const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( + LOCAL_CLI_COMMANDS.acp, LOCAL_CLI_COMMANDS.auth, LOCAL_CLI_COMMANDS.cdp, LOCAL_CLI_COMMANDS.connect, diff --git a/src/mcp/router.ts b/src/mcp/router.ts index 586f8fc51..ef8799630 100644 --- a/src/mcp/router.ts +++ b/src/mcp/router.ts @@ -8,7 +8,7 @@ const SUPPORTED_PROTOCOL_VERSION = '2025-11-25'; export type JsonRpcMessage = JsonRpcRequestEnvelope; -type JsonRpcResponse = +export type JsonRpcResponse = | { jsonrpc: '2.0'; id: JsonRpcId; result: unknown } | { jsonrpc: '2.0'; id: JsonRpcId; error: { code: number; message: string } }; @@ -67,9 +67,9 @@ async function callTool(params: unknown): Promise { } } -// Keep the full error contract (code + hint) visible to MCP agents; a bare -// message string strips exactly the guidance the hint system exists to give. -function formatToolError(error: unknown): string { +// Keep the full error contract (code + hint) visible to MCP and ACP agents; a +// bare message string strips exactly the guidance the hint system exists to give. +export function formatToolError(error: unknown): string { const normalized = normalizeError(error); const lines = [`Error (${normalized.code}): ${normalized.message}`]; if (normalized.hint) lines.push(`Hint: ${normalized.hint}`); @@ -88,11 +88,11 @@ function textToolResult(text: string, isError = false): ToolResult { }; } -function successResponse(id: JsonRpcId, result: unknown): JsonRpcResponse { +export function successResponse(id: JsonRpcId, result: unknown): JsonRpcResponse { return { jsonrpc: '2.0', id, result }; } -function errorResponse(id: JsonRpcId, code: number, message: string): JsonRpcResponse { +export function errorResponse(id: JsonRpcId, code: number, message: string): JsonRpcResponse { return { jsonrpc: '2.0', id, error: { code, message } }; } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 09a7b0c15..10a224494 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -8,16 +8,32 @@ type MessageWriter = (message: unknown) => void; export async function runAgentDeviceMcpServer(): Promise { const payloadQueue = createMcpPayloadQueue(); - const decoder = new McpMessageDecoder((payload) => { - payloadQueue.push(payload); + await runStdioJsonRpcServer({ + sink: payloadQueue.push, + write: writeJsonRpcMessage, + idle: payloadQueue.idle, }); +} + +/** + * Shared newline-delimited JSON-RPC stdio loop: decode stdin lines into the + * sink, report undecodable lines as -32700 parse errors, and resolve once + * stdin closes and the sink's work drains. The ACP server reuses this + * transport with its own sink and router. + */ +export async function runStdioJsonRpcServer(options: { + sink: MessageSink; + write: MessageWriter; + idle: () => Promise; +}): Promise { + const decoder = new McpMessageDecoder(options.sink); process.stdin.setEncoding('utf8'); process.stdin.on('data', (chunk: string) => { try { decoder.push(chunk); } catch (error) { - writeMessage({ + options.write({ jsonrpc: '2.0', id: null, error: { @@ -33,7 +49,7 @@ export async function runAgentDeviceMcpServer(): Promise { process.stdin.on('close', resolve); process.stdin.resume(); }); - await payloadQueue.idle(); + await options.idle(); } export function createMcpPayloadQueue( @@ -46,7 +62,7 @@ export function createMcpPayloadQueue( idle: () => Promise; } { const handlePayload = options.handlePayload ?? handleMcpPayload; - const write = options.write ?? writeMessage; + const write = options.write ?? writeJsonRpcMessage; let pending = Promise.resolve(); return { push: (payload) => { @@ -124,7 +140,7 @@ function bestEffortId(value: unknown): JsonRpcId { return null; } -class McpMessageDecoder { +export class McpMessageDecoder { private buffer = ''; private readonly sink: MessageSink; @@ -163,6 +179,6 @@ function responseArray(response: JsonRpcResponse | null): JsonRpcResponse[] { return response ? [response] : []; } -function writeMessage(message: unknown): void { +export function writeJsonRpcMessage(message: unknown): void { process.stdout.write(`${JSON.stringify(message)}\n`); } diff --git a/src/replay/script.ts b/src/replay/script.ts index e9feb4e4d..e4aed4fe8 100644 --- a/src/replay/script.ts +++ b/src/replay/script.ts @@ -399,7 +399,7 @@ function isNumericToken(token: string | undefined): token is string { return !Number.isNaN(Number(token)); } -function tokenizeReplayLine(line: string): string[] { +export function tokenizeReplayLine(line: string): string[] { const tokens: string[] = []; let cursor = 0; while (cursor < line.length) { diff --git a/src/utils/cli-command-overrides.ts b/src/utils/cli-command-overrides.ts index 85341436c..c8180b937 100644 --- a/src/utils/cli-command-overrides.ts +++ b/src/utils/cli-command-overrides.ts @@ -10,6 +10,12 @@ import { type SchemaOnlyCliCommandName = Exclude; const SCHEMA_ONLY_CLI_COMMAND_SCHEMAS = { + acp: { + helpDescription: `Start the stdio ACP (Agent Client Protocol) agent. + +ACP is a deterministic agent interface for ACP-capable editors and agent clients. It does not add LLM behavior: each text prompt line is parsed as one agent-device command line, with optional ACP slash-command syntax such as /devices or /snapshot -i. Commands execute through the CLI command reader, shared command contracts, and AgentDeviceClient; stdout remains newline-delimited JSON-RPC only.`, + summary: 'Start ACP agent', + }, cdp: { usageOverride: 'cdp [...args]', listUsageOverride: 'cdp', diff --git a/test/integration/smoke-acp.test.ts b/test/integration/smoke-acp.test.ts new file mode 100644 index 000000000..b217e9d6f --- /dev/null +++ b/test/integration/smoke-acp.test.ts @@ -0,0 +1,148 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runCmdBackground } from '../../src/utils/exec.ts'; + +type JsonLine = Record; + +const RESPONSE_TIMEOUT_MS = 30_000; + +function startAcpServer() { + const { child, wait } = runCmdBackground( + process.execPath, + ['--experimental-strip-types', 'src/bin.ts', 'acp'], + { stdio: ['pipe', 'pipe', 'pipe'], captureOutput: false }, + ); + const lines: JsonLine[] = []; + const waiters: Array<{ + predicate: (line: JsonLine) => boolean; + resolve: (line: JsonLine) => void; + }> = []; + let buffer = ''; + child.stdout?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + buffer += chunk; + for (;;) { + const newline = buffer.indexOf('\n'); + if (newline === -1) break; + const raw = buffer.slice(0, newline).trim(); + buffer = buffer.slice(newline + 1); + if (raw.length === 0) continue; + const line = JSON.parse(raw) as JsonLine; + lines.push(line); + for (let i = waiters.length - 1; i >= 0; i -= 1) { + if (waiters[i]!.predicate(line)) { + const [waiter] = waiters.splice(i, 1); + waiter!.resolve(line); + } + } + } + }); + + return { + child, + wait, + lines, + send: (message: JsonLine) => { + child.stdin?.write(`${JSON.stringify(message)}\n`); + }, + waitFor: (label: string, predicate: (line: JsonLine) => boolean): Promise => { + const existing = lines.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`Timed out waiting for ${label}`)), + RESPONSE_TIMEOUT_MS, + ); + waiters.push({ + predicate, + resolve: (line) => { + clearTimeout(timer); + resolve(line); + }, + }); + }); + }, + }; +} + +function isSessionUpdate(line: JsonLine, sessionUpdate: string): boolean { + if (line.method !== 'session/update') return false; + const update = (line.params as { update?: { sessionUpdate?: string } } | undefined)?.update; + return update?.sessionUpdate === sessionUpdate; +} + +test('acp stdio agent serves initialize, session/new, prompt refusal, and clean shutdown', async () => { + const server = startAcpServer(); + try { + server.send({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: 1, clientCapabilities: {} }, + }); + const initialize = await server.waitFor('initialize response', (line) => line.id === 1); + const initResult = initialize.result as { + protocolVersion: number; + authMethods: unknown[]; + agentInfo: { name: string }; + }; + assert.equal(initResult.protocolVersion, 1); + assert.deepEqual(initResult.authMethods, []); + assert.equal(initResult.agentInfo.name, 'agent-device'); + + server.send({ + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: process.cwd(), mcpServers: [] }, + }); + const newSession = await server.waitFor('session/new response', (line) => line.id === 2); + const sessionId = (newSession.result as { sessionId: string }).sessionId; + assert.ok(sessionId.length > 0); + + const commandsUpdate = await server.waitFor('available_commands_update', (line) => + isSessionUpdate(line, 'available_commands_update'), + ); + const commands = ( + commandsUpdate.params as { + update: { availableCommands: Array<{ name: string; description: string }> }; + } + ).update.availableCommands.map((command) => command.name); + assert.ok(commands.includes('snapshot')); + assert.ok(commands.includes('press')); + assert.ok(!commands.includes('acp')); + // The session/new response must have been written before the notification. + assert.ok( + server.lines.indexOf(newSession) < server.lines.indexOf(commandsUpdate), + 'available_commands_update arrived before the session/new response', + ); + + // An unrunnable prompt is refused deterministically without a daemon/device. + server.send({ + jsonrpc: '2.0', + id: 3, + method: 'session/prompt', + params: { sessionId, prompt: [{ type: 'text', text: 'frobnicate' }] }, + }); + const prompt = await server.waitFor('session/prompt response', (line) => line.id === 3); + assert.deepEqual(prompt.result, { stopReason: 'refusal' }); + await server.waitFor('refusal agent message', (line) => + isSessionUpdate(line, 'agent_message_chunk'), + ); + + server.send({ + jsonrpc: '2.0', + id: 4, + method: 'session/prompt', + params: { sessionId: 'unknown-session', prompt: [{ type: 'text', text: 'devices' }] }, + }); + const unknownSession = await server.waitFor('unknown session error', (line) => line.id === 4); + assert.equal((unknownSession.error as { code: number }).code, -32602); + + server.child.stdin?.end(); + const exit = await server.wait; + assert.equal(exit.exitCode, 0); + } finally { + if (server.child.exitCode === null) server.child.kill(); + } +}); diff --git a/website/docs/docs/agent-setup.md b/website/docs/docs/agent-setup.md index e97b0172f..52ad0889b 100644 --- a/website/docs/docs/agent-setup.md +++ b/website/docs/docs/agent-setup.md @@ -1,15 +1,15 @@ --- title: AI Agent Setup -description: Configure Cursor, Codex, Claude Code, Windsurf, Cline, Goose, skills, and MCP for agent-device mobile, TV, desktop, and web app verification. +description: Configure Cursor, Codex, Claude Code, Windsurf, Cline, Goose, Zed, skills, MCP, and ACP for agent-device mobile, TV, desktop, and web app verification. --- # AI Agent Setup `agent-device` is built for AI agents, but humans usually install it, grant device permissions, and decide which agent client should use it. -Use this page to wire Cursor, Codex, Claude Code, Windsurf, Cline, Goose, or another coding agent into mobile, TV, desktop, and web app verification. It covers skills, project rules, and MCP setup for React Native QA, Expo app verification, iOS Simulator automation, Android Emulator automation, tvOS checks, Android TV checks, web browser sessions, debugging, profiling, and exploratory QA. +Use this page to wire Cursor, Codex, Claude Code, Windsurf, Cline, Goose, Zed, ACP clients, or another coding agent into mobile, TV, desktop, and web app verification. It covers skills, project rules, MCP setup, and ACP setup for React Native QA, Expo app verification, iOS Simulator automation, Android Emulator automation, tvOS checks, Android TV checks, web browser sessions, debugging, profiling, and exploratory QA. -The short version: install the CLI, make the agent read version-matched help, and let the agent use either MCP tools or CLI commands. MCP tools use command contracts backed by the same `AgentDeviceClient` execution path as the CLI adapters. +The short version: install the CLI, make the agent read version-matched help, and let the agent use CLI commands, MCP tools, or the ACP agent depending on what its client supports. MCP tools and ACP command turns share command contracts and the same daemon/client implementation; ACP reads CLI-shaped prompt lines while MCP receives structured tool input. ## Prerequisite: install the CLI @@ -90,6 +90,40 @@ No global install variant. Pin a user- or project-selected package version for u Registry metadata uses MCP name `io.github.callstackincubator/agent-device`, npm package `agent-device`, stdio transport, `mcpName` package verification, `server.json`, `glama.json`, and `smithery.yaml`. Glama lists the server at [callstack/agent-device](https://glama.ai/mcp/servers/callstack/agent-device). +## ACP agent (Zed and other ACP clients) + +`agent-device acp` starts a stdio [ACP](https://agentclientprotocol.com/) (Agent Client Protocol) agent, so ACP clients such as Zed can drive devices from the agent panel. It is a deterministic agent, not an LLM: each line of a prompt is one agent-device command in CLI syntax (ACP slash commands such as `/devices` and an optional leading `agent-device` are accepted), executed through the CLI command reader, shared command contracts, and `AgentDeviceClient`. Command executions stream back as ACP tool calls with daemon progress updates, screenshots attach as inline images, and available commands are advertised per session. + +Target selection sticks within a session: after `open com.example.app --platform ios`, later lines such as `snapshot -i` reuse the same `--platform`, `--device`, `--udid`, `--session`, and `--state-dir` values until a line overrides them. Project config resolution uses the working directory the ACP client passes on `session/new`. + +Refs follow CLI semantics in ACP prompts. MCP tools auto-pin plain refs across tool calls, but ACP prompt lines do not add MCP ref-generation pins; after another `snapshot` or `find`, use the current refs from the latest output or durable selectors. + +Zed `settings.json`: + +```json +{ + "agent_servers": { + "agent-device": { + "type": "custom", + "command": "agent-device", + "args": ["acp"] + } + } +} +``` + +Prompts are literal command lines, one per line: + +``` +open com.example.app --platform ios +snapshot -i +press @e2 +screenshot +close +``` + +Natural-language prompts are refused with guidance rather than guessed at; keep the reasoning in the ACP client's own model and send resolved commands here. `session/cancel` stops the turn between commands; the command already in flight runs to completion. + ## Cursor Cursor works well with either the plain CLI or MCP tools. Use the CLI path when you want the most auditable setup and terminal-visible commands. Add MCP when you want Cursor Agent to discover structured `agent-device` tools directly from chat. @@ -263,7 +297,7 @@ If the client has project rules or custom instructions, add the recommended agen ## Why this setup works -The CLI stays the auditable automation surface, installed help stays version-matched with the commands, skills and rules route agents toward the right help topics, and MCP gives compatible clients direct structured tools backed by the same daemon/client implementation. +The CLI stays the auditable automation surface, installed help stays version-matched with the commands, skills and rules route agents toward the right help topics, MCP gives compatible clients direct structured tools, and ACP gives compatible clients a deterministic prompt-line agent backed by the same daemon/client implementation. For the broader positioning, supported targets, observability features, and how `agent-device` differs from scripted test frameworks, see [Introduction](/docs/introduction). For exact command groups and platform behavior, see [Commands](/docs/commands). diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index f447e30ab..aac0d8521 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -32,6 +32,14 @@ agent-device mcp The MCP server exposes direct structured tools for installed commands. Tools use structured input contracts through `AgentDeviceClient`; local-only workflows stay CLI-only rather than subprocess fallbacks. It does not expose generic shell execution over MCP. MCP tools can target `platform: "web"` after `agent-device web setup`, but setup and doctor stay CLI-only. +For ACP-aware clients that support Agent Client Protocol agents, run: + +```bash +agent-device acp +``` + +The ACP agent exposes installed commands as a deterministic prompt-line interface. ACP clients send prompt text such as `/devices` or `snapshot -i`; `agent-device` parses one command per line, executes through the CLI command reader, shared command contracts, and `AgentDeviceClient`, streams ACP tool-call updates, and refuses natural-language prompts instead of guessing. + ## Navigation ```bash diff --git a/website/docs/docs/installation.md b/website/docs/docs/installation.md index 653336354..049433bc9 100644 --- a/website/docs/docs/installation.md +++ b/website/docs/docs/installation.md @@ -26,7 +26,7 @@ agent-device help cdp Some agent clients run commands in an environment that differs from the user's normal install shell. If `agent-device` is missing in the agent terminal but was installed globally elsewhere, resolve the command the same way the user would from a normal terminal session, then use the absolute binary path for agent commands. This may require inspecting shell startup behavior or package-manager/global bin locations; do not assume the agent process `PATH` is the user's `PATH`. -For Cursor, Codex, Claude Code, Windsurf, Cline, Goose, skills, and project rules, see [AI Agent Setup](/docs/agent-setup). For the first app automation commands, see [Quick Start](/docs/quick-start). +For Cursor, Codex, Claude Code, Windsurf, Cline, Goose, skills, MCP, ACP, and project rules, see [AI Agent Setup](/docs/agent-setup). For the first app automation commands, see [Quick Start](/docs/quick-start). Interactive CLI runs periodically check for a newer published `agent-device` package in the background. When an upgrade is available, the CLI suggests reinstalling the package globally: @@ -37,7 +37,7 @@ agent-device --version Set `AGENT_DEVICE_NO_UPDATE_NOTIFIER=1` to disable the notice. -## Agent clients and MCP +## Agent clients, MCP, and ACP The official MCP server exposes direct structured tools for installed `agent-device` commands. Tools use command contracts through `AgentDeviceClient`, so app and device automation still uses the same daemon implementation. @@ -45,7 +45,13 @@ The official MCP server exposes direct structured tools for installed `agent-dev agent-device mcp ``` -Use [AI Agent Setup](/docs/agent-setup#mcp-server) for copy-paste MCP client configuration. +The ACP agent exposes the same command surface to ACP-capable editors and agent clients as explicit prompt lines. It is deterministic: prompt text such as `/devices` or `snapshot -i` is parsed as command lines and executed through `AgentDeviceClient`; natural-language instructions are refused instead of guessed. + +```bash +agent-device acp +``` + +Use [AI Agent Setup](/docs/agent-setup) for copy-paste MCP and ACP client configuration. ## Without installing diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index b16c2fd0a..0908ff8b9 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -53,7 +53,7 @@ agent-device help cdp agent-device help dogfood ``` -Use [AI Agent Setup](/docs/agent-setup) for Cursor, Codex, Claude Code, Windsurf, Cline, Goose, skills, and MCP setup. Use [Commands](/docs/commands) for detailed command groups and platform behavior. +Use [AI Agent Setup](/docs/agent-setup) for Cursor, Codex, Claude Code, Windsurf, Cline, Goose, skills, MCP setup, and ACP setup. Use [Commands](/docs/commands) for detailed command groups and platform behavior. ## Where it fits @@ -63,6 +63,8 @@ It complements scripted test frameworks such as Appium, Maestro, Detox, XCTest, MCP support exposes direct structured tools for installed `agent-device` commands. Tools use structured input contracts through `AgentDeviceClient`, so MCP clients can call device workflows directly while the daemon remains the execution source of truth. +ACP support exposes a deterministic stdio agent for ACP-capable editors and agent clients. The ACP agent interprets prompt text as explicit `agent-device` command lines, including advertised slash commands such as `/devices`, and executes them through the CLI command reader, shared command contracts, and `AgentDeviceClient`. It is not an LLM and does not guess natural-language actions. + ## Next steps - Install the CLI: [Installation](/docs/installation) diff --git a/website/docs/docs/security-trust.md b/website/docs/docs/security-trust.md index 9c8fcaabe..30cf40ba7 100644 --- a/website/docs/docs/security-trust.md +++ b/website/docs/docs/security-trust.md @@ -11,6 +11,8 @@ description: Security and trust guidance for agent-device local app automation, - Device automation runs through the installed CLI and platform tooling such as Xcode, ADB, macOS accessibility APIs, and Linux AT-SPI. - The MCP server exposes direct structured tools for `agent-device` commands. Tools use command contracts through `AgentDeviceClient`; local-only workflows stay CLI-only rather than subprocess fallbacks. It does not expose generic shell execution over MCP. +- The ACP agent exposes a deterministic prompt-line interface for ACP clients. It accepts explicit command lines and advertised slash commands, executes them through `AgentDeviceClient`, and refuses prompts that do not contain runnable commands. It is not a generic shell or an LLM. +- MCP and ACP stdio stdout are protocol channels. Do not wrap these commands with logging or banner output on stdout; use stderr or client-side logs for diagnostics. - Mutating commands should run serially against one session. Use separate sessions/devices for parallel work. ## Daemon trust model