diff --git a/.changeset/acp-reliability-overhaul.md b/.changeset/acp-reliability-overhaul.md new file mode 100644 index 0000000..1721865 --- /dev/null +++ b/.changeset/acp-reliability-overhaul.md @@ -0,0 +1,25 @@ +--- +"use-local-agent": minor +--- + +ACP reliability and spec coverage overhaul. + +- Bumps `@agentclientprotocol/sdk` peer dep to `^0.20.0`, picking up the upstream reliability rework: clean transport-failure handling (#103), final ndjson message flush (#119), no spurious unhandled rejection on transport errors (#122), notification/response ordering (#130), private-keyword cross-copy compatibility (#127), stable `closeSession`/`resumeSession` (#132). +- Spawn race: fast-fails as `AgentSpawnError` when the subprocess cannot spawn, before the initialize timeout fires. +- Initialize fast-fail: subprocess exit during initialize raises `AgentConnectionClosedError` immediately (with stderr tail). +- Stderr fatal-pattern detection is now gated on a successful initialize, eliminating boot-banner false positives. `stderrFatalPatterns` lets callers override the auth/usage match lists. +- Inactivity watchdog now pauses while a permission request is pending in stream mode and treats permission events as activity. +- Per-session buffered update cap (`MAX_BUFFERED_UPDATES_PER_SESSION = 1024`) drops oldest first. +- `loadSessionStreaming({ sessionId, cwd, ... })` exposes the replay updates emitted during `session/load` as an async iterable; existing `loadSession` preserved. +- Slash command helper: `prompt(sessionId, { command: { name, input? } })` formats `/ ` text content and validates against `available_commands_update`. New helpers: `commandsFor`, `modeStateFor`, `configOptionsFor`. +- New session-input field `additionalDirectories` (gated on `sessionCapabilities.additionalDirectories`) with absolute-path validation. +- `listSessions({ cwd?, cursor? })` now supports cursor pagination; new `streamAllSessions` auto-paginates. +- `_meta` trace context propagation via opt-in `traceContext: () => Record` (filters to W3C-reserved `traceparent` / `tracestate` / `baggage`). +- Optional `terminal` capability: pass `terminal: TerminalHandlers` to advertise and forward all five `terminal/*` methods. +- Auth retry hook: `onAuthRequired(methods)` is invoked on `auth_required` (-32000) and re-runs `session/new` after a successful `authenticate`. +- Trace and dispose tuning: `onTrace`, `disposeGraceMs`, `envFilter` connect options. +- Adapter env fallbacks: `ANTHROPIC_API_KEY` (claude), `GITHUB_TOKEN` (copilot), `GEMINI_API_KEY` / `GOOGLE_API_KEY` (gemini). +- `runCommand`: SIGTERM→SIGKILL escalation on timeout. +- Error wrapping preserves `cause.message` when distinct from the wrapper's message. + +No breaking changes to the public API. diff --git a/apps/playground/README.md b/apps/playground/README.md new file mode 100644 index 0000000..d8af454 --- /dev/null +++ b/apps/playground/README.md @@ -0,0 +1,107 @@ +# `@use-local-agent/playground` + +Local web playground for the `use-local-agent` library. + +```bash +pnpm install +pnpm --filter @use-local-agent/playground dev # vite dev server +pnpm --filter @use-local-agent/playground test # playwright (node + browser) +``` + +## What it does + +`apps/playground/src/server.ts` exposes a WebSocket bridge at `/agent` that +connects to a deterministic local agent fixture (`src/echo-agent.mjs`). + +`echo-agent.mjs` is a tiny ACP agent built on top of +`@agentclientprotocol/sdk`. It exists for two reasons: + +1. To provide a real subprocess for the Playwright e2e tests in + `tests/spawn.node.spec.ts` to drive — exercising the entire + stdio / NDJSON pipeline. +2. To let the browser playground talk to a real ACP agent without requiring + a heavy LLM-backed CLI. + +## Echo agent commands + +The bundled agent recognizes the following inputs: + +| Prompt | Behavior | +| ------------- | ---------------------------------------------------------------------- | +| `ping` | streams `pong` | +| `/tool` | emits a `tool_call` then a `tool_call_update` (status `completed`) | +| `/cancel` | waits for `session/cancel` and resolves with `stopReason: "cancelled"` | +| `/usage` | emits a `usage_update` notification | +| `/auth` | rejects with JSON-RPC `-32000` (auth_required) | +| `/die` | emits one chunk and then exits the process to simulate a crash | +| anything else | echoes the prompt back as text deltas | + +## Wiring optional capabilities + +The wrapper only advertises optional capabilities when the host provides +handlers for them. Two examples: + +### Filesystem + +```ts +const agent = await LocalAgent.connect("claude", { + fileSystem: { + readTextFile: async ({ path, line, limit }) => { + const content = await readFile(path, "utf-8"); + return { content }; + }, + writeTextFile: async ({ path, content }) => { + await writeFile(path, content); + return {}; + }, + }, +}); +``` + +### Terminal (`terminal/*`) + +Pass `terminal: TerminalHandlers` to `LocalAgent.connect` to advertise +`clientCapabilities.terminal = true` and forward the agent's +`terminal/{create,output,wait_for_exit,kill,release}` calls. Always pair +`createTerminal` with a deterministic `releaseTerminal` to free resources. + +```ts +const agent = await LocalAgent.connect("codex", { + terminal: { + createTerminal: async (params) => myShell.create(params), + terminalOutput: async (params) => myShell.output(params), + releaseTerminal: async (params) => myShell.release(params), + waitForTerminalExit: async (params) => myShell.wait(params), + killTerminal: async (params) => myShell.kill(params), + }, +}); +``` + +### Additional workspace directories + +When the agent advertises +`sessionCapabilities.additionalDirectories`, expand the session's +filesystem scope (e.g., monorepo siblings) without changing `cwd`: + +```ts +await agent.createSession({ + cwd: "/repo/apps/web", + additionalDirectories: ["/repo/packages/shared"], +}); +``` + +Paths must be absolute; relative paths throw `AgentStreamError`. + +## Tests + +- `tests/spawn.node.spec.ts` — real-subprocess e2e against `echo-agent.mjs` + (covers initialize, streaming, tool calls, usage, auth_required, + cancellation, mid-stream crashes). +- `tests/playground.browser.spec.ts` — full browser flow over WebSocket. + +Run: + +```bash +pnpm --filter @use-local-agent/playground exec playwright test --project=node +pnpm --filter @use-local-agent/playground exec playwright test --project=chromium +``` diff --git a/apps/playground/package.json b/apps/playground/package.json index b7c2db8..465494c 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -12,7 +12,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1", + "@agentclientprotocol/sdk": "^0.20.0", "@wterm/dom": "^0.1.9", "use-local-agent": "workspace:*", "ws": "^8.18.0" diff --git a/apps/playground/src/echo-agent.mjs b/apps/playground/src/echo-agent.mjs index 4d21a7b..04f0cd8 100644 --- a/apps/playground/src/echo-agent.mjs +++ b/apps/playground/src/echo-agent.mjs @@ -61,8 +61,8 @@ const agent = { }, loadSession: async () => ({}), - unstable_resumeSession: async () => ({}), - unstable_closeSession: async () => ({}), + resumeSession: async () => ({}), + closeSession: async () => ({}), listSessions: async () => ({ sessions: [] }), setSessionMode: async () => ({}), setSessionConfigOption: async (params) => ({ diff --git a/packages/use-local-agent/README.md b/packages/use-local-agent/README.md index f2c7753..5594df4 100644 --- a/packages/use-local-agent/README.md +++ b/packages/use-local-agent/README.md @@ -105,8 +105,69 @@ for await (const event of stream) { - **Adapter**: per-provider launch metadata (`bin`, `args`, `env`) plus install/auth checks. Built-in adapters live in `use-local-agent/adapters`. - **`LocalAgent`**: a single ACP subprocess. Multiple sessions, multiple turns. Implements `Symbol.asyncDispose`. -- **`Session`**: a conversation context with its own history. Resume via `agent.loadSession({ sessionId, cwd })`. -- **`AgentEvent`**: the public event union. `text-delta`, `thinking-delta`, `tool-call`, `tool-call-update`, `plan`, `permission-request`, `config-options`, `usage`, `finish`, plus `raw` for forward-compat with new ACP updates. +- **`Session`**: a conversation context with its own history. Resume via `agent.loadSession({ sessionId, cwd })`, `agent.loadSessionStreaming(...)` (replay-aware), `agent.resumeSession(...)`, or `agent.closeSession(...)` when the agent advertises the matching capability. +- **`AgentEvent`**: the public event union. `text-delta`, `thinking-delta`, `tool-call`, `tool-call-update`, `plan`, `permission-request`, `config-options`, `available-commands`, `mode-changed`, `session-info`, `usage`, `finish`, `raw`. + +## Reliability features + +`use-local-agent` targets the latest ACP TypeScript SDK (`@agentclientprotocol/sdk@^0.20.0`) so it inherits these upstream reliability fixes: clean transport-failure handling (#103), ndjson decoder flush (#119), no spurious unhandled rejection (#122), notification/response ordering (#130), private-keyword cross-copy compatibility (#127), stable `closeSession`/`resumeSession` (#132). + +On top of those, the wrapper itself adds: + +- **Spawn race detection** — fails fast as `AgentSpawnError` when the child binary cannot be launched, before initialize times out. +- **Initialize fast-fail** — if the subprocess exits before responding to `initialize`, you get `AgentConnectionClosedError` (with stderr tail) instead of waiting `initializeTimeoutMs`. +- **Stderr fatal gating** — auth/usage stderr patterns are only escalated _after_ `initialize` succeeds, eliminating boot-banner false positives. +- **Inactivity watchdog** — pauses while a permission request is pending in stream mode so users can take their time. +- **Buffered update cap** — drops oldest first when an agent emits >1024 buffered updates for a single session. +- **`onTrace` hook** — observe inbound/outbound JSON-RPC and stderr without parsing logs. +- **`disposeGraceMs`** — configurable SIGTERM→SIGKILL grace. +- **`envFilter` hook** — scrub environment before passing to the child. + +## Slash commands + +```ts +const stream = agent.prompt(sessionId, { + command: { name: "web", input: "agent client protocol" }, +}); +// → forwards `/web agent client protocol` as a text content block +agent.commandsFor(sessionId); // currently advertised commands +``` + +## Working directories + +Pass `additionalDirectories: ["/workspace/sub-pkg"]` to `createSession` / +`loadSession` / `resumeSession` to expand the session's filesystem scope when +the agent advertises `sessionCapabilities.additionalDirectories`. + +## Listing sessions + +```ts +for await (const info of agent.streamAllSessions({ cwd: process.cwd() })) { + console.log(info.sessionId, info.title); +} +``` + +## Trace context + +Pass `traceContext: () => ({ traceparent, tracestate, baggage })` to +`LocalAgent.connect`. Only the W3C-reserved keys are forwarded into request +`_meta` for compatibility with OpenTelemetry and MCP tooling. + +## Auth retry + +```ts +const agent = await LocalAgent.connect("claude", { + onAuthRequired: async (methods) => { + const choice = await pickAuthMethod(methods); + return choice?.id; + }, +}); +``` + +When `session/new` returns `auth_required` (-32000), the hook is invoked. +Returning a method id triggers `authenticate` then a single retry of +`session/new`. Returning `undefined` preserves the original +`AgentUnauthenticatedError`. ## API diff --git a/packages/use-local-agent/package.json b/packages/use-local-agent/package.json index 719f85c..3e73aad 100644 --- a/packages/use-local-agent/package.json +++ b/packages/use-local-agent/package.json @@ -33,7 +33,7 @@ "check": "vp check" }, "dependencies": { - "@agentclientprotocol/sdk": "^0.16.1" + "@agentclientprotocol/sdk": "^0.20.0" }, "devDependencies": { "@types/node": "^22.19.17", diff --git a/packages/use-local-agent/src/adapters/claude.ts b/packages/use-local-agent/src/adapters/claude.ts index e0b06cd..5ccfc6b 100644 --- a/packages/use-local-agent/src/adapters/claude.ts +++ b/packages/use-local-agent/src/adapters/claude.ts @@ -22,6 +22,12 @@ export const claude = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ } }, checkAuthenticated: async () => { + if ( + typeof process.env.ANTHROPIC_API_KEY === "string" && + process.env.ANTHROPIC_API_KEY.trim().length > 0 + ) { + return true; + } try { const result = await runCommand("claude", ["auth", "status"], { timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, @@ -50,21 +56,27 @@ export const claude = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ ); } - let authStatus: { loggedIn?: boolean } = {}; - try { - const result = await runCommand("claude", ["auth", "status"], { - timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, - }); - authStatus = JSON.parse(result.stdout || "{}") as { loggedIn?: boolean }; - } catch { - authStatus = {}; - } - if (!authStatus.loggedIn) { - throw new AgentUnauthenticatedError( - PROVIDER, - "Claude Code is not authenticated. Run `claude login` and try again.", - "claude login", - ); + const hasEnvKey = + typeof process.env.ANTHROPIC_API_KEY === "string" && + process.env.ANTHROPIC_API_KEY.trim().length > 0; + + if (!hasEnvKey) { + let authStatus: { loggedIn?: boolean } = {}; + try { + const result = await runCommand("claude", ["auth", "status"], { + timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, + }); + authStatus = JSON.parse(result.stdout || "{}") as { loggedIn?: boolean }; + } catch { + authStatus = {}; + } + if (!authStatus.loggedIn) { + throw new AgentUnauthenticatedError( + PROVIDER, + "Claude Code is not authenticated. Set ANTHROPIC_API_KEY or run `claude login` and try again.", + "claude login", + ); + } } const shimPath = resolvePackageEntry(SHIM_PACKAGE, SHIM_ENTRY); diff --git a/packages/use-local-agent/src/adapters/copilot.ts b/packages/use-local-agent/src/adapters/copilot.ts index 6893ab6..6fd59ee 100644 --- a/packages/use-local-agent/src/adapters/copilot.ts +++ b/packages/use-local-agent/src/adapters/copilot.ts @@ -19,6 +19,12 @@ export const copilot = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ } }, checkAuthenticated: async () => { + if ( + typeof process.env.GITHUB_TOKEN === "string" && + process.env.GITHUB_TOKEN.trim().length > 0 + ) { + return true; + } try { const result = await runCommand("gh", ["auth", "token"], { timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, @@ -29,24 +35,28 @@ export const copilot = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ } }, resolve: async () => { - try { - const result = await runCommand("gh", ["auth", "token"], { - timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, - }); - if (result.exitCode !== 0 || result.stdout.trim().length === 0) { + const hasEnvToken = + typeof process.env.GITHUB_TOKEN === "string" && process.env.GITHUB_TOKEN.trim().length > 0; + if (!hasEnvToken) { + try { + const result = await runCommand("gh", ["auth", "token"], { + timeoutMs: DEFAULT_AUTH_CHECK_TIMEOUT_MS, + }); + if (result.exitCode !== 0 || result.stdout.trim().length === 0) { + throw new AgentUnauthenticatedError( + PROVIDER, + "GitHub CLI auth token is empty. Set GITHUB_TOKEN or run `gh auth login` and try again.", + "gh auth login", + ); + } + } catch (cause) { + if (cause instanceof AgentUnauthenticatedError) throw cause; throw new AgentUnauthenticatedError( PROVIDER, - "GitHub CLI auth token is empty. Run `gh auth login` and try again.", + "Unable to verify GitHub Copilot auth. Set GITHUB_TOKEN or run `gh auth login`.", "gh auth login", ); } - } catch (cause) { - if (cause instanceof AgentUnauthenticatedError) throw cause; - throw new AgentUnauthenticatedError( - PROVIDER, - "Unable to verify GitHub Copilot auth via `gh auth token`.", - "gh auth login", - ); } if (options.binPath) { return { bin: options.binPath, args: ["--acp"], env: options.env ?? {} }; diff --git a/packages/use-local-agent/src/adapters/gemini.ts b/packages/use-local-agent/src/adapters/gemini.ts index 071e645..411f7c2 100644 --- a/packages/use-local-agent/src/adapters/gemini.ts +++ b/packages/use-local-agent/src/adapters/gemini.ts @@ -9,7 +9,13 @@ const SHIM_PACKAGE = "@google/gemini-cli"; const homedir = (): string | undefined => process.env.HOME ?? process.env.USERPROFILE; +const hasEnvKey = (): boolean => { + const candidates = [process.env.GEMINI_API_KEY, process.env.GOOGLE_API_KEY]; + return candidates.some((value) => typeof value === "string" && value.trim().length > 0); +}; + const isAuthenticated = async (): Promise => { + if (hasEnvKey()) return true; const home = homedir(); if (!home) return false; const accountsPath = path.join(home, ".gemini", "google_accounts.json"); @@ -42,7 +48,7 @@ export const gemini = (options: AdapterFactoryOptions = {}): AgentAdapter => ({ if (!(await isAuthenticated())) { throw new AgentUnauthenticatedError( PROVIDER, - "Gemini CLI is not authenticated. Run `gemini auth login` and try again.", + "Gemini CLI is not authenticated. Set GEMINI_API_KEY/GOOGLE_API_KEY or run `gemini auth login` and try again.", "gemini auth login", ); } diff --git a/packages/use-local-agent/src/connect.ts b/packages/use-local-agent/src/connect.ts index f60f531..76c2e0d 100644 --- a/packages/use-local-agent/src/connect.ts +++ b/packages/use-local-agent/src/connect.ts @@ -1,17 +1,22 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { Readable } from "node:stream"; -import type { Client } from "@agentclientprotocol/sdk"; +import type { AnyMessage, Client } from "@agentclientprotocol/sdk"; import { ClientSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; import type { AgentAdapter } from "./adapter"; import { DEFAULT_DISPOSE_GRACE_MS, DEFAULT_STDERR_TAIL_LIMIT_BYTES } from "./constants"; import { AgentSpawnError } from "./errors"; +export type TraceDirection = "out" | "in" | "stderr"; + export interface ConnectOptions { readonly client: Client; readonly onStderr?: (line: string) => void; readonly onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void; readonly extraEnv?: NodeJS.ProcessEnv; readonly stderrTailLimit?: number; + readonly disposeGraceMs?: number; + readonly onTrace?: (direction: TraceDirection, payload: unknown) => void; + readonly envFilter?: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; } export interface ConnectResult { @@ -29,11 +34,12 @@ export const connect = async ( options: ConnectOptions, ): Promise => { const resolved = await adapter.resolve(); - const env: NodeJS.ProcessEnv = { + const mergedEnv: NodeJS.ProcessEnv = { ...process.env, ...resolved.env, ...options.extraEnv, }; + const env = options.envFilter ? options.envFilter(mergedEnv) : mergedEnv; let child: ChildProcessWithoutNullStreams; try { @@ -45,6 +51,8 @@ export const connect = async ( throw new AgentSpawnError(adapter.id, cause); } + await waitForSpawnOrError(child, adapter.id); + const stderrLimit = options.stderrTailLimit ?? DEFAULT_STDERR_TAIL_LIMIT_BYTES; let stderrTail = ""; const appendStderrTail = (chunk: string): void => { @@ -59,6 +67,7 @@ export const connect = async ( child.stderr.on("data", (chunk: Buffer) => { const text = chunk.toString("utf8"); appendStderrTail(text); + options.onTrace?.("stderr", text); if (!options.onStderr) return; stderrLineBuffer += text; let newlineIndex = stderrLineBuffer.indexOf("\n"); @@ -102,15 +111,17 @@ export const connect = async ( }), }); - const stream = ndJsonStream(childStdinWritable, childStdoutWebStream); + const baseStream = ndJsonStream(childStdinWritable, childStdoutWebStream); + const stream = options.onTrace ? wrapStreamWithTrace(baseStream, options.onTrace) : baseStream; const connection = new ClientSideConnection(() => options.client, stream); + const disposeGraceMs = options.disposeGraceMs ?? DEFAULT_DISPOSE_GRACE_MS; const dispose = async (): Promise => { if (!child.killed) { child.kill("SIGTERM"); const killTimer = setTimeout(() => { if (!child.killed) child.kill("SIGKILL"); - }, DEFAULT_DISPOSE_GRACE_MS); + }, disposeGraceMs); try { await closed; } finally { @@ -129,3 +140,66 @@ export const connect = async ( dispose, }; }; + +const waitForSpawnOrError = ( + child: ChildProcessWithoutNullStreams, + adapterId: string, +): Promise => + new Promise((resolveSpawn, rejectSpawn) => { + const onSpawn = (): void => { + child.removeListener("error", onError); + resolveSpawn(); + }; + const onError = (error: Error): void => { + child.removeListener("spawn", onSpawn); + rejectSpawn(new AgentSpawnError(adapterId, error)); + }; + child.once("spawn", onSpawn); + child.once("error", onError); + }); + +const wrapStreamWithTrace = ( + base: { writable: WritableStream; readable: ReadableStream }, + onTrace: (direction: TraceDirection, payload: unknown) => void, +): { writable: WritableStream; readable: ReadableStream } => { + const tracedReadable = base.readable.pipeThrough( + new TransformStream({ + transform(message, controller) { + try { + onTrace("in", message); + } catch {} + controller.enqueue(message); + }, + }), + ); + const tracedWritable = new WritableStream({ + async write(message) { + try { + onTrace("out", message); + } catch {} + const writer = base.writable.getWriter(); + try { + await writer.write(message); + } finally { + writer.releaseLock(); + } + }, + async close() { + const writer = base.writable.getWriter(); + try { + await writer.close(); + } finally { + writer.releaseLock(); + } + }, + async abort(reason) { + const writer = base.writable.getWriter(); + try { + await writer.abort(reason); + } finally { + writer.releaseLock(); + } + }, + }); + return { writable: tracedWritable, readable: tracedReadable }; +}; diff --git a/packages/use-local-agent/src/constants.ts b/packages/use-local-agent/src/constants.ts index 17703eb..9e8ec89 100644 --- a/packages/use-local-agent/src/constants.ts +++ b/packages/use-local-agent/src/constants.ts @@ -10,6 +10,8 @@ export const DEFAULT_DISPOSE_GRACE_MS = 2_000; export const DEFAULT_STDERR_TAIL_LIMIT_BYTES = 8 * 1024; export const STDERR_TAIL_PREVIEW_BYTES = 500; +export const MAX_BUFFERED_UPDATES_PER_SESSION = 1024; + export const PACKAGE_NAME = "use-local-agent"; export const PACKAGE_VERSION = "0.0.0"; export const PACKAGE_TITLE = "Use Local Agent"; diff --git a/packages/use-local-agent/src/errors.ts b/packages/use-local-agent/src/errors.ts index 4f38bed..96a42bd 100644 --- a/packages/use-local-agent/src/errors.ts +++ b/packages/use-local-agent/src/errors.ts @@ -223,7 +223,16 @@ export const isMethodNotFoundJsonRpcError = (cause: unknown): boolean => getJsonRpcErrorCode(cause) === ACP_METHOD_NOT_FOUND_ERROR_CODE; const stringifyCause = (cause: unknown): string => { - if (cause instanceof Error) return cause.message; + if (cause instanceof Error) { + if ( + cause.cause instanceof Error && + cause.cause.message && + cause.cause.message !== cause.message + ) { + return `${cause.message} (caused by: ${cause.cause.message})`; + } + return cause.message; + } if (typeof cause === "string") return cause; try { return JSON.stringify(cause); diff --git a/packages/use-local-agent/src/index.ts b/packages/use-local-agent/src/index.ts index 9f50e91..b8203aa 100644 --- a/packages/use-local-agent/src/index.ts +++ b/packages/use-local-agent/src/index.ts @@ -3,6 +3,7 @@ export { type LocalAgentClientInfo, type LocalAgentConnectOptions, type FileSystemHandlers, + type TerminalHandlers, } from "./local-agent"; export { streamAgent, @@ -52,6 +53,7 @@ export type { SessionModeState, SessionNotification, SessionUpdate, + SlashCommandInput, StopReason, ToolCallContent, ToolCallLocation, diff --git a/packages/use-local-agent/src/local-agent.ts b/packages/use-local-agent/src/local-agent.ts index 9897074..bcaa385 100644 --- a/packages/use-local-agent/src/local-agent.ts +++ b/packages/use-local-agent/src/local-agent.ts @@ -19,6 +19,7 @@ import { AUTH_FAILURE_PATTERNS, DEFAULT_INACTIVITY_TIMEOUT_MS, DEFAULT_INITIALIZE_TIMEOUT_MS, + MAX_BUFFERED_UPDATES_PER_SESSION, PACKAGE_NAME, PACKAGE_TITLE, PACKAGE_VERSION, @@ -53,6 +54,7 @@ import type { PendingPermission, PromptInput, SessionId, + SlashCommandInput, TurnResult, UsageReport, } from "./types"; @@ -67,6 +69,21 @@ export interface ClientDispatcher { onPermissionRequest: (request: RequestPermissionRequest) => Promise; onReadTextFile?: (params: schema.ReadTextFileRequest) => Promise; onWriteTextFile?: (params: schema.WriteTextFileRequest) => Promise; + onCreateTerminal?: ( + params: schema.CreateTerminalRequest, + ) => Promise; + onTerminalOutput?: ( + params: schema.TerminalOutputRequest, + ) => Promise; + onReleaseTerminal?: ( + params: schema.ReleaseTerminalRequest, + ) => Promise; + onWaitForTerminalExit?: ( + params: schema.WaitForTerminalExitRequest, + ) => Promise; + onKillTerminal?: ( + params: schema.KillTerminalRequest, + ) => Promise; } export interface LocalAgentClientInfo { @@ -80,6 +97,18 @@ export interface FileSystemHandlers { writeTextFile?(params: schema.WriteTextFileRequest): Promise; } +export interface TerminalHandlers { + createTerminal(params: schema.CreateTerminalRequest): Promise; + terminalOutput(params: schema.TerminalOutputRequest): Promise; + releaseTerminal( + params: schema.ReleaseTerminalRequest, + ): Promise; + waitForTerminalExit( + params: schema.WaitForTerminalExitRequest, + ): Promise; + killTerminal(params: schema.KillTerminalRequest): Promise; +} + export interface LocalAgentConnectOptions { readonly cwd?: string; readonly env?: Readonly>; @@ -90,9 +119,19 @@ export interface LocalAgentConnectOptions { readonly systemPrompt?: string; readonly clientInfo?: LocalAgentClientInfo; readonly fileSystem?: FileSystemHandlers; + readonly terminal?: TerminalHandlers; + readonly traceContext?: () => Record; + readonly onAuthRequired?: (methods: readonly schema.AuthMethod[]) => Promise; readonly clock?: Clock; readonly onStderr?: (line: string) => void; readonly stderrTailLimit?: number; + readonly disposeGraceMs?: number; + readonly onTrace?: (direction: "out" | "in" | "stderr", payload: unknown) => void; + readonly envFilter?: (env: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + readonly stderrFatalPatterns?: { + readonly auth?: ReadonlyArray; + readonly usage?: ReadonlyArray; + }; } interface SessionState { @@ -103,6 +142,7 @@ interface SessionState { readonly systemPrompt?: string; pendingConfigOptions?: readonly SessionConfigOption[]; modeState?: SessionModeState; + availableCommands?: readonly schema.AvailableCommand[]; activeStream: ActiveStreamState | undefined; pendingPermissions: Set; inFlightToolCalls: Map; @@ -137,10 +177,16 @@ const matchesAny = (line: string, patterns: ReadonlyArray): boolean => { }; const toContentBlocks = (prompt: PromptInput["prompt"]): ContentBlock[] => { + if (prompt === undefined) return []; if (typeof prompt === "string") return [{ type: "text", text: prompt }]; return [...prompt]; }; +const formatSlashCommand = (command: SlashCommandInput): string => + command.input && command.input.length > 0 + ? `/${command.name} ${command.input}` + : `/${command.name}`; + const PERMISSION_MODE_PATTERN = /invalid permissions\.defaultmode/i; export class LocalAgent { @@ -160,6 +206,8 @@ export class LocalAgent { #defaultMcpServers: readonly McpServer[]; #defaultSystemPrompt?: string; #inactivityTimeoutMs: number; + #traceContext?: () => Record; + #onAuthRequired?: (methods: readonly schema.AuthMethod[]) => Promise; #clock: Clock; #sessions = new Map(); #pendingUpdates = new Map(); @@ -188,6 +236,8 @@ export class LocalAgent { this.#defaultMcpServers = options.mcpServers ?? []; this.#defaultSystemPrompt = options.systemPrompt; this.#inactivityTimeoutMs = options.inactivityTimeoutMs ?? DEFAULT_INACTIVITY_TIMEOUT_MS; + if (options.traceContext) this.#traceContext = options.traceContext; + if (options.onAuthRequired) this.#onAuthRequired = options.onAuthRequired; this.#clock = options.clock ?? realClock; const policy: PermissionPolicy = options.permission ?? "auto-allow"; this.#permissionStream = policy === "stream"; @@ -208,19 +258,29 @@ export class LocalAgent { const { dispatcher, clientCapabilities, clientInfo, onFatalError, fatalErrorListeners } = LocalAgent.#buildDispatcher(options); + const stderrFatalEnabled = { value: false }; + + const authPatterns: ReadonlyArray = + options.stderrFatalPatterns?.auth ?? AUTH_FAILURE_PATTERNS; + const usagePatterns: ReadonlyArray = + options.stderrFatalPatterns?.usage ?? USAGE_LIMIT_PATTERNS; const connectionResult = await connect(adapter, { stderrTailLimit: options.stderrTailLimit, extraEnv: options.env, + ...(options.disposeGraceMs !== undefined ? { disposeGraceMs: options.disposeGraceMs } : {}), + ...(options.onTrace ? { onTrace: options.onTrace } : {}), + ...(options.envFilter ? { envFilter: options.envFilter } : {}), onStderr: (line) => { options.onStderr?.(line); - if (matchesAny(line, AUTH_FAILURE_PATTERNS)) { + if (!stderrFatalEnabled.value) return; + if (matchesAny(line, authPatterns)) { onFatalError( new AgentUnauthenticatedError( adapter.id, `${adapter.displayName} reported authentication failure: ${line}`, ), ); - } else if (matchesAny(line, USAGE_LIMIT_PATTERNS)) { + } else if (matchesAny(line, usagePatterns)) { onFatalError(new AgentUsageLimitError(adapter.id, line)); } }, @@ -229,16 +289,25 @@ export class LocalAgent { requestPermission: async (request) => dispatcher.onPermissionRequest(request), ...(dispatcher.onReadTextFile ? { readTextFile: dispatcher.onReadTextFile } : {}), ...(dispatcher.onWriteTextFile ? { writeTextFile: dispatcher.onWriteTextFile } : {}), + ...(dispatcher.onCreateTerminal ? { createTerminal: dispatcher.onCreateTerminal } : {}), + ...(dispatcher.onTerminalOutput ? { terminalOutput: dispatcher.onTerminalOutput } : {}), + ...(dispatcher.onReleaseTerminal ? { releaseTerminal: dispatcher.onReleaseTerminal } : {}), + ...(dispatcher.onWaitForTerminalExit + ? { waitForTerminalExit: dispatcher.onWaitForTerminalExit } + : {}), + ...(dispatcher.onKillTerminal ? { killTerminal: dispatcher.onKillTerminal } : {}), }, }); - return LocalAgent.fromConnectResult(connectionResult, { + const localAgent = await LocalAgent.fromConnectResult(connectionResult, { options, dispatcher, clientCapabilities, clientInfo, fatalErrorListeners, }); + stderrFatalEnabled.value = true; + return localAgent; } static buildDispatcher(options: LocalAgentConnectOptions): { @@ -267,19 +336,38 @@ export class LocalAgent { const initializeTimeoutMs = init.options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS; let initResponse: InitializeResponse; + let processExited: { exitCode: number | null; signal: NodeJS.Signals | null } | undefined; + void connectionResult.closed.then((exit) => { + processExited = exit; + }); try { + const initPromise = connectionResult.connection.initialize({ + protocolVersion: ACP_PROTOCOL_VERSION, + clientCapabilities: init.clientCapabilities, + clientInfo: init.clientInfo, + }); initResponse = await raceTimeout( - connectionResult.connection.initialize({ - protocolVersion: ACP_PROTOCOL_VERSION, - clientCapabilities: init.clientCapabilities, - clientInfo: init.clientInfo, - }), + initPromise, initializeTimeoutMs, () => new AgentInitTimeoutError(adapterId, initializeTimeoutMs), ); } catch (cause) { + if (processExited === undefined) { + await Promise.race([ + connectionResult.closed, + new Promise((resolveSleep) => setTimeout(resolveSleep, 50)), + ]); + } await connectionResult.dispose(); if (cause instanceof AgentInitTimeoutError) throw cause; + if (processExited !== undefined) { + throw new AgentConnectionClosedError( + adapterId, + processExited.exitCode, + processExited.signal, + connectionResult.stderrTail(), + ); + } throw new AgentInitError(adapterId, cause); } @@ -321,6 +409,7 @@ export class LocalAgent { fatalErrorListeners: Set<(error: AgentUnauthenticatedError | AgentUsageLimitError) => void>; } { const fs = options.fileSystem; + const term = options.terminal; const clientCapabilities: schema.ClientCapabilities = { ...(fs?.readTextFile || fs?.writeTextFile ? { @@ -330,6 +419,7 @@ export class LocalAgent { }, } : {}), + ...(term ? { terminal: true } : {}), }; const clientInfo: LocalAgentClientInfo = options.clientInfo ?? { name: PACKAGE_NAME, @@ -342,6 +432,11 @@ export class LocalAgent { onPermissionRequest: async () => ({ outcome: { outcome: "cancelled" } }), onReadTextFile: fs?.readTextFile?.bind(fs), onWriteTextFile: fs?.writeTextFile?.bind(fs), + onCreateTerminal: term?.createTerminal?.bind(term), + onTerminalOutput: term?.terminalOutput?.bind(term), + onReleaseTerminal: term?.releaseTerminal?.bind(term), + onWaitForTerminalExit: term?.waitForTerminalExit?.bind(term), + onKillTerminal: term?.killTerminal?.bind(term), }; const fatalErrorListeners = new Set< @@ -375,12 +470,30 @@ export class LocalAgent { const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); const systemPrompt = input.systemPrompt ?? this.#defaultSystemPrompt; const meta = this.#buildSessionMeta(input.meta, systemPrompt); + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); + const newSessionRequest = { + cwd, + mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), + ...(meta ? { _meta: meta } : {}), + }; try { - const response = await this.#connection.connection.newSession({ - cwd, - mcpServers: [...mcpServers], - ...(meta ? { _meta: meta } : {}), - }); + let response; + try { + response = await this.#connection.connection.newSession(newSessionRequest); + } catch (initialCause) { + if (this.#onAuthRequired && isAuthRequiredJsonRpcError(initialCause)) { + const methodId = await this.#onAuthRequired(this.authMethods); + if (methodId !== undefined) { + await this.authenticate(methodId); + response = await this.#connection.connection.newSession(newSessionRequest); + } else { + throw initialCause; + } + } else { + throw initialCause; + } + } const sessionId = response.sessionId as SessionId; const state: SessionState = { id: sessionId, @@ -412,11 +525,13 @@ export class LocalAgent { const cwd = input.cwd ?? this.#defaultCwd; const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); const systemPrompt = input.systemPrompt ?? this.#defaultSystemPrompt; + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); try { await this.#connection.connection.loadSession({ sessionId: input.sessionId, cwd, mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), }); const state: SessionState = { id: input.sessionId, @@ -435,12 +550,99 @@ export class LocalAgent { } } - async listSessions(): Promise { + loadSessionStreaming(input: LoadSessionInput): { + readonly sessionId: SessionId; + readonly replay: AsyncIterable; + readonly completion: Promise; + } { + this.#assertOpen(); + if (!this.agentCapabilities.loadSession) { + throw new CapabilityNotSupportedError(this.id, "loadSession"); + } + const cwd = input.cwd ?? this.#defaultCwd; + const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); + const systemPrompt = input.systemPrompt ?? this.#defaultSystemPrompt; + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); + + const events = new AsyncQueue(); + const activeStream: ActiveStreamState = { + events, + textBuffer: "", + thinkingBuffer: "", + lastActivityAt: this.#clock.now(), + cancelled: false, + }; + const state: SessionState = { + id: input.sessionId, + cwd, + mcpServers, + meta: input.meta, + systemPrompt, + activeStream, + pendingPermissions: new Set(), + inFlightToolCalls: new Map(), + }; + this.#sessions.set(input.sessionId, state); + + const buffered = this.#pendingUpdates.get(input.sessionId); + if (buffered) { + this.#pendingUpdates.delete(input.sessionId); + for (const notification of buffered) { + this.#emitFromUpdate(state, activeStream, notification.update); + } + } + + const completion = (async (): Promise => { + try { + await this.#connection.connection.loadSession({ + sessionId: input.sessionId, + cwd, + mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), + }); + events.end(); + } catch (cause) { + const mapped = this.#mapSessionError(cause, "load"); + events.fail(mapped); + this.#sessions.delete(input.sessionId); + throw mapped; + } finally { + state.activeStream = undefined; + } + })(); + + return { + sessionId: input.sessionId, + replay: { [Symbol.asyncIterator]: () => events[Symbol.asyncIterator]() }, + completion, + }; + } + + async listSessions( + input: { readonly cwd?: string; readonly cursor?: string } = {}, + ): Promise { this.#assertOpen(); if (!this.agentCapabilities.sessionCapabilities?.list) { throw new CapabilityNotSupportedError(this.id, "sessionCapabilities.list"); } - return this.#connection.connection.listSessions({}); + return this.#connection.connection.listSessions({ + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(input.cursor ? { cursor: input.cursor } : {}), + }); + } + + async *streamAllSessions( + input: { readonly cwd?: string } = {}, + ): AsyncIterable { + let cursor: string | undefined; + do { + const response = await this.listSessions({ + ...(input.cwd ? { cwd: input.cwd } : {}), + ...(cursor ? { cursor } : {}), + }); + for (const session of response.sessions) yield session; + cursor = response.nextCursor ?? undefined; + } while (cursor); } async resumeSession(input: LoadSessionInput): Promise { @@ -450,11 +652,13 @@ export class LocalAgent { } const cwd = input.cwd ?? this.#defaultCwd; const mcpServers = this.#validateMcpServers(input.mcpServers ?? this.#defaultMcpServers); + const additionalDirectories = this.#validateAdditionalDirectories(input.additionalDirectories); try { - await this.#connection.connection.unstable_resumeSession({ + await this.#connection.connection.resumeSession({ sessionId: input.sessionId, cwd, mcpServers: [...mcpServers], + ...(additionalDirectories ? { additionalDirectories: [...additionalDirectories] } : {}), }); const state: SessionState = { id: input.sessionId, @@ -481,13 +685,25 @@ export class LocalAgent { const state = this.#sessions.get(sessionId); if (state) this.#cleanupSession(state, undefined); try { - await this.#connection.connection.unstable_closeSession({ sessionId }); + await this.#connection.connection.closeSession({ sessionId }); } finally { this.#sessions.delete(sessionId); this.#pendingUpdates.delete(sessionId); } } + commandsFor(sessionId: SessionId): readonly schema.AvailableCommand[] { + return this.#sessions.get(sessionId)?.availableCommands ?? []; + } + + modeStateFor(sessionId: SessionId): SessionModeState | undefined { + return this.#sessions.get(sessionId)?.modeState; + } + + configOptionsFor(sessionId: SessionId): readonly SessionConfigOption[] { + return this.#sessions.get(sessionId)?.pendingConfigOptions ?? []; + } + async setMode(sessionId: SessionId, modeId: string): Promise { this.#assertOpen(); const state = this.#sessions.get(sessionId); @@ -560,7 +776,8 @@ export class LocalAgent { const systemPrompt = input.systemPrompt ?? state.systemPrompt; let promptBlocks: ContentBlock[]; try { - promptBlocks = this.#buildPromptBlocks(input.prompt, systemPrompt); + const baseBlocks = this.#applySlashCommand(input, state); + promptBlocks = this.#buildPromptBlocks(baseBlocks, systemPrompt); this.#validatePromptCapabilities(promptBlocks); } catch (validationError) { events.fail(validationError); @@ -583,10 +800,11 @@ export class LocalAgent { ), ); } + const promptMeta = this.#buildRequestMeta(input.meta); const promptPromise = this.#connection.connection.prompt({ sessionId, prompt: promptBlocks, - ...(input.meta ? { _meta: input.meta } : {}), + ...(promptMeta ? { _meta: promptMeta } : {}), }); const forcePromise = new Promise((_, rejectForce) => { activeStream.forceFail = rejectForce; @@ -768,6 +986,9 @@ export class LocalAgent { respond, cancel, }; + if (state.activeStream) { + state.activeStream.lastActivityAt = this.#clock.now(); + } state.activeStream?.events.push({ type: "permission-request", request: pending }); }); } @@ -776,20 +997,25 @@ export class LocalAgent { const sessionId = notification.sessionId as SessionId; const state = this.#sessions.get(sessionId); if (!state) { - const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; - buffered.push(notification); - this.#pendingUpdates.set(notification.sessionId, buffered); + this.#bufferUpdate(notification); return; } if (!state.activeStream) { - const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; - buffered.push(notification); - this.#pendingUpdates.set(notification.sessionId, buffered); + this.#bufferUpdate(notification); return; } this.#emitFromUpdate(state, state.activeStream, notification.update); } + #bufferUpdate(notification: SessionNotification): void { + const buffered = this.#pendingUpdates.get(notification.sessionId) ?? []; + buffered.push(notification); + while (buffered.length > MAX_BUFFERED_UPDATES_PER_SESSION) { + buffered.shift(); + } + this.#pendingUpdates.set(notification.sessionId, buffered); + } + #emitFromUpdate(state: SessionState, active: ActiveStreamState, update: SessionUpdate): void { if (isMeaningfulActivity(update)) { active.lastActivityAt = this.#clock.now(); @@ -858,10 +1084,17 @@ export class LocalAgent { return; } case "available_commands_update": { + state.availableCommands = update.availableCommands; active.events.push({ type: "available-commands", commands: update.availableCommands }); return; } case "current_mode_update": { + if (state.modeState) { + state.modeState = { + ...state.modeState, + currentModeId: update.currentModeId, + }; + } active.events.push({ type: "mode-changed", modeId: update.currentModeId }); return; } @@ -893,6 +1126,11 @@ export class LocalAgent { if (this.#inactivityTimeoutMs <= 0) return; const tick = (): void => { if (active !== state.activeStream) return; + if (state.pendingPermissions.size > 0) { + active.lastActivityAt = this.#clock.now(); + active.inactivityTimer = this.#clock.setTimeout(tick, this.#inactivityTimeoutMs); + return; + } const elapsed = this.#clock.now() - active.lastActivityAt; if (elapsed >= this.#inactivityTimeoutMs) { this.#cleanupSession(state, new AgentInactivityError(this.id, state.id, elapsed)); @@ -950,16 +1188,37 @@ export class LocalAgent { } #buildPromptBlocks( - prompt: PromptInput["prompt"], + blocks: readonly ContentBlock[], systemPrompt: string | undefined, ): ContentBlock[] { - const blocks = toContentBlocks(prompt); - if (!systemPrompt || this.id === "claude") return blocks; - if (blocks.length > 0 && blocks[0].type === "text") { + const copy = [...blocks]; + if (!systemPrompt || this.id === "claude") return copy; + if (copy.length > 0 && copy[0].type === "text") { + const first = copy[0]; + return [{ type: "text", text: `${systemPrompt}\n\n${first.text}` }, ...copy.slice(1)]; + } + return [{ type: "text", text: systemPrompt }, ...copy]; + } + + #applySlashCommand(input: PromptInput, state: SessionState): readonly ContentBlock[] { + const blocks = toContentBlocks(input.prompt); + if (!input.command) return blocks; + if (state.availableCommands && state.availableCommands.length > 0) { + const known = state.availableCommands.some((command) => command.name === input.command!.name); + if (!known) { + throw new AgentStreamError( + this.id, + `Slash command "${input.command.name}" is not advertised by ${this.displayName}`, + ); + } + } + const commandText = formatSlashCommand(input.command); + if (blocks.length === 0) return [{ type: "text", text: commandText }]; + if (blocks[0].type === "text") { const first = blocks[0]; - return [{ type: "text", text: `${systemPrompt}\n\n${first.text}` }, ...blocks.slice(1)]; + return [{ type: "text", text: `${commandText} ${first.text}` }, ...blocks.slice(1)]; } - return [{ type: "text", text: systemPrompt }, ...blocks]; + return [{ type: "text", text: commandText }, ...blocks]; } #validatePromptCapabilities(blocks: ReadonlyArray): void { @@ -981,6 +1240,21 @@ export class LocalAgent { } } + #validateAdditionalDirectories( + dirs: readonly string[] | undefined, + ): readonly string[] | undefined { + if (!dirs || dirs.length === 0) return undefined; + if (!this.agentCapabilities.sessionCapabilities?.additionalDirectories) { + throw new CapabilityNotSupportedError(this.id, "sessionCapabilities.additionalDirectories"); + } + for (const dir of dirs) { + if (!dir.startsWith("/") && !/^[a-zA-Z]:[\\/]/.test(dir)) { + throw new AgentStreamError(this.id, `additionalDirectories must be absolute paths: ${dir}`); + } + } + return dirs; + } + #validateMcpServers(servers: readonly McpServer[]): readonly McpServer[] { const mcpCaps = this.agentCapabilities.mcpCapabilities ?? {}; for (const server of servers) { @@ -999,12 +1273,37 @@ export class LocalAgent { meta: Record | undefined, systemPrompt: string | undefined, ): Record | undefined { - if (!systemPrompt && !meta) return undefined; - if (!systemPrompt) return meta; - if (this.id === "claude") { - return { ...(meta ?? {}), systemPrompt }; + let result: Record | undefined = meta ? { ...meta } : undefined; + if (systemPrompt && this.id === "claude") { + result = { ...(result ?? {}), systemPrompt }; + } + const trace = this.#collectTraceContext(); + if (trace) { + result = { ...(result ?? {}), ...trace }; + } + return result; + } + + #buildRequestMeta( + meta: Record | undefined, + ): Record | undefined { + const trace = this.#collectTraceContext(); + if (!meta && !trace) return undefined; + return { ...(meta ?? {}), ...(trace ?? {}) }; + } + + #collectTraceContext(): Record | undefined { + if (!this.#traceContext) return undefined; + try { + const ctx = this.#traceContext(); + const entries = Object.entries(ctx).filter(([key]) => + ["traceparent", "tracestate", "baggage"].includes(key), + ); + if (entries.length === 0) return undefined; + return Object.fromEntries(entries); + } catch { + return undefined; } - return meta; } } diff --git a/packages/use-local-agent/src/testing/mock-agent.ts b/packages/use-local-agent/src/testing/mock-agent.ts index 60bc630..374ad47 100644 --- a/packages/use-local-agent/src/testing/mock-agent.ts +++ b/packages/use-local-agent/src/testing/mock-agent.ts @@ -47,10 +47,10 @@ export interface MockAgentHandlers { request: LoadSessionRequest, conn: AgentSideConnection, ) => LoadSessionResponse | Promise; - unstable_resumeSession?: ( + resumeSession?: ( request: ResumeSessionRequest, ) => ResumeSessionResponse | Promise; - unstable_closeSession?: ( + closeSession?: ( request: CloseSessionRequest, ) => CloseSessionResponse | Promise; unstable_forkSession?: ( @@ -128,16 +128,16 @@ class MockAcpAgent implements Agent { return {}; } - async unstable_resumeSession(request: ResumeSessionRequest): Promise { - if (this.handlers.unstable_resumeSession) { - return this.handlers.unstable_resumeSession(request); + async resumeSession(request: ResumeSessionRequest): Promise { + if (this.handlers.resumeSession) { + return this.handlers.resumeSession(request); } return {}; } - async unstable_closeSession(request: CloseSessionRequest): Promise { - if (this.handlers.unstable_closeSession) { - return this.handlers.unstable_closeSession(request); + async closeSession(request: CloseSessionRequest): Promise { + if (this.handlers.closeSession) { + return this.handlers.closeSession(request); } return {}; } @@ -227,6 +227,13 @@ export const connectMockAgent = async ( requestPermission: async (request) => dispatcher.onPermissionRequest(request), ...(dispatcher.onReadTextFile ? { readTextFile: dispatcher.onReadTextFile } : {}), ...(dispatcher.onWriteTextFile ? { writeTextFile: dispatcher.onWriteTextFile } : {}), + ...(dispatcher.onCreateTerminal ? { createTerminal: dispatcher.onCreateTerminal } : {}), + ...(dispatcher.onTerminalOutput ? { terminalOutput: dispatcher.onTerminalOutput } : {}), + ...(dispatcher.onReleaseTerminal ? { releaseTerminal: dispatcher.onReleaseTerminal } : {}), + ...(dispatcher.onWaitForTerminalExit + ? { waitForTerminalExit: dispatcher.onWaitForTerminalExit } + : {}), + ...(dispatcher.onKillTerminal ? { killTerminal: dispatcher.onKillTerminal } : {}), }), ndJsonStream(clientToAgent.writable, agentToClient.readable), ); @@ -266,3 +273,50 @@ export const connectMockAgent = async ( }, }; }; + +/** + * Returns an `AgentAdapter` whose `resolve()` always points at a binary that + * does not exist, so spawning it raises ENOENT. Useful for asserting that + * `LocalAgent.connect` surfaces `AgentSpawnError` cleanly without leaking + * unhandled rejections. + */ +export const withSpawnFailure = ( + id: string = "missing", +): { + readonly id: string; + readonly displayName: string; + resolve(): Promise<{ + readonly bin: string; + readonly args: readonly string[]; + readonly env: Readonly>; + }>; +} => ({ + id, + displayName: `Missing Adapter (${id})`, + resolve: async () => ({ + bin: "/this/path/does/not/exist/intentionally-missing-binary-zzz", + args: [], + env: {}, + }), +}); + +/** + * Drives a mock agent prompt handler that emits a session/update notification + * and returns a response in the same microtask, exercising the SDK's + * notification/response ordering guarantee (PR #130). Returns the prompt + * handler that callers can pass to `connectMockAgent`. + */ +export const withSdkOutOfOrderRace = (chunks: readonly string[]): MockAgentHandlers["prompt"] => { + return async (request: PromptRequest, conn: AgentSideConnection): Promise => { + for (const chunk of chunks) { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: chunk }, + }, + }); + } + return { stopReason: "end_turn" }; + }; +}; diff --git a/packages/use-local-agent/src/types.ts b/packages/use-local-agent/src/types.ts index d66ddff..8c832e6 100644 --- a/packages/use-local-agent/src/types.ts +++ b/packages/use-local-agent/src/types.ts @@ -97,8 +97,14 @@ export interface ModelPreference { readonly value: string; } +export interface SlashCommandInput { + readonly name: string; + readonly input?: string; +} + export interface PromptInput { - readonly prompt: string | readonly ContentBlock[]; + readonly prompt?: string | readonly ContentBlock[]; + readonly command?: SlashCommandInput; readonly meta?: Record; readonly modelPreference?: ModelPreference; readonly systemPrompt?: string; @@ -108,6 +114,7 @@ export interface PromptInput { export interface CreateSessionInput { readonly cwd?: string; readonly mcpServers?: readonly McpServer[]; + readonly additionalDirectories?: readonly string[]; readonly meta?: Record; readonly systemPrompt?: string; readonly signal?: AbortSignal; diff --git a/packages/use-local-agent/src/utils/run-command.ts b/packages/use-local-agent/src/utils/run-command.ts index 818f8fa..fb81e1c 100644 --- a/packages/use-local-agent/src/utils/run-command.ts +++ b/packages/use-local-agent/src/utils/run-command.ts @@ -73,6 +73,11 @@ export const runCommand = ( if (options.timeoutMs && options.timeoutMs > 0) { timer = setTimeout(() => { child.kill("SIGTERM"); + const escalateAfterMs = Math.min(2000, Math.max(50, Math.floor(options.timeoutMs! / 2))); + const killTimer = setTimeout(() => { + if (!child.killed) child.kill("SIGKILL"); + }, escalateAfterMs); + killTimer.unref?.(); reject( new CommandError(`Command ${bin} timed out after ${options.timeoutMs}ms`, { stdout: stdoutBuffer, diff --git a/packages/use-local-agent/tests/adapter-env-fallbacks.test.ts b/packages/use-local-agent/tests/adapter-env-fallbacks.test.ts new file mode 100644 index 0000000..da71179 --- /dev/null +++ b/packages/use-local-agent/tests/adapter-env-fallbacks.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it } from "vite-plus/test"; +import { gemini } from "../src/adapters/gemini"; + +describe("adapter env-var fallbacks", () => { + const originalGemini = process.env.GEMINI_API_KEY; + const originalGoogle = process.env.GOOGLE_API_KEY; + + afterEach(() => { + process.env.GEMINI_API_KEY = originalGemini; + process.env.GOOGLE_API_KEY = originalGoogle; + }); + + it("gemini.checkAuthenticated returns true when GEMINI_API_KEY is set", async () => { + process.env.GEMINI_API_KEY = "fake-key"; + delete process.env.GOOGLE_API_KEY; + const adapter = gemini(); + expect(await adapter.checkAuthenticated?.()).toBe(true); + }); + + it("gemini.checkAuthenticated returns true when GOOGLE_API_KEY is set", async () => { + delete process.env.GEMINI_API_KEY; + process.env.GOOGLE_API_KEY = "fake-key"; + const adapter = gemini(); + expect(await adapter.checkAuthenticated?.()).toBe(true); + }); +}); diff --git a/packages/use-local-agent/tests/additional-directories.test.ts b/packages/use-local-agent/tests/additional-directories.test.ts new file mode 100644 index 0000000..6c3595c --- /dev/null +++ b/packages/use-local-agent/tests/additional-directories.test.ts @@ -0,0 +1,62 @@ +import type { NewSessionRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { CapabilityNotSupportedError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; + +describe("additionalDirectories", () => { + it("forwards additionalDirectories when capability is advertised", async () => { + let captured: NewSessionRequest | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: { additionalDirectories: {} }, + }, + }), + newSession: (request) => { + captured = request; + return { sessionId: "s1" }; + }, + }); + + await session.agent.createSession({ + cwd: "/tmp", + additionalDirectories: ["/extra/one", "/extra/two"], + }); + expect(captured?.additionalDirectories).toEqual(["/extra/one", "/extra/two"]); + await session.close(); + }); + + it("rejects additionalDirectories when capability is not advertised", async () => { + const session = await connectMockAgent({ + initialize: () => ({ protocolVersion: 1, agentCapabilities: {} }), + }); + + await expect( + session.agent.createSession({ + cwd: "/tmp", + additionalDirectories: ["/extra"], + }), + ).rejects.toBeInstanceOf(CapabilityNotSupportedError); + await session.close(); + }); + + it("rejects relative additional directory paths", async () => { + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { + sessionCapabilities: { additionalDirectories: {} }, + }, + }), + }); + + await expect( + session.agent.createSession({ + cwd: "/tmp", + additionalDirectories: ["./relative"], + }), + ).rejects.toThrow(/absolute paths/); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/auth-retry.test.ts b/packages/use-local-agent/tests/auth-retry.test.ts new file mode 100644 index 0000000..f881a4d --- /dev/null +++ b/packages/use-local-agent/tests/auth-retry.test.ts @@ -0,0 +1,62 @@ +import { RequestError } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { AgentUnauthenticatedError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; + +describe("onAuthRequired hook", () => { + it("retries newSession after authenticate when hook returns a method id", async () => { + let attempts = 0; + let authenticated: string | undefined; + const session = await connectMockAgent( + { + initialize: () => ({ + protocolVersion: 1, + authMethods: [{ id: "oauth", name: "OAuth" }], + }), + authenticate: (request) => { + authenticated = request.methodId; + return {}; + }, + newSession: () => { + attempts += 1; + if (attempts === 1) throw RequestError.authRequired(); + return { sessionId: "s1" }; + }, + }, + { + onAuthRequired: async (methods) => { + expect(methods.length).toBe(1); + return methods[0]!.id; + }, + }, + ); + + const sessionId = await session.agent.createSession({ cwd: "/tmp" }); + expect(sessionId).toBe("s1"); + expect(authenticated).toBe("oauth"); + expect(attempts).toBe(2); + await session.close(); + }); + + it("does not retry when hook returns undefined", async () => { + const session = await connectMockAgent( + { + initialize: () => ({ + protocolVersion: 1, + authMethods: [{ id: "oauth", name: "OAuth" }], + }), + newSession: () => { + throw RequestError.authRequired(); + }, + }, + { + onAuthRequired: async () => undefined, + }, + ); + + await expect(session.agent.createSession({ cwd: "/tmp" })).rejects.toBeInstanceOf( + AgentUnauthenticatedError, + ); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/buffer-cap.test.ts b/packages/use-local-agent/tests/buffer-cap.test.ts new file mode 100644 index 0000000..c6a1fad --- /dev/null +++ b/packages/use-local-agent/tests/buffer-cap.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vite-plus/test"; +import { MAX_BUFFERED_UPDATES_PER_SESSION } from "../src/constants"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("per-session buffered update cap", () => { + it("drops oldest buffered updates beyond MAX_BUFFERED_UPDATES_PER_SESSION", async () => { + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: () => ({ stopReason: "end_turn" }), + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + + const overflow = MAX_BUFFERED_UPDATES_PER_SESSION + 50; + for (let index = 0; index < overflow; index += 1) { + await session.mockConnection.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: `chunk-${index}` }, + }, + }); + } + await new Promise((resolveSleep) => setTimeout(resolveSleep, 30)); + + const stream = session.agent.prompt(sessionId, { prompt: "go" }); + const events: AgentEvent[] = []; + for await (const event of stream) events.push(event); + + const textDeltas = events.filter((event) => event.type === "text-delta"); + expect(textDeltas.length).toBe(MAX_BUFFERED_UPDATES_PER_SESSION); + if (textDeltas[0]?.type === "text-delta") { + expect(textDeltas[0].text).toBe(`chunk-${overflow - MAX_BUFFERED_UPDATES_PER_SESSION}`); + } + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/late-updates.test.ts b/packages/use-local-agent/tests/late-updates.test.ts new file mode 100644 index 0000000..e263a90 --- /dev/null +++ b/packages/use-local-agent/tests/late-updates.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("late updates after cancel", () => { + it("forwards tool_call_update notifications received after session/cancel", async () => { + let cancelTriggered = false; + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "tool_call", + toolCallId: "t1", + title: "running", + kind: "execute", + status: "in_progress", + }, + }); + // Wait until cancel arrives + for (let attempts = 0; attempts < 50; attempts += 1) { + if (cancelTriggered) break; + await new Promise((resolveSleep) => setTimeout(resolveSleep, 10)); + } + // Send a final update AFTER cancel + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: "t1", + status: "failed", + }, + }); + return { stopReason: "cancelled" }; + }, + cancel: () => { + cancelTriggered = true; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "go" }); + + const events: AgentEvent[] = []; + const iter = stream[Symbol.asyncIterator](); + const first = await iter.next(); + if (first.value) events.push(first.value); + await stream.cancel(); + while (true) { + try { + const next = await iter.next(); + if (next.done) break; + events.push(next.value); + } catch { + break; + } + } + await stream.completion.catch(() => {}); + + expect(events.find((event) => event.type === "tool-call-cancelled")).toBeDefined(); + const lateUpdate = events.find( + (event) => event.type === "tool-call-update" && event.status === "failed", + ); + expect(lateUpdate).toBeDefined(); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/list-sessions-pagination.test.ts b/packages/use-local-agent/tests/list-sessions-pagination.test.ts new file mode 100644 index 0000000..a822b90 --- /dev/null +++ b/packages/use-local-agent/tests/list-sessions-pagination.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionInfo } from "@agentclientprotocol/sdk"; + +describe("listSessions cursor pagination", () => { + it("paginates through pages via streamAllSessions", async () => { + const pages = [ + { + sessions: [ + { sessionId: "s1", cwd: "/p" }, + { sessionId: "s2", cwd: "/p" }, + ] as SessionInfo[], + nextCursor: "c1", + }, + { + sessions: [{ sessionId: "s3", cwd: "/p" }] as SessionInfo[], + }, + ]; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { list: {} } }, + }), + listSessions: (request) => { + if (!request.cursor) return pages[0]; + if (request.cursor === "c1") return pages[1]; + throw new Error(`unknown cursor ${request.cursor}`); + }, + }); + + const all: string[] = []; + for await (const info of session.agent.streamAllSessions()) { + all.push(info.sessionId); + } + expect(all).toEqual(["s1", "s2", "s3"]); + await session.close(); + }); + + it("forwards cwd filter to the agent", async () => { + let receivedCwd: string | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { list: {} } }, + }), + listSessions: (request) => { + receivedCwd = request.cwd ?? undefined; + return { sessions: [] }; + }, + }); + await session.agent.listSessions({ cwd: "/scoped" }); + expect(receivedCwd).toBe("/scoped"); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/load-session-replay.test.ts b/packages/use-local-agent/tests/load-session-replay.test.ts new file mode 100644 index 0000000..ad1fdb1 --- /dev/null +++ b/packages/use-local-agent/tests/load-session-replay.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("loadSessionStreaming", () => { + it("streams replay updates emitted during loadSession back to the caller", async () => { + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { loadSession: true }, + }), + loadSession: async (request, conn) => { + for (let i = 0; i < 5; i += 1) { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: `replay-${i}` }, + }, + }); + } + return {}; + }, + }); + + const handle = session.agent.loadSessionStreaming({ + sessionId: "sess-old" as SessionId, + cwd: "/tmp", + }); + expect(handle.sessionId).toBe("sess-old"); + + const events: AgentEvent[] = []; + const collector = (async () => { + for await (const event of handle.replay) events.push(event); + })(); + await handle.completion; + await collector; + + const rawUserMessages = events.filter( + (event) => event.type === "raw" && event.update.sessionUpdate === "user_message_chunk", + ); + expect(rawUserMessages.length).toBe(5); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/resume-close.test.ts b/packages/use-local-agent/tests/resume-close.test.ts new file mode 100644 index 0000000..dc28530 --- /dev/null +++ b/packages/use-local-agent/tests/resume-close.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; +import { CapabilityNotSupportedError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("resumeSession / closeSession (SDK 0.20 stabilized)", () => { + it("resumeSession succeeds when sessionCapabilities.resume is advertised", async () => { + let resumed: string | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { resume: {} } }, + }), + resumeSession: (request) => { + resumed = request.sessionId; + return {}; + }, + }); + + const result = await session.agent.resumeSession({ + sessionId: "abc" as SessionId, + cwd: "/tmp", + }); + expect(result).toBe("abc"); + expect(resumed).toBe("abc"); + await session.close(); + }); + + it("resumeSession throws CapabilityNotSupportedError when not advertised", async () => { + const session = await connectMockAgent({ + initialize: () => ({ protocolVersion: 1, agentCapabilities: {} }), + }); + + await expect( + session.agent.resumeSession({ sessionId: "abc" as SessionId, cwd: "/tmp" }), + ).rejects.toBeInstanceOf(CapabilityNotSupportedError); + await session.close(); + }); + + it("closeSession succeeds when sessionCapabilities.close is advertised", async () => { + let closed: string | undefined; + const session = await connectMockAgent({ + initialize: () => ({ + protocolVersion: 1, + agentCapabilities: { sessionCapabilities: { close: {} } }, + }), + newSession: () => ({ sessionId: "s1" }), + closeSession: (request) => { + closed = request.sessionId; + return {}; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.closeSession(sessionId); + expect(closed).toBe(sessionId); + await session.close(); + }); + + it("closeSession throws CapabilityNotSupportedError when not advertised", async () => { + const session = await connectMockAgent({ + initialize: () => ({ protocolVersion: 1, agentCapabilities: {} }), + newSession: () => ({ sessionId: "s1" }), + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await expect(session.agent.closeSession(sessionId)).rejects.toBeInstanceOf( + CapabilityNotSupportedError, + ); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/sdk-upgrade.test.ts b/packages/use-local-agent/tests/sdk-upgrade.test.ts new file mode 100644 index 0000000..4452cb9 --- /dev/null +++ b/packages/use-local-agent/tests/sdk-upgrade.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId } from "../src/types"; + +describe("SDK 0.20 reliability fixes proxy through use-local-agent", () => { + it("response and notification arriving together preserves logical ordering (PR #130)", async () => { + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + // Emit chunks then return — simulates the SDK 0.19.2 race fix + // where notifications enqueued during a response handler must + // run AFTER the response handler completes. + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "a" }, + }, + }); + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "b" }, + }, + }); + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "hi" }); + const events: AgentEvent[] = []; + for await (const event of stream) events.push(event); + const result = await stream.completion; + + expect(result.text).toBe("ab"); + const types = events.map((event) => event.type); + expect(types[0]).toBe("text-delta"); + expect(types[1]).toBe("text-delta"); + expect(types.at(-1)).toBe("finish"); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/slash-commands.test.ts b/packages/use-local-agent/tests/slash-commands.test.ts new file mode 100644 index 0000000..61345c5 --- /dev/null +++ b/packages/use-local-agent/tests/slash-commands.test.ts @@ -0,0 +1,73 @@ +import type { PromptRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { AgentStreamError } from "../src/errors"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("slash command helper", () => { + it("formats /command name with input as a text block", async () => { + let captured: PromptRequest | undefined; + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "web", description: "Search" }], + }, + }); + captured = request; + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "warmup" }).completion; + + let captured2: PromptRequest | undefined; + const session2Prompt = async (): Promise => { + const handlers = (session.agent as unknown as { id: string }).id; + void handlers; + }; + await session2Prompt(); + + const commands = session.agent.commandsFor(sessionId); + expect(commands.length).toBe(1); + + const stream = session.agent.prompt(sessionId, { + command: { name: "web", input: "agent client protocol" }, + }); + await stream.completion; + + captured2 = captured; + expect(captured2?.prompt[0]?.type).toBe("text"); + if (captured2?.prompt[0]?.type === "text") { + expect(captured2.prompt[0].text).toBe("/web agent client protocol"); + } + await session.close(); + }); + + it("rejects unknown slash commands when commands are advertised", async () => { + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + await conn.sessionUpdate({ + sessionId: request.sessionId, + update: { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "web", description: "Search" }], + }, + }); + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "warmup" }).completion; + + const stream = session.agent.prompt(sessionId, { command: { name: "nope" } }); + await expect(stream.completion).rejects.toBeInstanceOf(AgentStreamError); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/spawn-failures.test.ts b/packages/use-local-agent/tests/spawn-failures.test.ts new file mode 100644 index 0000000..c78aff7 --- /dev/null +++ b/packages/use-local-agent/tests/spawn-failures.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { AgentAdapter } from "../src/adapter"; +import { AgentConnectionClosedError, AgentSpawnError, LocalAgentError } from "../src/errors"; +import { LocalAgent } from "../src/local-agent"; +import { withSpawnFailure } from "../src/testing/mock-agent"; + +const missingBinAdapter = (): AgentAdapter => withSpawnFailure(); + +const exitImmediatelyAdapter = (): AgentAdapter => ({ + id: "exit-fast", + displayName: "Exit Fast Adapter", + resolve: async () => ({ + bin: process.execPath, + args: ["-e", "process.exit(7)"], + env: {}, + }), +}); + +describe("spawn / init fast-fail", () => { + it("spawn ENOENT surfaces AgentSpawnError without unhandled rejection", async () => { + const captured: unknown[] = []; + const onUnhandled = (reason: unknown): void => { + captured.push(reason); + }; + process.on("unhandledRejection", onUnhandled); + try { + await expect( + LocalAgent.connect(missingBinAdapter(), { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 30_000, + }), + ).rejects.toBeInstanceOf(AgentSpawnError); + await new Promise((resolveSleep) => setTimeout(resolveSleep, 30)); + expect(captured).toEqual([]); + } finally { + process.off("unhandledRejection", onUnhandled); + } + }); + + it("subprocess exit before initialize raises AgentConnectionClosedError quickly", async () => { + const start = Date.now(); + await expect( + LocalAgent.connect(exitImmediatelyAdapter(), { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 30_000, + }), + ).rejects.toBeInstanceOf(LocalAgentError); + const elapsed = Date.now() - start; + expect(elapsed).toBeLessThan(2_000); + }); + + it("subprocess exit before initialize: error tagged ConnectionClosed", async () => { + let thrown: unknown; + try { + await LocalAgent.connect(exitImmediatelyAdapter(), { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 30_000, + }); + } catch (error) { + thrown = error; + } + expect(thrown).toBeInstanceOf(AgentConnectionClosedError); + }); +}); diff --git a/packages/use-local-agent/tests/stderr-fatal-detection.test.ts b/packages/use-local-agent/tests/stderr-fatal-detection.test.ts new file mode 100644 index 0000000..121b62f --- /dev/null +++ b/packages/use-local-agent/tests/stderr-fatal-detection.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "vite-plus/test"; +import type { AgentAdapter } from "../src/adapter"; +import { AgentUnauthenticatedError } from "../src/errors"; +import { LocalAgent } from "../src/local-agent"; + +const echoStderrAdapter = (script: string): AgentAdapter => ({ + id: "stderr-fixture", + displayName: "Stderr Fixture", + resolve: async () => ({ + bin: process.execPath, + args: ["-e", script], + env: {}, + }), +}); + +const initRespondingAgent = (preInitStderr: string, postInitStderr: string): string => ` +const { Readable } = require("node:stream"); +let inputBuffer = ""; +process.stdin.on("data", (chunk) => { + inputBuffer += chunk.toString("utf8"); + let newlineIndex = inputBuffer.indexOf("\\n"); + while (newlineIndex !== -1) { + const line = inputBuffer.slice(0, newlineIndex); + inputBuffer = inputBuffer.slice(newlineIndex + 1); + if (line.trim().length === 0) { + newlineIndex = inputBuffer.indexOf("\\n"); + continue; + } + let msg; + try { + msg = JSON.parse(line); + } catch (error) { + newlineIndex = inputBuffer.indexOf("\\n"); + continue; + } + if (msg.method === "initialize") { + process.stdout.write(JSON.stringify({ + jsonrpc: "2.0", + id: msg.id, + result: { + protocolVersion: 1, + agentCapabilities: {}, + }, + }) + "\\n"); + setTimeout(() => process.stderr.write(${JSON.stringify(postInitStderr)} + "\\n"), 30); + } + newlineIndex = inputBuffer.indexOf("\\n"); + } +}); +process.stderr.write(${JSON.stringify(preInitStderr)} + "\\n"); +`; + +describe("stderr fatal detection gating", () => { + it("auth-failure stderr after init triggers AgentUnauthenticatedError on next session", async () => { + const lines: string[] = []; + const fatals: unknown[] = []; + + const agent = await LocalAgent.connect( + echoStderrAdapter(initRespondingAgent("", "Authentication failed")), + { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 5_000, + onStderr: (line) => { + lines.push(line); + }, + }, + ); + + await new Promise((resolveSleep) => setTimeout(resolveSleep, 100)); + try { + await agent.createSession({ cwd: "/tmp" }); + } catch (error) { + fatals.push(error); + } + expect(fatals[0]).toBeInstanceOf(AgentUnauthenticatedError); + expect(lines.some((line) => /authentication failed/i.test(line))).toBe(true); + await agent.close(); + }); + + it("pre-init stderr matches do not crash and surface only via onStderr", async () => { + const lines: string[] = []; + const agent = await LocalAgent.connect( + echoStderrAdapter(initRespondingAgent("Authentication failed", "")), + { + inactivityTimeoutMs: 0, + initializeTimeoutMs: 5_000, + onStderr: (line) => { + lines.push(line); + }, + }, + ); + expect(lines.some((line) => /authentication failed/i.test(line))).toBe(true); + expect(agent.protocolVersion).toBe(1); + await agent.close(); + }); +}); diff --git a/packages/use-local-agent/tests/terminal.test.ts b/packages/use-local-agent/tests/terminal.test.ts new file mode 100644 index 0000000..ffbf045 --- /dev/null +++ b/packages/use-local-agent/tests/terminal.test.ts @@ -0,0 +1,64 @@ +import type { CreateTerminalRequest, InitializeRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("terminal capability", () => { + it("does not advertise terminal capability when handlers are absent", async () => { + let received: InitializeRequest | undefined; + const session = await connectMockAgent({ + initialize: (request) => { + received = request; + return { protocolVersion: 1 }; + }, + }); + expect(received?.clientCapabilities?.terminal).toBeFalsy(); + await session.close(); + }); + + it("advertises terminal capability and forwards createTerminal calls", async () => { + let received: InitializeRequest | undefined; + let captured: CreateTerminalRequest | undefined; + const session = await connectMockAgent( + { + initialize: (request) => { + received = request; + return { + protocolVersion: 1, + agentCapabilities: {}, + }; + }, + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + const result = await conn.createTerminal({ + sessionId: request.sessionId, + command: "echo", + args: ["hello"], + }); + captured = { + sessionId: request.sessionId, + command: "echo", + args: ["hello"], + }; + await result.release(); + return { stopReason: "end_turn" }; + }, + }, + { + terminal: { + createTerminal: async () => ({ terminalId: "term-1" }), + terminalOutput: async () => ({ output: "", truncated: false }), + releaseTerminal: async () => ({}), + waitForTerminalExit: async () => ({ exitCode: 0, signal: null }), + killTerminal: async () => ({}), + }, + }, + ); + + expect(received?.clientCapabilities?.terminal).toBe(true); + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "go" }).completion; + expect(captured?.command).toBe("echo"); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/trace-context.test.ts b/packages/use-local-agent/tests/trace-context.test.ts new file mode 100644 index 0000000..95da730 --- /dev/null +++ b/packages/use-local-agent/tests/trace-context.test.ts @@ -0,0 +1,47 @@ +import type { NewSessionRequest, PromptRequest } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { SessionId } from "../src/types"; + +describe("_meta trace context propagation", () => { + it("injects traceparent / tracestate / baggage from traceContext into prompt _meta", async () => { + let capturedNew: NewSessionRequest | undefined; + let capturedPrompt: PromptRequest | undefined; + const session = await connectMockAgent( + { + newSession: (request) => { + capturedNew = request; + return { sessionId: "s1" }; + }, + prompt: (request) => { + capturedPrompt = request; + return { stopReason: "end_turn" }; + }, + }, + { + traceContext: () => ({ + traceparent: "00-aaaa-bbbb-01", + tracestate: "vendor=key", + baggage: "k=v", + // Should be filtered (non-reserved key): + custom: "ignored", + }), + }, + ); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + await session.agent.prompt(sessionId, { prompt: "hi" }).completion; + + const newMeta = capturedNew?._meta as Record | undefined; + expect(newMeta?.traceparent).toBe("00-aaaa-bbbb-01"); + expect(newMeta?.tracestate).toBe("vendor=key"); + expect(newMeta?.baggage).toBe("k=v"); + expect(newMeta?.custom).toBeUndefined(); + + const promptMeta = capturedPrompt?._meta as Record | undefined; + expect(promptMeta?.traceparent).toBe("00-aaaa-bbbb-01"); + expect(promptMeta?.tracestate).toBe("vendor=key"); + expect(promptMeta?.baggage).toBe("k=v"); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/watchdog-permission.test.ts b/packages/use-local-agent/tests/watchdog-permission.test.ts new file mode 100644 index 0000000..80b428f --- /dev/null +++ b/packages/use-local-agent/tests/watchdog-permission.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { Clock } from "../src/utils/clock"; +import type { AgentEvent, SessionId } from "../src/types"; + +const makeFakeClock = (): { + clock: Clock; + advance: (ms: number) => void; +} => { + let current = 0; + const handles = new Set<{ deadline: number; callback: () => void }>(); + const clock: Clock = { + now: () => current, + setTimeout: (callback, ms) => { + const handle = { deadline: current + ms, callback }; + handles.add(handle); + return { + clear: () => handles.delete(handle), + }; + }, + }; + const advance = (ms: number): void => { + current += ms; + for (const handle of [...handles]) { + if (current >= handle.deadline) { + handles.delete(handle); + handle.callback(); + } + } + }; + return { clock, advance }; +}; + +describe("inactivity watchdog respects pending permissions", () => { + it("does not fire while a permission request is open in stream mode", async () => { + const { clock, advance } = makeFakeClock(); + const session = await connectMockAgent( + { + newSession: () => ({ sessionId: "s1" }), + prompt: async (request, conn) => { + const response = await conn.requestPermission({ + sessionId: request.sessionId, + toolCall: { toolCallId: "t1", title: "edit" }, + options: [{ optionId: "ok", name: "Allow", kind: "allow_once" }], + }); + if (response.outcome.outcome !== "selected") { + return { stopReason: "cancelled" }; + } + return { stopReason: "end_turn" }; + }, + }, + { permission: "stream", inactivityTimeoutMs: 1_000, clock }, + ); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "hi" }); + const iter = stream[Symbol.asyncIterator](); + const first = await iter.next(); + expect((first.value as AgentEvent | undefined)?.type).toBe("permission-request"); + + advance(5_000); + if (first.value && first.value.type === "permission-request") { + first.value.request.respond("ok"); + } + const result = await stream.completion; + expect(result.stopReason).toBe("end_turn"); + await session.close(); + }); +}); diff --git a/packages/use-local-agent/tests/wire-fuzz.test.ts b/packages/use-local-agent/tests/wire-fuzz.test.ts new file mode 100644 index 0000000..cf44d02 --- /dev/null +++ b/packages/use-local-agent/tests/wire-fuzz.test.ts @@ -0,0 +1,143 @@ +import { describe, expect, it } from "vite-plus/test"; +import { connectMockAgent } from "../src/testing/mock-agent"; +import type { AgentEvent, SessionId, SessionUpdate } from "../src/types"; + +const VALID_KINDS = [ + "read", + "edit", + "delete", + "move", + "search", + "execute", + "think", + "fetch", + "other", +] as const; +const VALID_TOOL_STATUSES = ["pending", "in_progress", "completed", "failed"] as const; + +const seededRandom = (seed: number): (() => number) => { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +}; + +const randomString = (rand: () => number, max = 12): string => { + const length = 1 + Math.floor(rand() * max); + const chars = "abcdefghijklmnopqrstuvwxyz0123456789 "; + let result = ""; + for (let index = 0; index < length; index += 1) { + result += chars[Math.floor(rand() * chars.length)]; + } + return result; +}; + +const randomUpdate = (rand: () => number, toolCallId: string): SessionUpdate => { + const kind = Math.floor(rand() * 7); + switch (kind) { + case 0: + return { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: randomString(rand) }, + }; + case 1: + return { + sessionUpdate: "agent_thought_chunk", + content: { type: "text", text: randomString(rand) }, + }; + case 2: + return { + sessionUpdate: "tool_call", + toolCallId, + title: randomString(rand), + kind: VALID_KINDS[Math.floor(rand() * VALID_KINDS.length)]!, + status: "in_progress", + }; + case 3: + return { + sessionUpdate: "tool_call_update", + toolCallId, + status: VALID_TOOL_STATUSES[Math.floor(rand() * VALID_TOOL_STATUSES.length)]!, + }; + case 4: + return { + sessionUpdate: "plan", + entries: [ + { content: randomString(rand), priority: "medium", status: "pending" }, + { content: randomString(rand), priority: "low", status: "pending" }, + ], + }; + case 5: + return { + sessionUpdate: "available_commands_update", + availableCommands: [{ name: randomString(rand, 6), description: randomString(rand) }], + }; + default: + return { + sessionUpdate: "usage_update", + size: Math.floor(rand() * 1_000_000), + used: Math.floor(rand() * 100_000), + }; + } +}; + +describe("wire fuzz", () => { + it("does not crash when fed 200 randomized session updates per turn", async () => { + const rand = seededRandom(0xfeed_face); + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "fuzz-1" }), + prompt: async (request, conn) => { + for (let index = 0; index < 200; index += 1) { + const update = randomUpdate(rand, `tc-${index % 5}`); + await conn.sessionUpdate({ sessionId: request.sessionId, update }); + } + return { stopReason: "end_turn" }; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "fuzz" }); + const events: AgentEvent[] = []; + for await (const event of stream) { + events.push(event); + // Spot-check: every event has a `type` string we know about + expect(typeof event.type).toBe("string"); + } + const result = await stream.completion; + expect(result.stopReason).toBe("end_turn"); + expect(events.length).toBeGreaterThan(0); + // The terminating event must be `finish` + expect(events.at(-1)?.type).toBe("finish"); + await session.close(); + }); + + it("survives interleaved cancel under random updates", async () => { + const rand = seededRandom(0xc0ffee); + let cancelled = false; + const session = await connectMockAgent({ + newSession: () => ({ sessionId: "fuzz-2" }), + prompt: async (request, conn) => { + for (let index = 0; index < 100; index += 1) { + if (cancelled && index > 30) break; + const update = randomUpdate(rand, `tc-${index % 3}`); + await conn.sessionUpdate({ sessionId: request.sessionId, update }); + await new Promise((resolveSleep) => setTimeout(resolveSleep, 0)); + } + return { stopReason: cancelled ? "cancelled" : "end_turn" }; + }, + cancel: () => { + cancelled = true; + }, + }); + + const sessionId = (await session.agent.createSession({ cwd: "/tmp" })) as SessionId; + const stream = session.agent.prompt(sessionId, { prompt: "fuzz-cancel" }); + setTimeout(() => void stream.cancel(), 5); + const collected: AgentEvent[] = []; + for await (const event of stream) collected.push(event); + await stream.completion.catch(() => {}); + await session.close(); + expect(collected.length).toBeGreaterThan(0); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c32cf6..364c9f8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -34,8 +34,8 @@ importers: apps/playground: dependencies: '@agentclientprotocol/sdk': - specifier: ^0.16.1 - version: 0.16.1(zod@4.3.6) + specifier: ^0.20.0 + version: 0.20.0(zod@4.3.6) '@wterm/dom': specifier: ^0.1.9 version: 0.1.9 @@ -68,8 +68,8 @@ importers: specifier: ^0.24.0 version: 0.24.2 '@agentclientprotocol/sdk': - specifier: ^0.16.1 - version: 0.16.1(zod@4.3.6) + specifier: ^0.20.0 + version: 0.20.0(zod@4.3.6) '@zed-industries/codex-acp': specifier: ^0.10.0 version: 0.10.0 @@ -87,13 +87,13 @@ packages: resolution: {integrity: sha512-/qRaecc/Hj0nL1jPIaYwCtVOIOZ6z7YBNf8uG4TGesI9YjGXixgo+xT1iAH4Gz0tyo3Q8+LmIv37oQlzOxI1/w==} hasBin: true - '@agentclientprotocol/sdk@0.16.1': - resolution: {integrity: sha512-1ad+Sc/0sCtZGHthxxvgEUo5Wsbw16I+aF+YwdiLnPwkZG8KAGUEAPK6LM6Pf69lCyJPt1Aomk1d+8oE3C4ZEw==} + '@agentclientprotocol/sdk@0.17.0': + resolution: {integrity: sha512-inBMYAEd9t4E+ULZK2os9kmLG5jbPvMLbPvY71XDDem1YteW/uDwkahg6OwsGR3tvvgVhYbRJ9mJCp2VXqG4xQ==} peerDependencies: zod: ^3.25.0 || ^4.0.0 - '@agentclientprotocol/sdk@0.17.0': - resolution: {integrity: sha512-inBMYAEd9t4E+ULZK2os9kmLG5jbPvMLbPvY71XDDem1YteW/uDwkahg6OwsGR3tvvgVhYbRJ9mJCp2VXqG4xQ==} + '@agentclientprotocol/sdk@0.20.0': + resolution: {integrity: sha512-BxEHyE4MvwyOsdyVPub1vEtyrq8E0JSdjC+ckXWimY1VabFCTXdPyXv2y2Omz1j+iod7Z8oBJDXFCJptM0GBqQ==} peerDependencies: zod: ^3.25.0 || ^4.0.0 @@ -1463,11 +1463,11 @@ snapshots: '@anthropic-ai/claude-agent-sdk': 0.2.84(zod@4.3.6) zod: 4.3.6 - '@agentclientprotocol/sdk@0.16.1(zod@4.3.6)': + '@agentclientprotocol/sdk@0.17.0(zod@4.3.6)': dependencies: zod: 4.3.6 - '@agentclientprotocol/sdk@0.17.0(zod@4.3.6)': + '@agentclientprotocol/sdk@0.20.0(zod@4.3.6)': dependencies: zod: 4.3.6