From 996d488589b8db7a0f9af3dfc7b886d9d47ebae9 Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Sat, 22 Aug 2026 14:59:03 +0400 Subject: [PATCH] feat: align Claude modes and clear-context planning (#1004) ## Summary - translate Claude SDK permission suggestions and tool-specific decisions into ACP v1 presentation and editable option metadata - align Claude session permission modes and clear-context plan handling - split permission effects, modes, normalization, option construction, presentation, and response handling into focused modules - document the permission extension and add unit plus live-audit coverage for native edge cases ## Tests - `npm run check` - `npm run build` - `env -u ANTHROPIC_BASE_URL npm run test:run` (931 passed, 27 skipped) --- README.md | 1 + docs/permission-extension.md | 243 +++++ src/acp-agent.ts | 697 ++++++-------- src/clear-context-coordinator.ts | 163 ++++ src/exit-plan.ts | 217 +++++ src/permissions/effects.ts | 223 +++++ src/permissions/modes.ts | 59 ++ src/permissions/normalization.ts | 130 +++ src/permissions/options.ts | 86 ++ src/permissions/options/filesystem.ts | 186 ++++ src/permissions/options/shared.ts | 105 +++ src/permissions/options/shell.ts | 121 +++ src/permissions/options/tools.ts | 170 ++++ src/permissions/presentation.ts | 112 +++ src/permissions/response.ts | 36 + src/session-config-ids.ts | 4 + src/tests/acp-agent-settings.test.ts | 39 +- src/tests/acp-agent.test.ts | 853 +++++++++++++++--- src/tests/clear-context-coordinator.test.ts | 386 ++++++++ .../live-capability-events-audit.test.ts | 352 ++++++++ .../live-capability-session-audit.test.ts | 417 +++++++++ src/tests/permission-effects.test.ts | 347 +++++++ src/tests/permission-normalization.test.ts | 38 + src/tests/permission-options.test.ts | 593 ++++++++++++ src/tests/permission-presentation.test.ts | 309 +++++++ src/tests/resolve-permission-mode.test.ts | 3 +- src/tests/session-config-options.test.ts | 145 +-- src/tests/tools.test.ts | 70 +- src/tool-result-meta.ts | 22 + src/tools.ts | 13 +- 30 files changed, 5492 insertions(+), 648 deletions(-) create mode 100644 docs/permission-extension.md create mode 100644 src/clear-context-coordinator.ts create mode 100644 src/exit-plan.ts create mode 100644 src/permissions/effects.ts create mode 100644 src/permissions/modes.ts create mode 100644 src/permissions/normalization.ts create mode 100644 src/permissions/options.ts create mode 100644 src/permissions/options/filesystem.ts create mode 100644 src/permissions/options/shared.ts create mode 100644 src/permissions/options/shell.ts create mode 100644 src/permissions/options/tools.ts create mode 100644 src/permissions/presentation.ts create mode 100644 src/permissions/response.ts create mode 100644 src/session-config-ids.ts create mode 100644 src/tests/clear-context-coordinator.test.ts create mode 100644 src/tests/live-capability-events-audit.test.ts create mode 100644 src/tests/live-capability-session-audit.test.ts create mode 100644 src/tests/permission-effects.test.ts create mode 100644 src/tests/permission-normalization.test.ts create mode 100644 src/tests/permission-options.test.ts create mode 100644 src/tests/permission-presentation.test.ts create mode 100644 src/tool-result-meta.ts diff --git a/README.md b/README.md index 3ccc9b51..5545ed2f 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https - Client MCP servers - Session-scoped long-running goals through the provider-neutral [goal extension](docs/goal-extension.md) - Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/session-failure-extension.md) +- Tool permission presentation, editable choices, and durable effects through the [permission extension](docs/permission-extension.md) Learn more about the [Agent Client Protocol](https://agentclientprotocol.com/). diff --git a/docs/permission-extension.md b/docs/permission-extension.md new file mode 100644 index 00000000..dd39fc56 --- /dev/null +++ b/docs/permission-extension.md @@ -0,0 +1,243 @@ +# Permission extension + +This document defines the experimental permission presentation extension implemented by +`claude-agent-acp`. The adapter uses standard ACP permission requests and responses, while `_meta` +adds compact request presentation text. + +The extension does not replace ACP permissions. `RequestPermissionRequest.toolCall`, `options`, +`RequestPermissionResponse.outcome`, and each option's standard `kind` remain authoritative. A client +that does not understand the extension can ignore `_meta` and render the ordinary ACP request. + +## Request lifecycle + +When the Claude Agent SDK invokes `canUseTool`, the adapter: + +1. stops immediately if the tool-call signal is already aborted; +2. ensures the referenced ACP `tool_call` has been emitted; +3. validates and snapshots any SDK `suggestions: PermissionUpdate[]`; +4. builds a tool-specific presentation and ordered option list; +5. sends `session/request_permission` with the tool-call cancellation signal; +6. validates that the selected option was offered in the exact request; +7. maps the selection to a Claude SDK `PermissionResult`. + +The eager `tool_call` announcement always precedes the permission request. If the announcement fails, +its de-duplication marker is removed so the normal streamed tool-use path may retry it. + +Request-level presentation metadata requires no capability negotiation. It is an additive `_meta` +record and may be ignored by clients that do not render it. + +## Request presentation + +The request-level record is placed under `RequestPermissionRequest._meta.permission`: + +```json +{ + "sessionId": "session-1", + "toolCall": { + "toolCallId": "toolu_1", + "name": "Bash", + "status": "pending", + "rawInput": { "command": "npm test" } + }, + "options": [ + { "optionId": "allow-once", "name": "Yes", "kind": "allow_once" }, + { "optionId": "reject", "name": "No", "kind": "reject_once" } + ], + "_meta": { + "permission": { + "version": 1, + "title": "npm test", + "description": "Reason: Needed to verify the change." + } + } +} +``` + +| Field | Required | Type | Meaning | +| ------------- | -------: | ---------------- | ----------------------------------------- | +| `version` | yes | integer `1` | Permission presentation schema version. | +| `title` | yes | non-empty string | The standard tool-call operation title. | +| `description` | no | string | Temporary diagnostic SDK decision reason. | + +The permission title normally duplicates `toolCall.title`: one operation has one heading across the +tool card and approval UI. `ExitPlanMode` is the deliberate exception and uses the action-oriented +permission heading `Ready to code?`. Commands, paths, URLs, and other structured details remain in +`rawInput`, `content`, and `locations`. + +`description` temporarily carries the SDK's non-blank `decisionReason` for diagnostics, prefixed +with `Reason: `. The SDK `description` operation subtitle is not copied there. + +## Tool-call presentation + +The permission request carries the same standard ACP tool information used for normal tool updates: + +- `name`, `kind`, `title`, `content`, and `locations` come from `toolInfoFromToolUse`; +- `status` is `pending`; +- `rawInput` is the original SDK input object; +- `blockedPath` is appended to `locations` when it is valid and not already present; +- subagent calls include `_meta.claudeCode.parentToolUseId` on the tool call; +- Sandbox Network and Computer Use requests synthesize JSON content when the normal renderer has no + content. + +Compact text removes control characters, collapses whitespace where appropriate, and enforces length +limits. Invalid optional presentation text is omitted instead of being truncated into misleading UI. + +Permission options are fixed. When a durable suggestion contains a command prefix, path, host, or +other rule, the adapter includes that value directly in `PermissionOption.name`, for example +`Yes, and don't ask again for npm test commands`. Selecting the option applies the exact snapshotted +Claude SDK `PermissionUpdate`; the client cannot edit it. A selected reject is distinct from +`{ "outcome": "cancelled" }`: cancellation aborts the tool use instead of returning a user rejection. + +## Standard option ids + +| Option id | Meaning | +| ------------------------------ | ----------------------------------------------------------- | +| `allow-once` | Allow this call without changing permission state. | +| `allow-with-updates` | Allow and apply the displayed durable effect. | +| `allow-skill-exact` | Allow the exact Skill invocation in local settings. | +| `allow-skill-prefix` | Allow the parameterized Skill prefix in local settings. | +| `exit-plan-auto` | Exit plan mode and set session mode to `auto`. | +| `exit-plan-bypass` | Exit plan mode and set session mode to `bypassPermissions`. | +| `exit-plan-accept-edits` | Exit plan mode and set session mode to `acceptEdits`. | +| `exit-plan-default` | Exit plan mode and set session mode to `default`. | +| `exit-plan-clear-auto` | Clear context, continue the plan, and use `auto`. | +| `exit-plan-clear-bypass` | Clear context, continue the plan, and bypass permissions. | +| `exit-plan-clear-accept-edits` | Clear context, continue the plan, and accept edits. | +| `reject` | Deny the call, optionally returning feedback to Claude. | + +The adapter stably groups options as `allow_once`, `allow_always`, then reject kinds. Options of the +same kind retain their builder order. Clients must return the selected option id unchanged. + +## Durable SDK suggestions + +Claude may provide `suggestions: PermissionUpdate[]`. Before a suggestion can appear as a durable +choice, the adapter validates the complete array and takes a structured clone. Validation recognizes: + +- `addRules`, `replaceRules`, and `removeRules`; +- `setMode` for known Claude permission modes; +- `addDirectories` and `removeDirectories`; +- known destinations and rule behaviors; +- bounded array sizes and bounded non-empty strings. + +Unknown update types, invalid values, oversized bundles, non-cloneable data, and empty suggestion +arrays are omitted. The snapshot prevents the label shown to the user from diverging from the effect +returned after a delayed response. + +An SDK suggestion is offered only when the tool-specific builder can accurately describe the entire +effect. The adapter never labels one part of a mixed bundle while silently applying the rest. + +When `matchedAskRule` is present, all persistent choices are suppressed. A configured `ask` rule must +continue asking; accepting a generated allow rule that cannot override it would be misleading. + +## Tool-specific behavior + +| Tool family | Permission choices and effects | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| Bash / PowerShell | One-time allow; exact representable SDK bundle with its complete effect in the option label; reject. | +| Read / Glob / Grep | One-time allow; matching session-scoped read grant; reject. | +| Edit / Write / NotebookEdit | One-time allow; matching session edit grant, including `.claude` handling; reject. | +| WebFetch | One-time allow; generated `domain:` local rule; reject. | +| Skill | One-time allow; exact skill and optional `prefix:*` local rules; reject. | +| EnterPlanMode | Enter plan mode once, or reject and continue implementing. | +| ExitPlanMode | One keep-context and one clear-context elevated mode (`auto` > bypass > accept edits), manual approval, or reject. | +| SandboxNetworkAccess | One-time allow; exact SDK host rule when representable; reject. | +| Computer Use MCP | One-time allow; exact representable SDK allow suggestion; reject. | +| MCP | One-time allow; exact representable SDK allow suggestion for that tool; reject. | +| WebSearch / Agent / Task | One-time allow; generated whole-tool local rule; reject. | +| ReviewArtifact / Workflow | Generic fallback behavior until SDK renderer state is available. | +| Monitor / unknown tools | Generic fallback behavior. | + +Generated WebFetch, Skill, and non-MCP fallback effects use `localSettings`. Filesystem grants are +offered only when the SDK bundle is session-scoped and covers the current path. Shell labels may +describe command rules, read paths, additional directories, or a representable combination. + +`AskUserQuestion` is not a permission dialog. With ACP form elicitation support it is routed through +ACP elicitation; without that support it is disabled when the Claude session is created. + +## Permission modes and bypass + +The session exposes the Claude modes available for the current model using Claude Code's labels: +`Manual`, `Accept edits`, `Plan`, conditional `Auto`, and conditional `Bypass permissions`. The +internal `dontAsk` SDK mode is accepted from settings for compatibility but is not advertised as a +user-selectable mode. + +The ACP mode ids are the Claude SDK wire ids: `default`, `acceptEdits`, `plan`, `auto`, and +`bypassPermissions`. `Manual` deliberately retains the SDK id `default`; `manual` is only a settings +input alias. The adapter does not invent parallel ids for presentation labels. + +Like Claude Code, `ExitPlanMode` offers one elevated keep-context choice by priority: `auto`, then +`bypassPermissions`, then `acceptEdits`. A non-empty plan also gets exactly one clear-context choice +with the same priority. This requires no ACP capability or setting: after selection the adapter +interrupts the old private Claude query, hides that internal rejection from the client, creates a +new private query under the same public ACP session, and continues the pending turn with +`Implement the following plan:` followed by the complete plan. Mode, model, agent, effort, Fast +mode, and accumulated turn usage are preserved. + +The SDK cannot reapply `allowedPrompts` through a public control request, so those renderer hints do +not survive this internal handoff. Ultraplan remains unavailable because its required renderer state +is not exposed by the SDK. + +The adapter does not auto-allow a callback merely because the session advertises +`bypassPermissions`. Claude Code applies bypass before invoking `canUseTool`. A request that still +reaches the callback is bypass-immune, safety-sensitive, interactive, or forced by an explicit ask +rule and must remain visible to the user. + +## Cancellation and failures + +The SDK tool-call `AbortSignal` is forwarded to the ACP client. The adapter also races the client +request locally against the same signal, so a client that ignores cancellation cannot leave Claude's +tool call waiting forever. + +These cases all abort the tool use with `Tool use aborted`: + +- the signal was already aborted; +- the signal aborts while the permission request is open; +- the client returns a cancelled outcome; +- the client rejects after cancellation. + +A normal selected reject returns `behavior: "deny"`, `decisionClassification: "user_reject"`, and +the default refusal message. + +## Client requirements + +A client implementing version 1 should: + +- render standard ACP tool-call content and locations as the action subject; +- treat request `_meta.permission.title` and `description` as optional presentation hints; +- preserve option ids; +- ignore unknown `_meta` fields and future feature names; +- settle the request when its cancellation signal fires. + +The client must not infer storage scope, lifetime, or permission effects from `kind` or button text. +The adapter owns the Claude `PermissionUpdate` and applies it only after validating the response. + +## Current limits + +The SDK does not expose enough renderer state to reproduce every Claude Code dialog. Version 1 does +not implement: + +- Bash output-redirection rewriting; +- classifier-specific prompt state; +- ExitPlanMode Ultraplan actions; +- Computer Use macOS/TCC or application-allowlist dialogs; +- structured permission lifetime or storage metadata for clients. + +These omissions do not weaken the one-time choice. When a durable effect cannot be represented +honestly, the adapter sends only one-time allow and reject. + +## Verification + +The executable contract lives in: + +- `src/tests/permission-presentation.test.ts` for capability and request presentation; +- `src/tests/permission-options.test.ts` for tool-specific option construction; +- `src/tests/permission-effects.test.ts` for response validation and effects; +- `src/tests/acp-agent.test.ts` for request ordering, cancellation, bypass, and subagent attribution; +- `src/tests/session-config-options.test.ts` for plan-mode integration; +- `src/tests/live-capability-*-audit.test.ts` for opt-in live SDK audits. + +```sh +npm run build +npm run check +npm run test:run +``` diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 83f80d8b..0df0402b 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -27,7 +27,6 @@ import { ndJsonStream, NewSessionRequest, NewSessionResponse, - PermissionOption, PromptRequest, PromptResponse, ProviderInfo, @@ -72,7 +71,6 @@ import { Options, PermissionMode, PermissionResult, - PermissionUpdate, Query, query, SDKAssistantMessageError, @@ -120,6 +118,11 @@ import { refusalFallbackResultFromResponse, refusalFallbackToCreateRequest, } from "./elicitation.js"; +import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js"; +import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js"; +import { buildClaudePermissionOptions } from "./permissions/options.js"; +import { buildClaudePermissionPresentation } from "./permissions/presentation.js"; +import { decodeClaudePermissionResponse } from "./permissions/response.js"; import { SettingsManager } from "./settings.js"; import { activeUsageLimitMessage, @@ -169,6 +172,17 @@ import { toolUpdateFromToolResult, } from "./tools.js"; import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; +import { + acceptedPlanToolResult, + ExitPlanCoordinator, + executionDiagnostic, + exitPlanModeRawOutput, + observeExitPlanToolResults, +} from "./exit-plan.js"; +import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js"; +import { parseToolResultMeta } from "./tool-result-meta.js"; + +export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js"; export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude"); @@ -419,6 +433,7 @@ type Turn = { /** What a steered turn settles with once its steered work has run: the outcome * of its latest result, so its usage covers every cycle the turn ran. */ steeredSettle?: PromptResponse; + carriedUsage?: AccumulatedUsage; resolve: (response: PromptResponse) => void; reject: (error: unknown) => void; /** Settles after the ACP prompt request completes, regardless of outcome. */ @@ -576,6 +591,7 @@ export type Session = { * fresh session it stalls until the first turn runs (see the seeding call * sites and `contextWindowCache`). */ contextWindowSize: number; + contextUsedTokens?: number; /** Whether `contextWindowSize` came from an authoritative source (the * cross-session cache, a resumed session's `getContextUsage` report, or a * `result.modelUsage`) rather than the text heuristic / default. Guards the @@ -610,6 +626,17 @@ export type Session = { * tool_use block streams; this set makes the two paths converge regardless of * order. Pruned at `tool_result` time alongside `toolUseCache`. */ emittedToolCalls: Set; + /** ExitPlanMode denial that intentionally interrupts the current Claude + * cycle. Correlated by tool-use id until the terminal result arrives. */ + pendingExitPlanModeInterruption?: { + toolUseId: string; + toolResultSeen: boolean; + }; + pendingExitPlanContextReset?: { + toolUseId: string; + plan: string; + mode: PermissionMode; + }; /** Registry of live background tasks, keyed by task id: populated at * `task_started`, pruned when the task settles (a `task_notification` or * a terminal `task_updated` patch), and reconciled against @@ -1118,10 +1145,6 @@ function shouldHideClaudeAuth(): boolean { * revivable, so the only recovery is a fresh session. */ const SESSION_ENDED_MESSAGE = "The Claude Agent session has ended. Please start a new session."; -// Bypass Permissions doesn't work if we are a root/sudo user -const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0; -const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX; - // Slash commands that the SDK handles locally without replaying the user // message and without invoking the model. const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); @@ -1240,172 +1263,6 @@ export function isSyntheticLoginMessage(apiMessage: unknown): boolean { ); } -const PERMISSION_MODE_ALIASES: Record = { - auto: "auto", - default: "default", - // Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts - // `"defaultMode": "manual"` in settings.json; honor the same alias here. - manual: "default", - acceptedits: "acceptEdits", - dontask: "dontAsk", - plan: "plan", - bypasspermissions: "bypassPermissions", - bypass: "bypassPermissions", -}; - -export function resolvePermissionMode( - defaultMode?: unknown, - logger: Logger = console, -): PermissionMode { - if (defaultMode === undefined) { - return "default"; - } - - if (typeof defaultMode !== "string") { - logger.error("Ignoring permissions.defaultMode from settings: expected a string."); - return "default"; - } - - const normalized = defaultMode.trim().toLowerCase(); - if (normalized === "") { - logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string."); - return "default"; - } - - const mapped = PERMISSION_MODE_ALIASES[normalized]; - if (!mapped) { - logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`); - return "default"; - } - - if (mapped === "bypassPermissions" && !ALLOW_BYPASS) { - logger.error( - "Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.", - ); - return "default"; - } - - return mapped; -} - -function permissionLifetime(destination: PermissionUpdate["destination"]): Record { - switch (destination) { - case "session": - return { scope: "session" }; - case "cliArg": - return { scope: "process", storage: "cli_argument" }; - case "userSettings": - return { scope: "persistent", storage: "user" }; - case "projectSettings": - return { scope: "persistent", storage: "project" }; - case "localSettings": - return { scope: "persistent", storage: "project_local" }; - default: - return { scope: "unknown" }; - } -} - -function permissionMetadataForAlwaysAllow( - suggestions: PermissionUpdate[] | undefined, - toolName: string, -): Record { - const effectiveSuggestions = - suggestions && suggestions.length > 0 - ? suggestions - : [ - { - type: "addRules" as const, - rules: [{ toolName }], - behavior: "allow" as const, - destination: "session" as const, - }, - ]; - const changes: Array> = []; - - for (const update of effectiveSuggestions) { - switch (update.type) { - case "addRules": - case "removeRules": - case "replaceRules": { - const operation = - update.type === "addRules" ? "add" : update.type === "removeRules" ? "remove" : "replace"; - const targets = update.rules.map((rule) => ({ - type: "tool", - toolName: rule.toolName, - ...(rule.ruleContent - ? { - matcher: { - type: "provider_rule", - provider: "claudeCode", - value: rule.ruleContent, - }, - } - : {}), - })); - const renderedRules = update.rules - .map((rule) => - rule.ruleContent - ? `${rule.toolName} calls matching ${rule.ruleContent}` - : `all ${rule.toolName} calls`, - ) - .join(", "); - const verb = - operation === "add" - ? update.behavior === "allow" - ? "Allow" - : update.behavior === "deny" - ? "Deny" - : "Ask before" - : operation === "remove" - ? `Remove ${update.behavior} rules for` - : `Replace ${update.behavior} rules with`; - changes.push({ - type: "policy_rule", - operation, - ruleBehavior: update.behavior, - description: `${verb} ${renderedRules}`, - lifetime: permissionLifetime(update.destination), - targets, - }); - break; - } - case "addDirectories": - case "removeDirectories": { - const operation = update.type === "addDirectories" ? "add" : "remove"; - changes.push({ - type: "policy_rule", - operation, - ruleBehavior: "allow", - description: - operation === "add" - ? `Allow filesystem access under ${update.directories.join(", ")}` - : `Remove additional filesystem access under ${update.directories.join(", ")}`, - lifetime: permissionLifetime(update.destination), - targets: update.directories.map((path) => ({ - type: "filesystem", - matcher: { type: "directory", path }, - })), - }); - break; - } - case "setMode": - changes.push({ - type: "permission_mode", - operation: "set", - provider: "claudeCode", - mode: update.mode, - description: `Set Claude Code permission mode to ${update.mode}`, - lifetime: permissionLifetime(update.destination), - }); - break; - default: - break; - } - } - - return { version: 1, changes }; -} - /** * Client-facing surface the agent calls back into. This is the subset of ACP * client methods the agent actually uses, expressed as a narrow interface so @@ -1482,6 +1339,30 @@ class ClientConnection implements AcpClient { } } +function raceWithAbort(operation: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const cleanup = () => signal.removeEventListener("abort", onAbort); + const onAbort = () => { + cleanup(); + reject(new Error("Tool use aborted")); + }; + signal.addEventListener("abort", onAbort, { once: true }); + if (signal.aborted) { + onAbort(); + } + void operation.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + }, + ); + }); +} + export class ClaudeAcpAgent { sessions: { [key: string]: Session; @@ -1494,6 +1375,7 @@ export class ClaudeAcpAgent { providerConfig?: ProviderConfig; /** Serializes provider changes while every open query is recreated between turns. */ private providerUpdate: Promise | null = null; + private readonly exitPlan: ExitPlanCoordinator; /** Grace period before a `session/cancel` forces a wedged prompt loop to * return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so * tests can shrink it. */ @@ -1503,6 +1385,39 @@ export class ClaudeAcpAgent { this.sessions = {}; this.client = client; this.logger = logger ?? console; + this.exitPlan = new ExitPlanCoordinator({ + currentSession: (id) => this.sessions[id], + closeQueryStream: (session) => this.closeQueryStream(session), + restartSession: async (params, options) => { + await this.createSession(params, options); + const session = this.sessions[options.publicSessionId]; + if (!session) throw new Error("Fresh Claude context was not created"); + return session; + }, + applyFastMode: (session, enabled) => this.applyFastMode(session, enabled), + sessionUpdate: (notification) => this.client.sessionUpdate(notification), + ensureConsumer: (session, id) => this.ensureConsumer(session, id), + logError: (message, error) => this.logger.error(message, error), + destroyReplacement: (id, session) => { + disarmForceCancel(session); + session.cancelController?.abort(); + this.closeQueryStream(session); + session.abortController.abort(); + if (this.sessions[id] === session) delete this.sessions[id]; + }, + settleCancelledTurn: (original, session, turn) => { + disarmForceCancel(session); + this.finishFileChangeAudit(session, turn, "cancelled"); + turn.settled = true; + turn.resolve({ stopReason: "cancelled", usage: sessionUsage(original) }); + }, + settleFailedTurn: (session, turn, error) => { + disarmForceCancel(session); + this.finishFileChangeAudit(session, turn, "providerError"); + turn.settled = true; + turn.reject(error); + }, + }); } async initialize(request: InitializeRequest): Promise { @@ -2341,12 +2256,13 @@ export class ClaudeAcpAgent { // cleared when each consolidated message consumes it. #785 stopped // resetting the streamed-content tracking here but left this line. stopReason = "end_turn"; - session.accumulatedUsage = { + session.accumulatedUsage = session.activeTurn?.carriedUsage ?? { inputTokens: 0, outputTokens: 0, cachedReadTokens: 0, cachedWriteTokens: 0, }; + if (session.activeTurn) session.activeTurn.carriedUsage = undefined; }; /** Promote a queued turn to active: it becomes the one output is attributed @@ -3076,6 +2992,7 @@ export class ClaudeAcpAgent { const usedTokens = await fetchContextUsedTokens(session.query, this.logger); lastAssistantUsage = null; lastAssistantTotalUsage = usedTokens ?? 0; + session.contextUsedTokens = usedTokens ?? 0; await sendUpdate({ sessionId: message.session_id, update: { @@ -3611,7 +3528,8 @@ export class ClaudeAcpAgent { // slash-command output forwarding), though their cost is real. const isAutonomousResult = message.origin != null && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind); - + const pendingExitPlanModeInterruption = session.pendingExitPlanModeInterruption; + const pendingExitPlanContextReset = session.pendingExitPlanContextReset; try { // Reconcile the Fast mode toggle with the SDK's reported state. // Gated to user-driven turns like every other side effect below; @@ -3787,6 +3705,8 @@ export class ClaudeAcpAgent { } if (session.cancelled) { + session.pendingExitPlanModeInterruption = undefined; + session.pendingExitPlanContextReset = undefined; if (!isAutonomousResult) { await clearFailuresFromEarlierTurns(); stopReason = "cancelled"; @@ -3828,6 +3748,45 @@ export class ClaudeAcpAgent { await clearFailuresFromEarlierTurns(); + // `interrupt: true` is required to make "No, keep planning" + // terminate the ACP turn. Claude represents that intentional + // interrupt as an error-shaped diagnostic, so translate only a + // diagnostic causally paired with the recorded ExitPlanMode + // permission response. + const diagnostic = executionDiagnostic(message); + if ( + pendingExitPlanModeInterruption && + pendingExitPlanModeInterruption.toolResultSeen && + message.is_error && + diagnostic && + /(?:^|\s)result_type=user(?:\s|$)/.test(diagnostic) && + /(?:^|\s)stop_reason=tool_use(?:\s|$)/.test(diagnostic) + ) { + session.pendingExitPlanModeInterruption = undefined; + if ( + pendingExitPlanContextReset && + pendingExitPlanContextReset.toolUseId === + pendingExitPlanModeInterruption.toolUseId + ) { + await this.exitPlan.restart( + params.sessionId, + session, + pendingExitPlanContextReset, + ); + return; + } + stopReason = "cancelled"; + settleOrDefer({ stopReason: "cancelled", usage: sessionUsage(session) }); + break; + } + if (pendingExitPlanModeInterruption) { + // This result ended the interrupted cycle without the exact + // correlated cancellation shape. Never carry its marker into + // a later turn, whether or not the tool result was observed. + session.pendingExitPlanModeInterruption = undefined; + session.pendingExitPlanContextReset = undefined; + } + // A refusal can arrive on any result subtype (and may even set // is_error), so handle it before the subtype switch — otherwise the // is_error throw below would surface it as an internal error. The @@ -4136,6 +4095,7 @@ export class ClaudeAcpAgent { const nextUsage = totalTokens(lastAssistantUsage); if (nextUsage !== lastAssistantTotalUsage) { lastAssistantTotalUsage = nextUsage; + session.contextUsedTokens = nextUsage; await sendUpdate({ sessionId: params.sessionId, update: { @@ -4281,6 +4241,7 @@ export class ClaudeAcpAgent { if (message.type === "assistant" && message.parent_tool_use_id === null) { lastAssistantUsage = snapshotFromUsage(message.message.usage); lastAssistantTotalUsage = totalTokens(lastAssistantUsage); + session.contextUsedTokens = lastAssistantTotalUsage; lastAssistantWasUsageLimit = isSyntheticUsageLimitMessage(message.message); if (message.error || lastAssistantWasUsageLimit) { lastAssistantFailureTitle = assistantMessageText(message.message); @@ -4459,6 +4420,8 @@ export class ClaudeAcpAgent { content = message.message.content; } + const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session); + for (const notification of toAcpNotifications( content, message.message.role, @@ -4486,7 +4449,7 @@ export class ClaudeAcpAgent { // filtered out of `content` above; blocks that do pass through // (e.g. a subagent image) carry the stamped parentToolUseId // meta and are excluded there. - await sendUpdate(notification); + await sendUpdate(acceptedPlanToolResult(notification, acceptedPlanToolUseId)); } break; } @@ -4664,10 +4627,14 @@ export class ClaudeAcpAgent { } async cancel(params: CancelNotification): Promise { + this.exitPlan.cancel(params.sessionId); const session = this.sessions[params.sessionId]; if (!session) { return; } + session.cancelled = true; + session.pendingExitPlanModeInterruption = undefined; + session.pendingExitPlanContextReset = undefined; // The stream already ended (see closeQueryStream): every in-flight turn was // settled when it closed, and there is no live query to interrupt. Calling // query.interrupt() on a finished iterator could reject and surface from @@ -4675,7 +4642,6 @@ export class ClaudeAcpAgent { if (session.queryClosed) { return; } - session.cancelled = true; // A priority steer may still be queued in the SDK when cancellation // settles its owning turn. Its later echo matches no live turn, and its // result must be skipped rather than promoted onto the next prompt. @@ -5309,16 +5275,16 @@ export class ClaudeAcpAgent { /** Forward a permission request to the client, wiring the tool call's * `signal` through as a `cancellationSignal`. When the turn is cancelled * while the client's prompt is still open the signal aborts, the SDK sends - * `$/cancel_request`, and the client settles the request (a `cancelled` - * outcome or a `requestCancelled` rejection). Either way we surface the same - * "Tool use aborted" the callers already expect, so a cancelled dialog no - * longer leaves the `await` hanging. */ + * `$/cancel_request`, and our local abort race settles even if the client + * ignores it. A `cancelled` outcome, request rejection, and local abort all + * surface the same "Tool use aborted" the callers already expect. */ private async requestPermissionFromClient( params: RequestPermissionRequest, toolName: string, signal: AbortSignal, parentToolUseId?: string, ): Promise { + if (signal.aborted) throw new Error("Tool use aborted"); // The SDK may invoke `canUseTool` (and therefore this permission request) // before the assistant message's tool_use block streams to us. Some ACP clients // expect the `tool_call` a permission request references to already exist, @@ -5331,9 +5297,15 @@ export class ClaudeAcpAgent { params.toolCall.toolCallId, params.toolCall.rawInput, parentToolUseId, + signal, ); + if (signal.aborted) throw new Error("Tool use aborted"); + + // Do not rely on every ACP client settling requestPermission after the + // cancellation signal. The local race guarantees that Claude's tool call + // is released even when an older or broken client ignores $/cancel_request. try { - return await this.client.requestPermission(params, signal); + return await raceWithAbort(this.client.requestPermission(params, signal), signal); } catch (error) { if (signal.aborted) { throw new Error("Tool use aborted", { cause: error }); @@ -5360,6 +5332,7 @@ export class ClaudeAcpAgent { toolCallId: string, toolInput: unknown, parentToolUseId?: string, + signal?: AbortSignal, ): Promise { const session = this.sessions[sessionId]; if (!session) { @@ -5385,14 +5358,34 @@ export class ClaudeAcpAgent { }, }; } - await this.client.sessionUpdate({ sessionId, update }); + try { + const emission = this.client.sessionUpdate({ sessionId, update }); + await (signal ? raceWithAbort(emission, signal) : emission); + } catch (error) { + // The set is also the de-duplication guard for the later streamed + // tool_use. Keep it truthful: if emission failed, that path must still + // be allowed to publish the tool call instead of refining a phantom one. + session.emittedToolCalls.delete(toolCallId); + throw error; + } } canUseTool(sessionId: string): CanUseTool { return async ( toolName, toolInput, - { signal, suggestions, toolUseID, agentID, matchedAskRule }, + { + signal, + suggestions, + toolUseID, + agentID, + matchedAskRule, + blockedPath, + decisionReason, + title, + displayName, + description, + }, ) => { const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true; const session = this.sessions[sessionId]; @@ -5457,181 +5450,101 @@ export class ClaudeAcpAgent { toolUseID, toolInput, parentToolUseId, + signal, ); return this.handleAskUserQuestion(sessionId, toolInput, toolUseID, signal); } - if (toolName === "ExitPlanMode") { - const optionsAll: PermissionOption[] = [ - { kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" }, - { - kind: "allow_always", - name: "Yes, and auto-accept edits", - optionId: "acceptEdits", - }, - { kind: "allow_once", name: "Yes, and manually approve edits", optionId: "default" }, - { kind: "reject_once", name: "No, keep planning", optionId: "plan" }, - ]; - if (ALLOW_BYPASS) { - optionsAll.unshift({ - kind: "allow_always", - name: "Yes, and bypass permissions", - optionId: "bypassPermissions", - }); - } - // Filter against the session's currently-advertised modes so we never - // present options the active model can't honor (e.g. `auto` on Haiku). - // `bypassPermissions` is already covered by `availableModes` via - // `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a - // "keep planning" reject path; it's always present in `availableModes`. - const options = optionsAll.filter((o) => - session.modes.availableModes.some((m) => m.id === o.optionId), - ); + // Do not auto-allow here based on the session's advertised mode. Claude + // Code applies bypassPermissions before invoking canUseTool; a request + // that still reaches this callback is deliberately bypass-immune (for + // example a safety check, a tool requiring user interaction, or an + // explicit ask rule). Re-applying bypass in the host would erase that + // provider safety decision. - const response = await this.requestPermissionFromClient( - { - options, - sessionId, - toolCall: { - toolCallId: toolUseID, - rawInput: toolInput, - ...toolInfoFromToolUse( - { name: toolName, input: toolInput, id: toolUseID }, - supportsTerminalOutput, - session?.cwd, - ), - // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta), - // so clients can rely on one shape everywhere. - ...(parentToolUseId - ? { _meta: { claudeCode: { toolName, parentToolUseId } } satisfies ToolUpdateMeta } - : {}), - }, - }, - toolName, - signal, - parentToolUseId, - ); + const durableChangeSet = normalizeDurablePermissionChangeSet( + suggestions, + matchedAskRule !== undefined, + ); + const presentation = buildClaudePermissionPresentation({ + toolName, + input: toolInput, + toolUseID, + cwd: session.cwd, + supportsTerminalOutput, + blockedPath, + title, + displayName, + description, + decisionReason, + }); - if (signal.aborted || response.outcome?.outcome === "cancelled") { - throw new Error("Tool use aborted"); - } - const selectedMode = - response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined; - const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode); - if ( - selectedModeWasOffered && - (selectedMode === "default" || - selectedMode === "acceptEdits" || - selectedMode === "auto" || - selectedMode === "bypassPermissions") - ) { - await this.client.sessionUpdate({ - sessionId, - update: { - sessionUpdate: "current_mode_update", - currentModeId: selectedMode, - }, - }); - await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode); - - return { - behavior: "allow", - updatedInput: toolInput, - updatedPermissions: suggestions ?? [ - { type: "setMode", mode: selectedMode, destination: "session" }, - ], - }; - } else { - return { - behavior: "deny", - message: "User rejected request to exit plan mode.", - }; - } - } - - // In bypass mode the CLI skips permission checks itself; the asks that - // still reach canUseTool are the ones it insists on prompting for even - // under --dangerously-skip-permissions. Keep auto-allowing those — - // bypass means bypass — EXCEPT rule-forced asks (`matchedAskRule`): the - // user explicitly configured a permissions.ask rule for this tool, and - // the SDK's guidance is that hosts running auto-approval must treat such - // asks as a human prompt. Fall through to the normal request below. - if (session.modes.currentModeId === "bypassPermissions" && !matchedAskRule) { - return { - behavior: "allow", - updatedInput: toolInput, - updatedPermissions: suggestions ?? [ - { type: "addRules", rules: [{ toolName }], behavior: "allow", destination: "session" }, - ], + if (parentToolUseId) { + presentation.toolCall._meta = { + claudeCode: { toolName, parentToolUseId }, }; } + const permissionOptions = buildClaudePermissionOptions({ + toolName, + displayName, + input: toolInput, + cwd: session.cwd, + durableChangeSet, + allowPersistentOptions: matchedAskRule === undefined, + availableModes: session.modes.availableModes.map((mode) => mode.id), + contextUsedPercent: + session.contextUsedTokens === undefined || session.contextWindowSize <= 0 + ? undefined + : Math.max( + 0, + Math.min( + 100, + Math.round((session.contextUsedTokens / session.contextWindowSize) * 100), + ), + ), + }); + const response = await this.requestPermissionFromClient( { - options: [ - { kind: "reject_once", name: "Deny", optionId: "reject" }, - { kind: "allow_once", name: "Allow Once", optionId: "allow" }, - { - kind: "allow_always", - name: "Always Allow", - optionId: "allow_always", - _meta: { - permission: permissionMetadataForAlwaysAllow(suggestions, toolName), - }, - }, - ], + ...presentation, + options: permissionOptions, sessionId, - toolCall: { - toolCallId: toolUseID, - rawInput: toolInput, - ...toolInfoFromToolUse( - { name: toolName, input: toolInput, id: toolUseID }, - supportsTerminalOutput, - session?.cwd, - ), - // `claudeCode` metas always carry `toolName` (see ToolUpdateMeta), - // so clients can rely on one shape everywhere. - ...(parentToolUseId - ? { _meta: { claudeCode: { toolName, parentToolUseId } } satisfies ToolUpdateMeta } - : {}), - }, }, toolName, signal, parentToolUseId, ); - if (signal.aborted || response.outcome?.outcome === "cancelled") { - throw new Error("Tool use aborted"); + if (signal.aborted) throw new Error("Tool use aborted"); + const { permissionResult, contextResetMode: clearContextMode } = + decodeClaudePermissionResponse( + response, + toolName, + toolInput, + toolUseID, + permissionOptions, + durableChangeSet, + ); + if (toolName === "ExitPlanMode" && clearContextMode) { + const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : ""; + if (!plan) throw new Error("ExitPlanMode clear-context selection requires a plan"); + session.pendingExitPlanContextReset = { + toolUseId: toolUseID, + plan, + mode: clearContextMode, + }; } if ( - response.outcome?.outcome === "selected" && - (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always") + toolName === "ExitPlanMode" && + permissionResult.behavior === "deny" && + permissionResult.interrupt === true ) { - // If Claude Code has suggestions, it will update their settings already - if (response.outcome.optionId === "allow_always") { - return { - behavior: "allow", - updatedInput: toolInput, - updatedPermissions: suggestions ?? [ - { - type: "addRules", - rules: [{ toolName }], - behavior: "allow", - destination: "session", - }, - ], - }; - } - return { - behavior: "allow", - updatedInput: toolInput, - }; - } else { - return { - behavior: "deny", - message: "User refused permission to run tool", + session.pendingExitPlanModeInterruption = { + toolUseId: toolUseID, + toolResultSeen: false, }; } + return permissionResult; }; } @@ -6149,7 +6062,12 @@ export class ClaudeAcpAgent { private async createSession( params: NewSessionRequest, - creationOpts: { resume?: string; forkSession?: boolean } = {}, + creationOpts: { + resume?: string; + forkSession?: boolean; + publicSessionId?: string; + permissionMode?: PermissionMode; + } = {}, ): Promise { // Validate `cwd` up front. The ACP spec requires an absolute path, and the // directory must actually exist on the machine running the agent. Without @@ -6161,7 +6079,9 @@ export class ClaudeAcpAgent { // We want to create a new session id unless it is resume, // but not resume + forkSession. let sessionId; - if (creationOpts.forkSession) { + if (creationOpts.publicSessionId) { + sessionId = creationOpts.publicSessionId; + } else if (creationOpts.forkSession) { sessionId = randomUUID(); } else if (creationOpts.resume) { sessionId = creationOpts.resume; @@ -6226,6 +6146,7 @@ export class ClaudeAcpAgent { settingsManager.getSettings().permissions?.defaultMode, this.logger, ); + const initialPermissionMode = creationOpts.permissionMode ?? permissionMode; // Extract options from _meta if provided const sessionMeta = params._meta as NewSessionMeta | undefined; @@ -6369,7 +6290,7 @@ export class ClaudeAcpAgent { // If we want bypassPermissions to be an option, we have to allow it here. // But it doesn't work in root mode, so we only activate it if it will work. allowDangerouslySkipPermissions: ALLOW_BYPASS, - permissionMode, + permissionMode: initialPermissionMode, canUseTool: this.canUseTool(sessionId), // Forward MCP elicitation requests onto ACP elicitation. Only attached // when the client advertised support, so non-supporting clients keep the @@ -6457,7 +6378,8 @@ export class ClaudeAcpAgent { }, ], }, - ...creationOpts, + ...(creationOpts.resume !== undefined && { resume: creationOpts.resume }), + ...(creationOpts.forkSession !== undefined && { forkSession: creationOpts.forkSession }), abortController, }; @@ -6469,7 +6391,7 @@ export class ClaudeAcpAgent { if (creationOpts?.resume === undefined || creationOpts?.forkSession) { // Set our own session id if not resuming an existing session. - options.sessionId = sessionId; + options.sessionId = creationOpts.publicSessionId ? randomUUID() : sessionId; } // Handle abort controller from meta options @@ -6576,7 +6498,7 @@ export class ClaudeAcpAgent { // `setPermissionMode`. Keep `permissionMode` as the resolved user intent // (matches what was passed into `options.permissionMode` above) and use // `effectiveMode` for the post-clamp value the session actually runs in. - let effectiveMode: PermissionMode = permissionMode; + let effectiveMode: PermissionMode = initialPermissionMode; if (!availableModes.some((m) => m.id === effectiveMode)) { if (effectiveMode === "auto") { this.logger.error( @@ -6961,47 +6883,40 @@ function isValidBaseUrl(baseUrl: string): boolean { * `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`. */ function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState["availableModes"] { - const modes: SessionModeState["availableModes"] = []; + const modes: SessionModeState["availableModes"] = [ + { + // Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces; + // the wire id stays "default" ("manual" is only an accepted input alias). + id: "default", + name: "Manual", + description: "Always ask before making changes", + }, + { + id: "acceptEdits", + name: "Accept edits", + description: "Automatically accept all file edits", + }, + { + id: "plan", + name: "Plan", + description: "Create a plan before making changes", + }, + ]; // Only advertise "auto" when the SDK reports the model supports it. if (modelInfo?.supportsAutoMode === true) { modes.push({ id: "auto", name: "Auto", - description: "Use a model classifier to approve/deny permission prompts", + description: "Claude handles permission decisions", }); } - modes.push( - { - // Claude Code 2.1.200 renamed this mode to "Manual" across its surfaces; - // the wire id stays "default" ("manual" is only an accepted input alias). - id: "default", - name: "Manual", - description: "Standard behavior, prompts for dangerous operations", - }, - { - id: "acceptEdits", - name: "Accept Edits", - description: "Auto-accept file edit operations", - }, - { - id: "plan", - name: "Plan Mode", - description: "Planning mode, no actual tool execution", - }, - { - id: "dontAsk", - name: "Don't Ask", - description: "Don't prompt for permissions, deny if not pre-approved", - }, - ); - if (ALLOW_BYPASS) { modes.push({ id: "bypassPermissions", - name: "Bypass Permissions", - description: "Bypass all permission checks", + name: "Bypass permissions", + description: "Accepts all permissions", }); } @@ -7040,8 +6955,6 @@ export const BUILTIN_AGENT_NAMES = new Set([ // reserved sentinel: a custom agent named exactly this would collide with it // (two options sharing the value, selection silently routing to `null`), so we // exclude that name from discovery. -export const DEFAULT_AGENT_ID = "default"; - /** Discover user/plugin/project-configured main-thread agents, excluding the * built-in subagents and the reserved "default" sentinel. Returns an empty * list if discovery fails so a flaky control request never blocks session @@ -7061,7 +6974,6 @@ export async function discoverCustomAgents(q: Query): Promise { * same identifiers and can't drift apart. */ export const MODE_CONFIG_ID = "mode"; export const MODEL_CONFIG_ID = "model"; -export const EFFORT_CONFIG_ID = "effort"; export const AGENT_CONFIG_ID = "agent"; export const FAST_MODE_CONFIG_ID = "fast"; @@ -8086,36 +7998,6 @@ function streamedInputRefinement( }; } -/** Validates the SDK user message's `tool_result_meta` sidecar (emitted on the - * wire by CLI ≥ 2.1.216 but absent from sdk.d.ts, hence unknown-typed) into a - * by-tool_use_id lookup. Each entry explains why an is_error tool_result - * carries harness prose instead of the tool's own output — "user-rejected", - * "permission-rule", "interrupted", "cancelled", … (open set: new kinds ship - * on the wire ahead of schema updates, so no enum check). Malformed entries - * are skipped rather than failing the message. */ -function parseToolResultMeta( - raw: unknown, -): Map | undefined { - if (!Array.isArray(raw)) { - return undefined; - } - let byToolUseId: Map | undefined; - for (const entry of raw) { - if (typeof entry !== "object" || entry === null) { - continue; - } - const { id, non_execution_kind, user_feedback } = entry as Record; - if (typeof id !== "string" || typeof non_execution_kind !== "string") { - continue; - } - (byToolUseId ??= new Map()).set(id, { - nonExecutionKind: non_execution_kind, - ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}), - }); - } - return byToolUseId; -} - /** * Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP). * Only handles text, image, and thinking chunks for now. @@ -8523,7 +8405,12 @@ export function toAcpNotifications( toolCallId: chunk.tool_use_id, sessionUpdate: "tool_call_update", status: "is_error" in chunk && chunk.is_error ? "failed" : "completed", - rawOutput: chunk.content, + // terminal_output already carried the exact bytes in the preceding + // update. Repeating them as rawOutput wastes bandwidth and lets a + // client accidentally render the same output twice. + ...(toolMeta?.terminal_output + ? {} + : { rawOutput: exitPlanModeRawOutput(toolUse.name, chunk.content) }), ...toolUpdate, }; } diff --git a/src/clear-context-coordinator.ts b/src/clear-context-coordinator.ts new file mode 100644 index 00000000..9cbc5a21 --- /dev/null +++ b/src/clear-context-coordinator.ts @@ -0,0 +1,163 @@ +import type { NewSessionRequest, SessionConfigOption } from "@agentclientprotocol/sdk"; +import type { + EffortLevel, + Options, + PermissionMode, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { DEFAULT_AGENT_ID, DEFAULT_MODEL_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js"; + +export type ClearContextReset = { + toolUseId: string; + plan: string; + mode: PermissionMode; +}; + +type Usage = { + inputTokens: number; + outputTokens: number; + cachedReadTokens: number; + cachedWriteTokens: number; +}; + +export type ClearContextTurn = { + settled: boolean; + promptUuid: string; + carriedUsage?: Usage; +}; + +/** The session state needed to replace Claude's private conversation without + * replacing the public ACP turn. Keeping this projection here makes the + * restart workflow independent from the agent's much larger Session record. */ +export type ClearContextSession = { + cwd: string; + creationParams?: NewSessionRequest; + accumulatedUsage: Usage; + models: { currentModelId: string }; + configOptions: SessionConfigOption[]; + currentAgent: string; + fastModeEnabled: boolean; + activeTurn?: Turn | null; + turnQueue?: Turn[]; + pendingExitPlanContextReset?: ClearContextReset; + contextUsedTokens?: number; + input: { push(message: SDKUserMessage): unknown }; +}; + +export type ClearContextCoordinatorHost< + Session extends ClearContextSession, + Turn extends ClearContextTurn, +> = { + currentSession(sessionId: string): Session | undefined; + closeQueryStream(session: Session): void; + restartSession( + params: NewSessionRequest, + options: { publicSessionId: string; permissionMode: PermissionMode }, + ): Promise; + applyFastMode(session: Session, enabled: boolean): Promise; + publishSessionState( + sessionId: string, + mode: PermissionMode, + configOptions: SessionConfigOption[], + ): Promise; + continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage; + ensureConsumer(session: Session, sessionId: string): void; + logError(message: string, error: unknown): void; +}; + +function restartParams(session: Session): NewSessionRequest { + const originalParams = session.creationParams ?? { cwd: session.cwd, mcpServers: [] }; + const currentEffort = session.configOptions.find( + (option) => option.id === EFFORT_CONFIG_ID, + )?.currentValue; + const originalMeta = originalParams._meta as + ({ claudeCode?: { options?: Options } } & Record) | undefined; + const originalOptions = originalMeta?.claudeCode?.options; + const unmanagedOptions = { ...(originalOptions ?? {}) }; + delete unmanagedOptions.model; + delete unmanagedOptions.agent; + delete unmanagedOptions.effort; + + return { + ...originalParams, + _meta: { + ...(originalMeta ?? {}), + claudeCode: { + ...(originalMeta?.claudeCode ?? {}), + options: { + ...unmanagedOptions, + ...(session.models.currentModelId !== DEFAULT_MODEL_ID + ? { model: session.models.currentModelId } + : {}), + ...(session.currentAgent !== DEFAULT_AGENT_ID ? { agent: session.currentAgent } : {}), + ...(typeof currentEffort === "string" && currentEffort !== "default" + ? { effort: currentEffort as EffortLevel } + : {}), + }, + }, + }, + }; +} + +/** Replace Claude's private conversation and attach the still-pending ACP turn + * to it. The host owns provider-specific creation and stream mechanics; this + * coordinator owns the ordering and state transfer invariants. */ +export async function continuePlanInFreshContext< + Turn extends ClearContextTurn, + Session extends ClearContextSession, +>( + sessionId: string, + oldSession: Session, + reset: ClearContextReset, + host: ClearContextCoordinatorHost, + signal?: AbortSignal, +): Promise { + const assertRestartActive = (session?: Session): void => { + if (signal?.aborted || (session && host.currentSession(sessionId) !== session)) { + throw new Error("Clear-context restart aborted"); + } + }; + assertRestartActive(); + const turn = oldSession.activeTurn; + if (!turn || turn.settled || host.currentSession(sessionId) !== oldSession) { + throw new Error("Cannot clear context without an active ACP turn"); + } + + const params = restartParams(oldSession); + host.closeQueryStream(oldSession); + + const freshSession = await host.restartSession(params, { + publicSessionId: sessionId, + permissionMode: reset.mode, + }); + assertRestartActive(freshSession); + + // Do not consume the reset or mutate the turn until a replacement exists. + // A failed restart must remain distinguishable from a lost query transport. + turn.carriedUsage = { ...oldSession.accumulatedUsage }; + oldSession.pendingExitPlanContextReset = undefined; + if (oldSession.fastModeEnabled !== freshSession.fastModeEnabled) { + try { + await host.applyFastMode(freshSession, oldSession.fastModeEnabled); + } catch (error) { + host.logError("Failed to restore Fast mode after clearing context:", error); + } + assertRestartActive(freshSession); + } + + oldSession.activeTurn = null; + oldSession.turnQueue = []; + freshSession.turnQueue = [turn]; + freshSession.contextUsedTokens = 0; + + try { + await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions); + } catch (error) { + host.logError("Failed to publish clear-context session state:", error); + } + assertRestartActive(freshSession); + + freshSession.input.push(host.continuationMessage(sessionId, reset.plan, turn.promptUuid)); + assertRestartActive(freshSession); + host.ensureConsumer(freshSession, sessionId); +} diff --git a/src/exit-plan.ts b/src/exit-plan.ts new file mode 100644 index 00000000..f8ef9c57 --- /dev/null +++ b/src/exit-plan.ts @@ -0,0 +1,217 @@ +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { + type ClearContextCoordinatorHost, + type ClearContextReset, + type ClearContextSession, + type ClearContextTurn, + continuePlanInFreshContext, +} from "./clear-context-coordinator.js"; +import { parseToolResultMeta } from "./tool-result-meta.js"; + +export function acceptedPlanToolResult( + notification: SessionNotification, + toolUseId: string | undefined, +): SessionNotification { + const update = notification.update; + if ( + !toolUseId || + update.sessionUpdate !== "tool_call_update" || + update.toolCallId !== toolUseId + ) { + return notification; + } + const completed = { ...update }; + delete completed.rawOutput; + delete completed.content; + return { ...notification, update: { ...completed, status: "completed" } }; +} + +function containsToolResultFor(content: unknown, toolUseId: string): boolean { + return ( + Array.isArray(content) && + content.some( + (block) => + typeof block === "object" && + block !== null && + (block as { type?: unknown }).type === "tool_result" && + (block as { tool_use_id?: unknown }).tool_use_id === toolUseId, + ) + ); +} + +type ExitPlanState = { + toolUseCache: Record; + pendingExitPlanModeInterruption?: { toolUseId: string; toolResultSeen: boolean }; + pendingExitPlanContextReset?: ClearContextReset; +}; + +function rejectedExitPlanToolUseId( + content: unknown, + toolUseCache: ExitPlanState["toolUseCache"], + rawToolResultMeta: unknown, +): string | undefined { + if (!Array.isArray(content)) return undefined; + const toolResultMeta = parseToolResultMeta(rawToolResultMeta); + if (!toolResultMeta) return undefined; + for (const block of content) { + if (typeof block !== "object" || block === null) continue; + const { type, tool_use_id: toolUseId, is_error: isError } = block as Record; + if ( + type === "tool_result" && + typeof toolUseId === "string" && + isError === true && + toolResultMeta.get(toolUseId)?.nonExecutionKind === "user-rejected" && + toolUseCache[toolUseId]?.name === "ExitPlanMode" + ) { + return toolUseId; + } + } + return undefined; +} + +/** Reconcile an SDK user message with the two pending ExitPlanMode lanes and + * return the accepted plan tool id whose rendered update must be completed. */ +export function observeExitPlanToolResults( + message: { type: string; tool_result_meta?: unknown }, + content: unknown, + state: ExitPlanState, +): string | undefined { + if (message.type !== "user") return undefined; + + const rejectedToolUseId = rejectedExitPlanToolUseId( + content, + state.toolUseCache, + message.tool_result_meta, + ); + if (rejectedToolUseId) { + // The stream is authoritative: resumed queries can lose the short-lived + // marker installed by canUseTool, while metadata preserves correlation. + state.pendingExitPlanModeInterruption = { + toolUseId: rejectedToolUseId, + toolResultSeen: true, + }; + } + + const pendingInterruption = state.pendingExitPlanModeInterruption; + if (pendingInterruption && containsToolResultFor(content, pendingInterruption.toolUseId)) { + pendingInterruption.toolResultSeen = true; + } + + const pendingReset = state.pendingExitPlanContextReset; + return pendingReset && containsToolResultFor(content, pendingReset.toolUseId) + ? pendingReset.toolUseId + : undefined; +} + +export function executionDiagnostic(message: SDKResultMessage): string | undefined { + if (message.subtype === "success") { + return message.result.startsWith("[ede_diagnostic]") ? message.result : undefined; + } + return message.errors.find((error) => error.startsWith("[ede_diagnostic]")); +} + +/** Claude wraps a rejected ExitPlanMode explanation in a Markdown code fence. + * Strip exactly one complete outer fence for that tool only. */ +export function exitPlanModeRawOutput(toolName: string, content: unknown): unknown { + if (toolName !== "ExitPlanMode" || typeof content !== "string") { + return content; + } + const fenced = /^\s*```[^\r\n]*\r?\n([\s\S]*?)\r?\n```\s*$/.exec(content); + return fenced?.[1] ?? content; +} + +export type ExitPlanRestartHost< + Session extends ClearContextSession, + Turn extends ClearContextTurn, +> = Omit< + ClearContextCoordinatorHost, + "publishSessionState" | "continuationMessage" +> & { + sessionUpdate(notification: SessionNotification): Promise; + destroyReplacement(sessionId: string, session: Session): void; + settleCancelledTurn(oldSession: Session, turnSession: Session, turn: Turn): void; + settleFailedTurn(turnSession: Session, turn: Turn, error: unknown): void; +}; + +/** Owns the lifetime of accepted-plan context replacements. In particular, a + * session cancellation invalidates an in-progress async restart so a late + * restartSession result cannot recreate a closed public session. */ +export class ExitPlanCoordinator< + Session extends ClearContextSession, + Turn extends ClearContextTurn, +> { + private readonly restarts = new Map(); + + constructor(private readonly host: ExitPlanRestartHost) {} + + cancel(sessionId: string): void { + this.restarts.get(sessionId)?.abort(); + } + + async restart(sessionId: string, oldSession: Session, reset: ClearContextReset): Promise { + this.cancel(sessionId); + const controller = new AbortController(); + this.restarts.set(sessionId, controller); + try { + const clearContextHost: ClearContextCoordinatorHost = { + ...this.host, + publishSessionState: async (id, mode, configOptions) => { + await this.host.sessionUpdate({ + sessionId: id, + update: { sessionUpdate: "current_mode_update", currentModeId: mode }, + }); + await this.host.sessionUpdate({ + sessionId: id, + update: { sessionUpdate: "config_option_update", configOptions }, + }); + }, + continuationMessage: (id, plan, promptUuid) => ({ + type: "user", + message: { + role: "user", + content: [{ type: "text", text: `Implement the following plan:\n\n${plan}` }], + }, + session_id: id, + parent_tool_use_id: null, + origin: { kind: "human" }, + uuid: promptUuid as `${string}-${string}-${string}-${string}-${string}`, + }), + }; + await continuePlanInFreshContext( + sessionId, + oldSession, + reset, + clearContextHost, + controller.signal, + ); + } catch (error) { + const currentSession = this.host.currentSession(sessionId); + const replacement = currentSession !== oldSession ? currentSession : undefined; + if (replacement) this.host.destroyReplacement(sessionId, replacement); + + const turn = replacement?.activeTurn ?? oldSession.activeTurn; + if (turn && !turn.settled) { + const turnSession = replacement?.activeTurn === turn ? replacement : oldSession; + if (controller.signal.aborted) { + this.host.settleCancelledTurn(oldSession, turnSession, turn); + } else { + // A provider/session-creation failure is turn-scoped. Settling it + // here keeps it out of the query consumer's transport-loss catch. + this.host.settleFailedTurn(turnSession, turn, error); + } + oldSession.activeTurn = null; + oldSession.turnQueue = (oldSession.turnQueue ?? []).filter((queued) => queued !== turn); + if (replacement) { + replacement.activeTurn = null; + replacement.turnQueue = (replacement.turnQueue ?? []).filter((queued) => queued !== turn); + } + } + oldSession.pendingExitPlanContextReset = undefined; + } finally { + if (this.restarts.get(sessionId) === controller) { + this.restarts.delete(sessionId); + } + } + } +} diff --git a/src/permissions/effects.ts b/src/permissions/effects.ts new file mode 100644 index 00000000..acc6f0d2 --- /dev/null +++ b/src/permissions/effects.ts @@ -0,0 +1,223 @@ +import type { RequestPermissionResponse } from "@agentclientprotocol/sdk"; +import type { + PermissionMode, + PermissionResult, + PermissionUpdate, +} from "@anthropic-ai/claude-agent-sdk"; +import type { DurablePermissionChangeSet } from "./normalization.js"; +import { PERMISSION_OPTION_ID } from "./options.js"; + +export interface ClaudePermissionSelection { + optionId: string; + contextResetMode?: PermissionMode; +} + +export interface ClaudePermissionEffectContext { + toolName: string; + input: Record; + toolUseID: string; + durableChangeSet?: DurablePermissionChangeSet; +} + +/** Parse the ACP envelope once before dispatching to a tool-specific effect. */ +export function parseClaudePermissionSelection( + response: RequestPermissionResponse, + toolName: string, +): ClaudePermissionSelection { + if (response.outcome?.outcome !== "selected") throw new Error("Tool use aborted"); + const optionId = response.outcome.optionId; + const contextResetMode = + toolName === "ExitPlanMode" ? exitPlanClearContextMode(optionId) : undefined; + return { optionId, ...(contextResetMode ? { contextResetMode } : {}) }; +} + +function allow( + context: ClaudePermissionEffectContext, + updatedPermissions?: PermissionUpdate[], + permanent = false, +): PermissionResult { + return { + behavior: "allow", + updatedInput: context.input, + ...(updatedPermissions ? { updatedPermissions } : {}), + toolUseID: context.toolUseID, + decisionClassification: permanent ? "user_permanent" : "user_temporary", + }; +} + +function deny( + context: ClaudePermissionEffectContext, + message = "User refused permission to run tool", + interrupt = false, +): PermissionResult { + return { + behavior: "deny", + message, + ...(interrupt ? { interrupt: true } : {}), + toolUseID: context.toolUseID, + decisionClassification: "user_reject", + }; +} + +function skillName(input: Record): string { + const value = typeof input.skill === "string" ? input.skill.trim() : ""; + if (!value || value.length > 1_000) throw new Error("Invalid Skill permission input"); + return value; +} + +function localAllowRuleUpdate(toolName: string, ruleContent?: string): PermissionUpdate[] { + return [ + { + type: "addRules", + rules: [{ toolName, ...(ruleContent === undefined ? {} : { ruleContent }) }], + behavior: "allow", + destination: "localSettings", + }, + ]; +} + +function webFetchPermissionUpdate(input: Record): PermissionUpdate[] { + if (typeof input.url !== "string") throw new Error("Invalid WebFetch permission input"); + let hostname: string; + try { + hostname = new URL(input.url).hostname; + } catch { + throw new Error("Invalid WebFetch permission input"); + } + if (!hostname) throw new Error("Invalid WebFetch permission input"); + return localAllowRuleUpdate("WebFetch", `domain:${hostname}`); +} + +function applySkillSelection( + selection: ClaudePermissionSelection, + context: ClaudePermissionEffectContext, +): PermissionResult { + switch (selection.optionId) { + case PERMISSION_OPTION_ID.allowSkillExact: { + return allow(context, localAllowRuleUpdate("Skill", skillName(context.input)), true); + } + case PERMISSION_OPTION_ID.allowSkillPrefix: { + const skill = skillName(context.input); + const spaceIndex = skill.indexOf(" "); + if (spaceIndex <= 0) throw new Error("Skill prefix permission requires arguments"); + return allow(context, localAllowRuleUpdate("Skill", `${skill.slice(0, spaceIndex)}:*`), true); + } + default: + return applyCommonSelection(selection, context); + } +} + +function exitPlanMode(optionId: string): PermissionMode | undefined { + switch (optionId) { + case PERMISSION_OPTION_ID.exitPlanBypass: + return "bypassPermissions"; + case PERMISSION_OPTION_ID.exitPlanAuto: + return "auto"; + case PERMISSION_OPTION_ID.exitPlanAcceptEdits: + return "acceptEdits"; + case PERMISSION_OPTION_ID.exitPlanDefault: + return "default"; + default: + return undefined; + } +} + +export function exitPlanClearContextMode(optionId: string): PermissionMode | undefined { + switch (optionId) { + case PERMISSION_OPTION_ID.exitPlanClearAuto: + return "auto"; + case PERMISSION_OPTION_ID.exitPlanClearBypass: + return "bypassPermissions"; + case PERMISSION_OPTION_ID.exitPlanClearAcceptEdits: + return "acceptEdits"; + default: + return undefined; + } +} + +function applyExitPlanModeSelection( + selection: ClaudePermissionSelection, + context: ClaudePermissionEffectContext, +): PermissionResult { + if (selection.contextResetMode) { + // The adapter consumes this interrupt and continues the same ACP turn on a + // fresh Claude query. Returning allow here would execute ExitPlanMode in + // the old context before the handoff. + return deny(context, "User accepted the plan and requested a fresh context", true); + } + const mode = exitPlanMode(selection.optionId); + if (mode) { + return allow(context, [{ type: "setMode", mode, destination: "session" }], mode !== "default"); + } + if (selection.optionId === PERMISSION_OPTION_ID.reject) { + // A regular deny lets Claude continue planning and ask another question. + // Interrupt stops this ACP turn; the adapter maps Claude's internal + // diagnostic for that intentional stop back to cancellation. + return deny(context, "User chose to keep planning", true); + } + return applyCommonSelection(selection, context); +} + +function applyCommonSelection( + selection: ClaudePermissionSelection, + context: ClaudePermissionEffectContext, +): PermissionResult { + switch (selection.optionId) { + case PERMISSION_OPTION_ID.allowOnce: + return allow(context); + case PERMISSION_OPTION_ID.allowWithUpdates: + if (!context.durableChangeSet) throw new Error("Invalid durable permission selection"); + return allow(context, context.durableChangeSet.updates, true); + case PERMISSION_OPTION_ID.reject: + return deny(context); + default: + throw new Error(`Unknown permission option: ${selection.optionId}`); + } +} + +function applyGeneratedDurableSelection( + selection: ClaudePermissionSelection, + context: ClaudePermissionEffectContext, + updates: () => PermissionUpdate[], +): PermissionResult { + if (selection.optionId !== PERMISSION_OPTION_ID.allowWithUpdates) { + return applyCommonSelection(selection, context); + } + return allow(context, updates(), true); +} + +/** Apply a parsed selection using the semantics of the tool that produced it. */ +export function applyClaudePermissionSelection( + selection: ClaudePermissionSelection, + context: ClaudePermissionEffectContext, +): PermissionResult { + switch (context.toolName) { + case "Skill": + return applySkillSelection(selection, context); + case "WebFetch": + return applyGeneratedDurableSelection(selection, context, () => + webFetchPermissionUpdate(context.input), + ); + case "EnterPlanMode": + return applyCommonSelection(selection, context); + case "ExitPlanMode": + return applyExitPlanModeSelection(selection, context); + case "Read": + case "Bash": + case "PowerShell": + case "Glob": + case "Grep": + case "Edit": + case "Write": + case "NotebookEdit": + case "SandboxNetworkAccess": + return applyCommonSelection(selection, context); + default: + if (context.toolName.startsWith("mcp__")) { + return applyCommonSelection(selection, context); + } + return applyGeneratedDurableSelection(selection, context, () => + localAllowRuleUpdate(context.toolName), + ); + } +} diff --git a/src/permissions/modes.ts b/src/permissions/modes.ts new file mode 100644 index 00000000..9fe9de18 --- /dev/null +++ b/src/permissions/modes.ts @@ -0,0 +1,59 @@ +import type { PermissionMode } from "@anthropic-ai/claude-agent-sdk"; + +interface PermissionModeLogger { + error: (...args: unknown[]) => void; +} + +// Bypass Permissions doesn't work if we are a root/sudo user. +const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0; +export const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX; + +const PERMISSION_MODE_ALIASES: Record = { + // Settings use user-facing, case-insensitive spellings while the SDK uses + // camel-cased wire values. Keep legacy shorthand accepted by Claude Code too. + auto: "auto", + default: "default", + // Claude Code 2.1.200 renamed the "default" mode to "Manual" and accepts + // `"defaultMode": "manual"` in settings.json; honor the same alias here. + manual: "default", + acceptedits: "acceptEdits", + dontask: "dontAsk", + plan: "plan", + bypasspermissions: "bypassPermissions", + bypass: "bypassPermissions", +}; + +export function resolvePermissionMode( + defaultMode?: unknown, + logger: PermissionModeLogger = console, +): PermissionMode { + if (defaultMode === undefined) { + return "default"; + } + + if (typeof defaultMode !== "string") { + logger.error("Ignoring permissions.defaultMode from settings: expected a string."); + return "default"; + } + + const normalized = defaultMode.trim().toLowerCase(); + if (normalized === "") { + logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string."); + return "default"; + } + + const mapped = PERMISSION_MODE_ALIASES[normalized]; + if (!mapped) { + logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`); + return "default"; + } + + if (mapped === "bypassPermissions" && !ALLOW_BYPASS) { + logger.error( + "Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.", + ); + return "default"; + } + + return mapped; +} diff --git a/src/permissions/normalization.ts b/src/permissions/normalization.ts new file mode 100644 index 00000000..a320ec03 --- /dev/null +++ b/src/permissions/normalization.ts @@ -0,0 +1,130 @@ +import type { + PermissionBehavior, + PermissionMode, + PermissionUpdate, + PermissionUpdateDestination, +} from "@anthropic-ai/claude-agent-sdk"; + +export interface DurablePermissionChangeSet { + updates: PermissionUpdate[]; +} + +const MAX_UPDATES = 100; +const MAX_RULES_PER_UPDATE = 100; +const MAX_DIRECTORIES_PER_UPDATE = 100; +const MAX_TOOL_NAME_LENGTH = 512; +const MAX_RULE_CONTENT_LENGTH = 10_000; +const MAX_DIRECTORY_LENGTH = 4_096; + +const DESTINATIONS = new Set([ + "session", + "cliArg", + "userSettings", + "projectSettings", + "localSettings", +]); +const BEHAVIORS = new Set(["allow", "deny", "ask"]); +const MODES = new Set([ + "default", + "acceptEdits", + "bypassPermissions", + "plan", + "dontAsk", + "auto", +]); + +function isRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function isDestination(value: unknown): value is PermissionUpdateDestination { + return typeof value === "string" && DESTINATIONS.has(value as PermissionUpdateDestination); +} + +function safePlainString(value: unknown, maxLength: number): value is string { + if (typeof value !== "string") return false; + const normalized = value.trim(); + return ( + normalized.length > 0 && + value.length <= maxLength && + !Array.from(normalized).some((character) => { + const code = character.charCodeAt(0); + return ( + code <= 0x08 || + code === 0x0b || + code === 0x0c || + (code >= 0x0e && code <= 0x1f) || + code === 0x7f + ); + }) + ); +} + +function isRule(value: unknown): value is { toolName: string; ruleContent?: string } { + if (!isRecord(value) || !safePlainString(value.toolName, MAX_TOOL_NAME_LENGTH)) { + return false; + } + return ( + value.ruleContent === undefined || safePlainString(value.ruleContent, MAX_RULE_CONTENT_LENGTH) + ); +} + +function isKnownUpdate(value: unknown): value is PermissionUpdate { + if (!isRecord(value) || !isDestination(value.destination) || typeof value.type !== "string") { + return false; + } + switch (value.type) { + case "addRules": + case "replaceRules": + case "removeRules": + return ( + Array.isArray(value.rules) && + value.rules.length <= MAX_RULES_PER_UPDATE && + (value.type === "replaceRules" || value.rules.length > 0) && + value.rules.every(isRule) && + typeof value.behavior === "string" && + BEHAVIORS.has(value.behavior as PermissionBehavior) + ); + case "setMode": + return typeof value.mode === "string" && MODES.has(value.mode as PermissionMode); + case "addDirectories": + case "removeDirectories": + return ( + Array.isArray(value.directories) && + value.directories.length > 0 && + value.directories.length <= MAX_DIRECTORIES_PER_UPDATE && + value.directories.every((directory) => safePlainString(directory, MAX_DIRECTORY_LENGTH)) + ); + default: + return false; + } +} + +export function normalizeDurablePermissionChangeSet( + suggestions: unknown, + forcedAsk = false, +): DurablePermissionChangeSet | undefined { + if (forcedAsk) return undefined; + try { + if ( + !Array.isArray(suggestions) || + suggestions.length === 0 || + suggestions.length > MAX_UPDATES || + !suggestions.every(isKnownUpdate) + ) { + return undefined; + } + return { + // The ACP prompt may stay open for an arbitrary amount of time. Snapshot the + // provider payload so the label the user approved and the effect returned to + // Claude cannot diverge through later mutation of the callback argument. + updates: structuredClone(suggestions as PermissionUpdate[]), + }; + } catch { + // SDK data is expected to be plain structured-cloneable input. Treat an + // accessor, function, proxy, or other non-wire value as an invalid bundle. + // Validation belongs in this boundary too because merely reading one of those + // values can throw before structuredClone gets a chance to reject it. + return undefined; + } +} diff --git a/src/permissions/options.ts b/src/permissions/options.ts new file mode 100644 index 00000000..89588a21 --- /dev/null +++ b/src/permissions/options.ts @@ -0,0 +1,86 @@ +import type { PermissionOption } from "@agentclientprotocol/sdk"; +import { + buildEditPermissionOptions, + buildGlobPermissionOptions, + buildGrepPermissionOptions, + buildNotebookEditPermissionOptions, + buildReadPermissionOptions, + buildWritePermissionOptions, +} from "./options/filesystem.js"; +import { buildBashPermissionOptions, buildPowerShellPermissionOptions } from "./options/shell.js"; +import type { PermissionOptionContext } from "./options/shared.js"; +import { + buildComputerUseMcpPermissionOptions, + buildEnterPlanModePermissionOptions, + buildExitPlanModePermissionOptions, + buildFallbackPermissionOptions, + buildSandboxNetworkPermissionOptions, + buildSkillPermissionOptions, + buildWebFetchPermissionOptions, + isComputerUseMcpTool, +} from "./options/tools.js"; + +export { PERMISSION_OPTION_ID } from "./options/shared.js"; + +export function buildClaudePermissionOptions(context: PermissionOptionContext): PermissionOption[] { + return buildUnsortedClaudePermissionOptions(context).sort( + (left, right) => permissionOptionOrder(left) - permissionOptionOrder(right), + ); +} + +function buildUnsortedClaudePermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + if (isComputerUseMcpTool(context.toolName)) { + return buildComputerUseMcpPermissionOptions(context); + } + switch (context.toolName) { + case "AskUserQuestion": + throw new Error("AskUserQuestion must be handled by ACP elicitation, not permission options"); + case "Bash": + return buildBashPermissionOptions(context); + case "PowerShell": + return buildPowerShellPermissionOptions(context); + case "Read": + return buildReadPermissionOptions(context); + case "Glob": + return buildGlobPermissionOptions(context); + case "Grep": + return buildGrepPermissionOptions(context); + case "Edit": + return buildEditPermissionOptions(context); + case "Write": + return buildWritePermissionOptions(context); + case "NotebookEdit": + return buildNotebookEditPermissionOptions(context); + case "WebFetch": + return buildWebFetchPermissionOptions(context); + case "Skill": + return buildSkillPermissionOptions(context); + case "EnterPlanMode": + return buildEnterPlanModePermissionOptions(); + case "ExitPlanMode": + return buildExitPlanModePermissionOptions(context); + case "SandboxNetworkAccess": + return buildSandboxNetworkPermissionOptions(context); + case "WebSearch": + case "Agent": + case "Task": + default: + return buildFallbackPermissionOptions(context); + } +} + +function permissionOptionOrder(option: PermissionOption): number { + switch (option.kind) { + case "allow_once": + return 0; + case "allow_always": + return 1; + case "reject_once": + case "reject_always": + return 3; + default: + return 2; + } +} diff --git a/src/permissions/options/filesystem.ts b/src/permissions/options/filesystem.ts new file mode 100644 index 00000000..112a6ddc --- /dev/null +++ b/src/permissions/options/filesystem.ts @@ -0,0 +1,186 @@ +import type { PermissionOption } from "@agentclientprotocol/sdk"; +import os from "node:os"; +import path from "node:path"; +import type { DurablePermissionChangeSet } from "../normalization.js"; +import { plainString, type PermissionOptionContext, withOptionalUpdate } from "./shared.js"; + +function isFileSessionChangeSet( + changeSet: DurablePermissionChangeSet | undefined, + operation: "read" | "write", +): boolean { + return ( + !!changeSet && + changeSet.updates.every((update) => { + if (update.destination !== "session") return false; + if (update.type === "setMode") { + return operation === "write" && update.mode === "acceptEdits"; + } + if (update.type === "addDirectories") return operation === "write"; + if (update.type !== "addRules" || update.behavior !== "allow") return false; + return update.rules.every((rule) => + operation === "read" ? rule.toolName === "Read" : rule.toolName === "Edit", + ); + }) + ); +} + +function inputPath(context: PermissionOptionContext, key: string): string | undefined { + return plainString(context.input[key]); +} + +function isInside(directory: string, candidate: string): boolean { + const relative = path.relative(path.resolve(directory), path.resolve(candidate)); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".."); +} + +function hasSessionAllowRuleFor( + changeSet: DurablePermissionChangeSet | undefined, + toolName: string, +): boolean { + return !!changeSet?.updates.some( + (update) => + update.type === "addRules" && + update.destination === "session" && + update.behavior === "allow" && + update.rules.some((rule) => rule.toolName === toolName), + ); +} + +function hasBroadSessionAllowRuleFor( + changeSet: DurablePermissionChangeSet, + toolName: string, +): boolean { + return changeSet.updates.some( + (update) => + update.type === "addRules" && + update.destination === "session" && + update.behavior === "allow" && + update.rules.some((rule) => rule.toolName === toolName && rule.ruleContent === undefined), + ); +} + +function hasAcceptEditsMode(changeSet: DurablePermissionChangeSet): boolean { + return changeSet.updates.some( + (update) => + update.type === "setMode" && + update.destination === "session" && + update.mode === "acceptEdits", + ); +} + +function permissionPath(ruleContent: string | undefined): string | undefined { + const value = plainString(ruleContent); + if (!value) return undefined; + return value.replace(/[/\\]\*\*$/, ""); +} + +function effectCoversFilePath( + changeSet: DurablePermissionChangeSet, + filePath: string, + operation: "read" | "write", + cwd: string, +): boolean { + const directory = path.dirname(filePath); + const comparable = (candidate: string): boolean => { + const resolved = path.resolve(cwd, candidate); + return resolved === directory || isInside(resolved, filePath); + }; + const paths = changeSet.updates.flatMap((update) => { + if (update.type === "addDirectories") return update.directories; + if (update.type !== "addRules" || update.behavior !== "allow") return []; + return update.rules + .filter((rule) => + operation === "read" ? rule.toolName === "Read" : rule.toolName === "Edit", + ) + .map((rule) => permissionPath(rule.ruleContent)) + .filter((value): value is string => value !== undefined); + }); + return paths.some(comparable); +} + +function fileSessionLabel( + context: PermissionOptionContext, + filePath: string | undefined, + operation: "read" | "write", +): string | undefined { + if (!isFileSessionChangeSet(context.durableChangeSet, operation)) return undefined; + const resolvedFilePath = filePath ? path.resolve(context.cwd, filePath) : undefined; + const isClaudePath = + operation === "write" && + !!resolvedFilePath && + (isInside(path.join(context.cwd, ".claude"), resolvedFilePath) || + isInside(path.join(os.homedir(), ".claude"), resolvedFilePath)); + const broadRule = hasBroadSessionAllowRuleFor( + context.durableChangeSet!, + operation === "read" ? "Read" : "Edit", + ); + if (resolvedFilePath) { + const coversCurrentPath = + effectCoversFilePath(context.durableChangeSet!, resolvedFilePath, operation, context.cwd) || + broadRule || + (operation === "write" && !isClaudePath && hasAcceptEditsMode(context.durableChangeSet!)); + if (!coversCurrentPath) return undefined; + } else if ( + !broadRule && + !(operation === "write" && hasAcceptEditsMode(context.durableChangeSet!)) + ) { + return undefined; + } + if ( + operation === "write" && + resolvedFilePath && + isClaudePath && + hasSessionAllowRuleFor(context.durableChangeSet, "Edit") + ) { + return "Yes, and allow Claude to edit its own settings for this session"; + } + if (!resolvedFilePath || isInside(context.cwd, resolvedFilePath)) { + return operation === "read" + ? "Yes, during this session" + : "Yes, allow all edits during this session"; + } + const directory = path.dirname(resolvedFilePath); + const directoryName = path.basename(directory) || "this directory"; + return operation === "read" + ? `Yes, allow reading from ${directoryName}${path.sep} during this session` + : `Yes, allow all edits in ${directoryName}${path.sep} during this session`; +} + +function buildFilePermissionOptions( + context: PermissionOptionContext, + filePath: string | undefined, + operation: "read" | "write", +): PermissionOption[] { + return withOptionalUpdate( + context.durableChangeSet, + fileSessionLabel(context, filePath, operation), + "Yes", + "No", + ); +} + +export function buildReadPermissionOptions(context: PermissionOptionContext): PermissionOption[] { + return buildFilePermissionOptions(context, inputPath(context, "file_path"), "read"); +} + +export function buildGlobPermissionOptions(context: PermissionOptionContext): PermissionOption[] { + return buildFilePermissionOptions(context, inputPath(context, "path") ?? context.cwd, "read"); +} + +export function buildGrepPermissionOptions(context: PermissionOptionContext): PermissionOption[] { + return buildFilePermissionOptions(context, inputPath(context, "path") ?? context.cwd, "read"); +} + +export function buildEditPermissionOptions(context: PermissionOptionContext): PermissionOption[] { + return buildFilePermissionOptions(context, inputPath(context, "file_path"), "write"); +} + +export function buildWritePermissionOptions(context: PermissionOptionContext): PermissionOption[] { + return buildFilePermissionOptions(context, inputPath(context, "file_path"), "write"); +} + +export function buildNotebookEditPermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + return buildFilePermissionOptions(context, inputPath(context, "notebook_path"), "write"); +} diff --git a/src/permissions/options/shared.ts b/src/permissions/options/shared.ts new file mode 100644 index 00000000..da489768 --- /dev/null +++ b/src/permissions/options/shared.ts @@ -0,0 +1,105 @@ +import type { PermissionOption } from "@agentclientprotocol/sdk"; +import type { DurablePermissionChangeSet } from "../normalization.js"; + +export const PERMISSION_OPTION_ID = { + allowOnce: "allow-once", + allowWithUpdates: "allow-with-updates", + allowSkillExact: "allow-skill-exact", + allowSkillPrefix: "allow-skill-prefix", + exitPlanBypass: "exit-plan-bypass", + exitPlanAuto: "exit-plan-auto", + exitPlanAcceptEdits: "exit-plan-accept-edits", + exitPlanDefault: "exit-plan-default", + exitPlanClearAuto: "exit-plan-clear-auto", + exitPlanClearBypass: "exit-plan-clear-bypass", + exitPlanClearAcceptEdits: "exit-plan-clear-accept-edits", + reject: "reject", +} as const; + +export interface PermissionOptionContext { + toolName: string; + displayName?: string; + input: Record; + cwd: string; + durableChangeSet?: DurablePermissionChangeSet; + allowPersistentOptions?: boolean; + availableModes?: readonly string[]; + contextUsedPercent?: number; +} + +export function allowOnce(name = "Yes"): PermissionOption { + return { optionId: PERMISSION_OPTION_ID.allowOnce, name, kind: "allow_once" }; +} + +export function allowWithUpdates(name: string): PermissionOption { + return { optionId: PERMISSION_OPTION_ID.allowWithUpdates, name, kind: "allow_always" }; +} + +export function reject(name = "No"): PermissionOption { + return { optionId: PERMISSION_OPTION_ID.reject, name, kind: "reject_once" }; +} + +export function withOptionalUpdate( + changeSet: DurablePermissionChangeSet | undefined, + updateName: string | undefined, + allowName = "Yes", + rejectName = "No", +): PermissionOption[] { + const options = [allowOnce(allowName)]; + if (changeSet && updateName) options.push(allowWithUpdates(updateName)); + options.push(reject(rejectName)); + return options; +} + +export function plainString(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} + +export function exactLocalAllowRule( + changeSet: DurablePermissionChangeSet | undefined, + toolName: string, + ruleContent?: string, +): boolean { + if (!changeSet || changeSet.updates.length !== 1) return false; + const update = changeSet.updates[0]; + if ( + update?.type !== "addRules" || + update.behavior !== "allow" || + update.destination !== "localSettings" || + update.rules.length !== 1 + ) { + return false; + } + const rule = update.rules[0]; + return ( + rule?.toolName === toolName && + (ruleContent === undefined + ? rule.ruleContent === undefined + : plainString(rule.ruleContent) === ruleContent) + ); +} + +/** + * An MCP "don't ask again" option is only truthful when every provider update + * adds an unrestricted allow rule for the tool currently being prompted. + */ +export function isMcpAllowChangeSet( + changeSet: DurablePermissionChangeSet | undefined, + toolName: string, +): boolean { + return ( + !!changeSet && + changeSet.updates.length > 0 && + changeSet.updates.every( + (update) => + update.type === "addRules" && + update.behavior === "allow" && + update.rules.length > 0 && + update.rules.every((rule) => rule.toolName === toolName && rule.ruleContent === undefined), + ) + ); +} + +export function withGeneratedUpdate(name: string, rejectName = "No"): PermissionOption[] { + return [allowOnce(), allowWithUpdates(name), reject(rejectName)]; +} diff --git a/src/permissions/options/shell.ts b/src/permissions/options/shell.ts new file mode 100644 index 00000000..9ead7463 --- /dev/null +++ b/src/permissions/options/shell.ts @@ -0,0 +1,121 @@ +import type { PermissionOption } from "@agentclientprotocol/sdk"; +import path from "node:path"; +import type { DurablePermissionChangeSet } from "../normalization.js"; +import { plainString, type PermissionOptionContext, withOptionalUpdate } from "./shared.js"; + +function permissionRulePrefix(value: string): string { + return value.endsWith(":*") ? value.slice(0, -2) : value; +} + +function displayList(values: string[]): string { + if (values.join(", ").length > 50) return "similar"; + if (values.length === 1) return values[0]!; + if (values.length === 2) return `${values[0]} and ${values[1]}`; + return `${values.slice(0, -1).join(", ")}, and ${values.at(-1)}`; +} + +/** Shorten paths only as much as needed to distinguish them in prompt text. */ +function displayPaths(paths: string[]): string { + const normalizedPaths = [...new Set(paths.map((value) => path.normalize(value)))]; + const segments = normalizedPaths.map((value) => value.split(path.sep).filter(Boolean)); + + // Compare equally deep suffixes so duplicate basenames gain the smallest + // useful parent prefix (for example, codex-acp/src/ vs claude-acp/src/). + const suffix = (index: number, depth: number): string => + segments[index]!.slice(-depth).join(path.sep) || normalizedPaths[index]!; + const names = normalizedPaths.map((value, index) => { + const parts = segments[index]!; + let name = path.basename(value) || value; + for (let depth = 1; depth <= parts.length; depth++) { + const candidate = suffix(index, depth); + if ( + normalizedPaths.every((_, other) => other === index || suffix(other, depth) !== candidate) + ) { + name = candidate; + break; + } + if (depth === parts.length) name = value; + } + return name.endsWith(path.sep) ? name : `${name}${path.sep}`; + }); + + // Native Claude labels enumerate two paths and summarize larger sets. + if (names.length <= 2) return displayList(names); + return `${names[0]}, ${names[1]} and ${names.length - 2} more`; +} + +/** Plain-text port of Claude Code's generateShellSuggestionsLabel. */ +function shellSuggestionsLabel( + toolName: "Bash" | "PowerShell", + changeSet: DurablePermissionChangeSet, +): string | undefined { + const representable = changeSet.updates.every((update) => { + if (update.type === "addDirectories") return update.destination === "session"; + if (update.type !== "addRules" || update.behavior !== "allow") return false; + return update.rules.every( + (rule) => + (rule.toolName === toolName || rule.toolName === "Read") && + plainString(rule.ruleContent) !== undefined, + ); + }); + if (!representable) return undefined; + + const rules = changeSet.updates + .filter((update) => update.type === "addRules") + .flatMap((update) => update.rules); + const readPaths = rules + .filter((rule) => rule.toolName === "Read") + .map((rule) => rule.ruleContent?.replace(/\/\*\*$/, "")) + .filter((value): value is string => !!value); + const commands = [ + ...new Set( + rules + .filter((rule) => rule.toolName === toolName && rule.ruleContent) + .map((rule) => permissionRulePrefix(rule.ruleContent!)), + ), + ]; + const directories = changeSet.updates + .filter((update) => update.type === "addDirectories") + .flatMap((update) => update.directories); + const hasPaths = readPaths.length > 0 || directories.length > 0; + + if (readPaths.length > 0 && directories.length === 0 && commands.length === 0) { + return `Yes, allow reading from ${displayPaths(readPaths)} from this project`; + } + if (directories.length > 0 && readPaths.length === 0 && commands.length === 0) { + return `Yes, and always allow access to ${displayPaths(directories)} from this project`; + } + if (commands.length > 0 && !hasPaths) { + return `Yes, and don't ask again for ${displayList(commands)} commands`; + } + if (hasPaths && commands.length === 0) { + return `Yes, and always allow access to ${displayPaths([...directories, ...readPaths])} from this project`; + } + if (hasPaths && commands.length > 0) { + const paths = [...directories, ...readPaths]; + return paths.length === 1 && commands.length === 1 + ? `Yes, and allow access to ${displayPaths(paths)} and ${displayList(commands)} commands` + : `Yes, and allow ${displayPaths(paths)} access and ${displayList(commands)} commands`; + } + return undefined; +} + +function buildShellPermissionOptions( + context: PermissionOptionContext, + toolName: "Bash" | "PowerShell", +): PermissionOption[] { + const name = context.durableChangeSet + ? shellSuggestionsLabel(toolName, context.durableChangeSet) + : undefined; + return withOptionalUpdate(context.durableChangeSet, name, "Yes", "No"); +} + +export function buildBashPermissionOptions(context: PermissionOptionContext): PermissionOption[] { + return buildShellPermissionOptions(context, "Bash"); +} + +export function buildPowerShellPermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + return buildShellPermissionOptions(context, "PowerShell"); +} diff --git a/src/permissions/options/tools.ts b/src/permissions/options/tools.ts new file mode 100644 index 00000000..4aca3cd1 --- /dev/null +++ b/src/permissions/options/tools.ts @@ -0,0 +1,170 @@ +import type { PermissionOption } from "@agentclientprotocol/sdk"; +import { + allowOnce, + exactLocalAllowRule, + isMcpAllowChangeSet, + PERMISSION_OPTION_ID, + plainString, + type PermissionOptionContext, + reject, + withGeneratedUpdate, + withOptionalUpdate, +} from "./shared.js"; + +export function buildWebFetchPermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + const url = plainString(context.input.url); + if (url && context.allowPersistentOptions !== false) { + try { + const hostname = new URL(url).hostname; + if (hostname) { + return withGeneratedUpdate(`Yes, and don't ask again for ${hostname}`); + } + } catch { + // Invalid input cannot produce Claude Code's domain-specific option. + } + } + return [allowOnce(), reject("No")]; +} + +export function buildSkillPermissionOptions(context: PermissionOptionContext): PermissionOption[] { + const skill = plainString(context.input.skill); + const options = [allowOnce()]; + if (skill && context.allowPersistentOptions !== false) { + options.push({ + optionId: PERMISSION_OPTION_ID.allowSkillExact, + name: `Yes, and don't ask again for ${skill}`, + kind: "allow_always", + }); + const spaceIndex = skill.indexOf(" "); + if (spaceIndex > 0) { + const prefix = `${skill.slice(0, spaceIndex)}:*`; + options.push({ + optionId: PERMISSION_OPTION_ID.allowSkillPrefix, + name: `Yes, and don't ask again for ${prefix} commands`, + kind: "allow_always", + }); + } + } + options.push(reject("No")); + return options; +} + +export function buildEnterPlanModePermissionOptions(): PermissionOption[] { + return [allowOnce("Yes, enter plan mode"), reject("No, start implementing now")]; +} + +export function buildExitPlanModePermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + const modes = new Set(context.availableModes); + const options: PermissionOption[] = []; + const elevatedMode = modes.has("auto") + ? "auto" + : modes.has("bypassPermissions") + ? "bypassPermissions" + : "acceptEdits"; + if (plainString(context.input.plan)) { + const usage = + context.contextUsedPercent === undefined ? "" : ` (${context.contextUsedPercent}% used)`; + if (elevatedMode === "auto") { + options.push({ + optionId: PERMISSION_OPTION_ID.exitPlanClearAuto, + name: `Yes, clear context${usage} and use auto mode`, + kind: "allow_always", + }); + } else if (elevatedMode === "bypassPermissions") { + options.push({ + optionId: PERMISSION_OPTION_ID.exitPlanClearBypass, + name: `Yes, clear context${usage} and bypass permissions`, + kind: "allow_always", + }); + } else { + options.push({ + optionId: PERMISSION_OPTION_ID.exitPlanClearAcceptEdits, + name: `Yes, clear context${usage} and auto-accept edits`, + kind: "allow_always", + }); + } + } + if (elevatedMode === "auto") { + options.push({ + optionId: PERMISSION_OPTION_ID.exitPlanAuto, + name: "Yes, and use auto mode", + kind: "allow_always", + }); + } else if (elevatedMode === "bypassPermissions") { + options.push({ + optionId: PERMISSION_OPTION_ID.exitPlanBypass, + name: "Yes, and bypass permissions", + kind: "allow_always", + }); + } else { + options.push({ + optionId: PERMISSION_OPTION_ID.exitPlanAcceptEdits, + name: "Yes, auto-accept edits", + kind: "allow_always", + }); + } + options.push({ + optionId: PERMISSION_OPTION_ID.exitPlanDefault, + name: "Yes, manually approve edits", + kind: "allow_once", + }); + options.push(reject("No, keep planning")); + return options; +} + +export function buildSandboxNetworkPermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + const host = plainString(context.input.host); + const name = + host && exactLocalAllowRule(context.durableChangeSet, context.toolName, host) + ? `Yes, and don't ask again for ${host}` + : undefined; + return withOptionalUpdate(context.durableChangeSet, name, "Yes", "No"); +} + +export function buildFallbackPermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + const toolLabel = plainString(context.displayName) ?? context.toolName; + if (context.toolName.startsWith("mcp__")) { + const changeSet = + context.allowPersistentOptions !== false && + isMcpAllowChangeSet(context.durableChangeSet, context.toolName) + ? context.durableChangeSet + : undefined; + return withOptionalUpdate( + changeSet, + changeSet ? `Yes, and don't ask again for ${toolLabel} commands` : undefined, + ); + } + if (context.allowPersistentOptions !== false) { + return withGeneratedUpdate(`Yes, and don't ask again for ${toolLabel} commands`); + } + return [allowOnce(), reject("No")]; +} + +const COMPUTER_USE_MCP_TOOL_PREFIX = "mcp__computer-use__"; + +export function isComputerUseMcpTool(toolName: string): boolean { + return toolName.startsWith(COMPUTER_USE_MCP_TOOL_PREFIX); +} + +export function buildComputerUseMcpPermissionOptions( + context: PermissionOptionContext, +): PermissionOption[] { + const toolLabel = plainString(context.displayName) ?? context.toolName; + const changeSet = + context.allowPersistentOptions !== false && + isMcpAllowChangeSet(context.durableChangeSet, context.toolName) + ? context.durableChangeSet + : undefined; + return withOptionalUpdate( + changeSet, + changeSet ? `Yes, and don't ask again for ${toolLabel}` : undefined, + ); +} diff --git a/src/permissions/presentation.ts b/src/permissions/presentation.ts new file mode 100644 index 00000000..5fb33d9a --- /dev/null +++ b/src/permissions/presentation.ts @@ -0,0 +1,112 @@ +import type { RequestPermissionRequest, ToolCallLocation } from "@agentclientprotocol/sdk"; +import { toolInfoFromToolUse } from "../tools.js"; + +export interface ClaudePermissionPresentationInput { + toolName: string; + input: Record; + toolUseID: string; + cwd?: string; + supportsTerminalOutput?: boolean; + blockedPath?: string; + title?: string; + displayName?: string; + description?: string; + decisionReason?: string; +} + +function humanText(value: unknown, maxLength: number, singleLine = false): string | undefined { + if (typeof value !== "string") return undefined; + const withoutControls = Array.from(value) + .filter((character) => { + const code = character.charCodeAt(0); + return !( + code <= 0x08 || + code === 0x0b || + code === 0x0c || + (code >= 0x0e && code <= 0x1f) || + code === 0x7f + ); + }) + .join(""); + const normalized = singleLine + ? withoutControls.replace(/\s+/gu, " ").trim() + : withoutControls.trim(); + return normalized && normalized.length <= maxLength ? normalized : undefined; +} + +function compactText(value: unknown): string | undefined { + return humanText(value, 160, true); +} + +function withBlockedPath( + locations: ToolCallLocation[] | undefined, + blockedPath: unknown, +): ToolCallLocation[] | undefined { + const path = humanText(blockedPath, 4_096, true); + if (!path) return locations; + const result = [...(locations ?? [])]; + if (!result.some((location) => location.path === path)) result.push({ path }); + return result; +} + +export function buildClaudePermissionPresentation( + value: ClaudePermissionPresentationInput, +): Pick { + const info = toolInfoFromToolUse( + { id: value.toolUseID, name: value.toolName, input: value.input }, + value.supportsTerminalOutput ?? false, + value.cwd, + ); + const host = + value.toolName === "SandboxNetworkAccess" ? compactText(value.input.host) : undefined; + const isComputerUse = value.toolName.startsWith("mcp__computer-use__"); + const subjectTitle = host ?? (isComputerUse ? compactText(value.displayName) : undefined); + const subjectContent = + (host || isComputerUse) && info.content.length === 0 + ? [ + { + type: "content" as const, + content: { + type: "text" as const, + text: `\`\`\`json\n${JSON.stringify(value.input, null, 2)}\n\`\`\``, + }, + }, + ] + : info.content; + // Reuse the exact standard tool-call heading as the permission heading so + // the approval never maintains a second, divergent name for the operation. + // decisionReason is temporarily exposed as the permission description so + // its actual SDK values can be inspected; it remains diagnostic policy text. + const shellTitle = + value.toolName === "Bash" || value.toolName === "PowerShell" + ? (compactText(value.input.description) ?? value.toolName) + : undefined; + const toolCallTitle = shellTitle ?? subjectTitle ?? info.title; + const permissionTitle = value.toolName === "ExitPlanMode" ? "Ready to code?" : toolCallTitle; + const title = humanText(permissionTitle, 4_000, true) ?? "Use tool?"; + const decisionReason = humanText(value.decisionReason, 4_000); + const description = decisionReason ? `Reason: ${decisionReason}` : undefined; + return { + toolCall: { + toolCallId: value.toolUseID, + name: value.toolName, + status: "pending", + rawInput: value.input, + ...info, + title: toolCallTitle, + content: subjectContent, + locations: withBlockedPath(info.locations, value.blockedPath), + }, + ...(title + ? { + _meta: { + permission: { + version: 1, + title, + ...(description ? { description } : {}), + }, + }, + } + : {}), + }; +} diff --git a/src/permissions/response.ts b/src/permissions/response.ts new file mode 100644 index 00000000..74d02776 --- /dev/null +++ b/src/permissions/response.ts @@ -0,0 +1,36 @@ +import type { RequestPermissionResponse } from "@agentclientprotocol/sdk"; +import type { PermissionOption } from "@agentclientprotocol/sdk"; +import type { PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk"; +import type { DurablePermissionChangeSet } from "./normalization.js"; +import { applyClaudePermissionSelection, parseClaudePermissionSelection } from "./effects.js"; + +export interface ClaudePermissionDecision { + permissionResult: PermissionResult; + contextResetMode?: PermissionMode; +} + +/** Decode, validate, and interpret an ACP response exactly once. */ +export function decodeClaudePermissionResponse( + response: RequestPermissionResponse, + toolName: string, + input: Record, + toolUseID: string, + offeredOptions: readonly PermissionOption[], + durableChangeSet?: DurablePermissionChangeSet, +): ClaudePermissionDecision { + const selection = parseClaudePermissionSelection(response, toolName); + const offeredOption = offeredOptions.find((option) => option.optionId === selection.optionId); + if (!offeredOption) { + throw new Error(`Permission option was not offered: ${selection.optionId}`); + } + const permissionResult = applyClaudePermissionSelection(selection, { + toolName, + input, + toolUseID, + durableChangeSet, + }); + return { + permissionResult, + ...(selection.contextResetMode ? { contextResetMode: selection.contextResetMode } : {}), + }; +} diff --git a/src/session-config-ids.ts b/src/session-config-ids.ts new file mode 100644 index 00000000..fa24b42f --- /dev/null +++ b/src/session-config-ids.ts @@ -0,0 +1,4 @@ +/** Shared sentinels and ids for session preferences managed by the adapter. */ +export const DEFAULT_MODEL_ID = "default"; +export const DEFAULT_AGENT_ID = "default"; +export const EFFORT_CONFIG_ID = "effort"; diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index 06648599..d9b95927 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -94,7 +94,7 @@ describe("ClaudeAcpAgent settings", () => { expect(getCapturedOptions().permissionMode).toBe("dontAsk"); expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]); - expect(response.modes.currentModeId).toBe("dontAsk"); + expect(response.modes.currentModeId).toBe("default"); }); it("supports acceptEdits mode defaults", async () => { @@ -277,9 +277,8 @@ describe("ClaudeAcpAgent settings", () => { const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id); expect(modeIds).not.toContain("auto"); - expect(modeIds).toEqual( - expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]), - ); + expect(modeIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"])); + expect(modeIds).not.toContain("dontAsk"); }); it("includes `auto` when the resolved model has supportsAutoMode: true", async () => { @@ -305,7 +304,37 @@ describe("ClaudeAcpAgent settings", () => { }); const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id); - expect(modeIds).toContain("auto"); + expect(response.modes.availableModes.slice(0, 4)).toEqual([ + { + id: "default", + name: "Manual", + description: "Always ask before making changes", + }, + { + id: "acceptEdits", + name: "Accept edits", + description: "Automatically accept all file edits", + }, + { + id: "plan", + name: "Plan", + description: "Create a plan before making changes", + }, + { + id: "auto", + name: "Auto", + description: "Claude handles permission decisions", + }, + ]); + const bypass = response.modes.availableModes[4]; + if (bypass) { + expect(bypass).toEqual({ + id: "bypassPermissions", + name: "Bypass permissions", + description: "Accepts all permissions", + }); + } + expect(modeIds).not.toContain("dontAsk"); }); it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => { diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 47b5fed1..26a043fd 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -51,6 +51,7 @@ import { deleteSession, getSessionInfo, getSessionMessages, + PermissionUpdate, query, SDKAssistantMessage, } from "@anthropic-ai/claude-agent-sdk"; @@ -2508,6 +2509,141 @@ describe("permission request cancellation", () => { await expect(pending).rejects.toThrow("Tool use aborted"); }); + it("does not wait for a permission client that ignores cancellation", async () => { + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: () => new Promise(() => {}), + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + session.emittedToolCalls.add("tool-1"); + + const controller = new AbortController(); + const pending = agent.canUseTool("session-1")("Bash", { command: "ls" }, { + signal: controller.signal, + suggestions: [], + toolUseID: "tool-1", + } as any); + await Promise.resolve(); + controller.abort(); + + await expect(pending).rejects.toThrow("Tool use aborted"); + }); + + it("observes a client rejection when the signal aborts during request creation", async () => { + const controller = new AbortController(); + const clientOperation = Promise.reject(new Error("Request cancelled")); + const then = vi.spyOn(clientOperation, "then"); + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: () => { + controller.abort(); + return clientOperation; + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + session.emittedToolCalls.add("tool-1"); + + await expect( + agent.canUseTool("session-1")("Bash", { command: "ls" }, { + signal: controller.signal, + suggestions: [], + toolUseID: "tool-1", + } as any), + ).rejects.toThrow("Tool use aborted"); + + expect(then).toHaveBeenCalledOnce(); + }); + + it("does not wait for an eager tool-call update that ignores cancellation", async () => { + const requestPermission = vi.fn(); + const mockClient = { + sessionUpdate: () => new Promise(() => {}), + requestPermission, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + const controller = new AbortController(); + + const pending = agent.canUseTool("session-1")("Bash", { command: "ls" }, { + signal: controller.signal, + suggestions: [], + toolUseID: "tool-1", + } as any); + await Promise.resolve(); + controller.abort(); + + await expect(pending).rejects.toThrow("Tool use aborted"); + expect(requestPermission).not.toHaveBeenCalled(); + expect(session.emittedToolCalls.has("tool-1")).toBe(false); + }); + + it("clears pending ExitPlanMode state even after the query has closed", async () => { + const mockClient = { sessionUpdate: vi.fn() } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + session.queryClosed = true; + session.pendingExitPlanModeInterruption = { + toolUseId: "tool-plan", + toolResultSeen: false, + }; + session.pendingExitPlanContextReset = { + toolUseId: "tool-plan", + plan: "Ship it", + mode: "auto", + }; + + await agent.cancel({ sessionId: "session-1" }); + + expect(session.cancelled).toBe(true); + expect(session.pendingExitPlanModeInterruption).toBeUndefined(); + expect(session.pendingExitPlanContextReset).toBeUndefined(); + }); + + it("does not emit or request permission for a pre-aborted tool call", async () => { + const sessionUpdate = vi.fn(); + const requestPermission = vi.fn(); + const mockClient = { sessionUpdate, requestPermission } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + const controller = new AbortController(); + controller.abort(); + + await expect( + agent.canUseTool("session-1")("Bash", { command: "ls" }, { + signal: controller.signal, + suggestions: [], + toolUseID: "tool-1", + } as any), + ).rejects.toThrow("Tool use aborted"); + expect(sessionUpdate).not.toHaveBeenCalled(); + expect(requestPermission).not.toHaveBeenCalled(); + expect(session.emittedToolCalls.has("tool-1")).toBe(false); + }); + + it("allows a streamed tool call to retry after eager emission fails", async () => { + const requestPermission = vi.fn(); + const mockClient = { + sessionUpdate: async () => { + throw new Error("transport failed"); + }, + requestPermission, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + + await expect( + agent.canUseTool("session-1")("Bash", { command: "ls" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-1", + } as any), + ).rejects.toThrow("transport failed"); + expect(requestPermission).not.toHaveBeenCalled(); + expect(session.emittedToolCalls.has("tool-1")).toBe(false); + }); + it("treats a cancelled permission outcome as an aborted tool use", async () => { const mockClient = { sessionUpdate: async () => {}, @@ -2525,7 +2661,7 @@ describe("permission request cancellation", () => { ).rejects.toThrow("Tool use aborted"); }); - it("offers the approval actions in deny, once, always order with human-readable labels", async () => { + it("omits the durable action when Claude supplies no permission update", async () => { let request: RequestPermissionRequest | undefined; const mockClient = { sessionUpdate: async () => {}, @@ -2544,32 +2680,224 @@ describe("permission request cancellation", () => { } as any); expect(request?.options).toEqual([ - { kind: "reject_once", name: "Deny", optionId: "reject" }, - { kind: "allow_once", name: "Allow Once", optionId: "allow" }, - { - kind: "allow_always", - name: "Always Allow", - optionId: "allow_always", - _meta: { - permission: { - version: 1, - changes: [ - { - type: "policy_rule", - operation: "add", - ruleBehavior: "allow", - description: "Allow all Bash calls", - lifetime: { scope: "session" }, - targets: [{ type: "tool", toolName: "Bash" }], - }, - ], - }, - }, + { kind: "allow_once", name: "Yes", optionId: "allow-once" }, + { kind: "reject_once", name: "No", optionId: "reject" }, + ]); + expect(request?._meta).toEqual({ + permission: { version: 1, title: "Bash" }, + }); + }); + + it("maps explicit reject to deny while retaining Claude classification", async () => { + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: async () => ({ outcome: { outcome: "selected", optionId: "reject" } }), + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + injectSession(agent, "session-1"); + + await expect( + agent.canUseTool("session-1")("Bash", { command: "ls" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-1", + } as any), + ).resolves.toEqual({ + behavior: "deny", + message: "User refused permission to run tool", + toolUseID: "tool-1", + decisionClassification: "user_reject", + }); + }); + + it("interrupts the turn after an ExitPlanMode keep-planning rejection", async () => { + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: async () => ({ outcome: { outcome: "selected", optionId: "reject" } }), + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + + await expect( + agent.canUseTool("session-1")("ExitPlanMode", { plan: "Implement it" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-plan", + } as any), + ).resolves.toEqual({ + behavior: "deny", + message: "User chose to keep planning", + interrupt: true, + toolUseID: "tool-plan", + decisionClassification: "user_reject", + }); + expect(session.pendingExitPlanModeInterruption).toEqual({ + toolUseId: "tool-plan", + toolResultSeen: false, + }); + }); + + it("offers ExitPlanMode clear-context with measured usage and records the handoff", async () => { + let request: RequestPermissionRequest | undefined; + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: async (value: RequestPermissionRequest) => { + request = value; + return { + outcome: { outcome: "selected", optionId: "exit-plan-clear-auto" }, + } as RequestPermissionResponse; }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + session.modes.availableModes = [ + { id: "default", name: "Manual" }, + { id: "acceptEdits", name: "Accept edits" }, + { id: "auto", name: "Auto" }, + ]; + session.contextUsedTokens = 150_000; + session.contextWindowSize = 200_000; + + await expect( + agent.canUseTool("session-1")("ExitPlanMode", { plan: "Implement it" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-plan", + } as any), + ).resolves.toMatchObject({ behavior: "deny", interrupt: true }); + + expect(request?.options).toContainEqual({ + optionId: "exit-plan-clear-auto", + name: "Yes, clear context (75% used) and use auto mode", + kind: "allow_always", + }); + expect(session.pendingExitPlanContextReset).toEqual({ + toolUseId: "tool-plan", + plan: "Implement it", + mode: "auto", + }); + }); + + it("maps Claude presentation fields without reconstructing provider text or changing input", async () => { + let request: RequestPermissionRequest | undefined; + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: async (value: RequestPermissionRequest) => { + request = value; + return { outcome: { outcome: "selected", optionId: "allow-once" } }; + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + injectSession(agent, "session-1"); + const input = { file_path: "/outside/a.ts" }; + + const result = await agent.canUseTool("session-1")("Read", input, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-1", + requestId: "claude-request-1", + title: "Claude wants to read /outside/a.ts", + displayName: "Read file", + description: "Read a.ts", + decisionReason: "Needed to inspect the dependency.", + blockedPath: "/outside/a.ts", + }); + + expect(request?._meta).toEqual({ + permission: { + version: 1, + title: "Read /outside/a.ts", + description: "Reason: Needed to inspect the dependency.", + }, + }); + expect(request?.toolCall).toMatchObject({ + kind: "read", + rawInput: input, + locations: [{ path: "/outside/a.ts", line: 1 }], + }); + expect(request?.toolCall.rawInput).toBe(input); + expect(result?.behavior === "allow" && result.updatedInput).toBe(input); + }); + + it("keeps concurrent permission requests independent even for the same tool call", async () => { + const requests: RequestPermissionRequest[] = []; + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: async (request: RequestPermissionRequest) => { + requests.push(request); + return { outcome: { outcome: "selected", optionId: "allow-once" } }; + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + const session = injectSession(agent, "session-1"); + session.emittedToolCalls.add("tool-shared"); + + const canUseTool = agent.canUseTool("session-1"); + await Promise.all([ + canUseTool("Bash", { command: "one" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-shared", + } as any), + canUseTool("Bash", { command: "two" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-shared", + } as any), + ]); + + expect(requests).toHaveLength(2); + expect(requests.map((request) => request.toolCall.rawInput)).toEqual([ + { command: "one" }, + { command: "two" }, ]); }); - it("attaches structured permission changes to the always-allow ACP option", async () => { + it("applies the exact representable provider updates described by the selected option", async () => { + let request: RequestPermissionRequest | undefined; + const updates: PermissionUpdate[] = [ + { + type: "addRules", + rules: [{ toolName: "Bash", ruleContent: "npm test:*" }], + behavior: "allow", + destination: "session", + }, + { + type: "addDirectories", + directories: ["/tmp/work"], + destination: "session", + }, + ]; + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: async (params: RequestPermissionRequest) => { + request = params; + return { outcome: { outcome: "selected", optionId: "allow-with-updates" } }; + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + injectSession(agent, "session-1"); + + const result = await agent.canUseTool("session-1")("Bash", { command: "npm test" }, { + signal: new AbortController().signal, + suggestions: updates, + toolUseID: "tool-1", + } as any); + + expect(result?.behavior).toBe("allow"); + if (!result || result.behavior !== "allow") throw new Error("expected allow result"); + expect(result.updatedPermissions).toEqual(updates); + const option = request?.options.find( + (candidate) => candidate.optionId === "allow-with-updates", + ); + expect(option).toEqual({ + kind: "allow_always", + name: "Yes, and allow access to work/ and npm test commands", + optionId: "allow-with-updates", + }); + }); + + it("keeps provider permission effects inside the adapter", async () => { let request: RequestPermissionRequest | undefined; const mockClient = { sessionUpdate: async () => {}, @@ -2599,47 +2927,12 @@ describe("permission request cancellation", () => { toolUseID: "tool-1", } as any); - expect(request?.options.find((option) => option.optionId === "allow_always")?._meta).toEqual({ - permission: { - version: 1, - changes: [ - { - type: "policy_rule", - operation: "add", - ruleBehavior: "allow", - description: "Allow Bash calls matching npm test:*", - lifetime: { scope: "session" }, - targets: [ - { - type: "tool", - toolName: "Bash", - matcher: { - type: "provider_rule", - provider: "claudeCode", - value: "npm test:*", - }, - }, - ], - }, - { - type: "policy_rule", - operation: "add", - ruleBehavior: "allow", - description: "Allow filesystem access under /tmp/work", - lifetime: { scope: "session" }, - targets: [ - { - type: "filesystem", - matcher: { type: "directory", path: "/tmp/work" }, - }, - ], - }, - ], - }, - }); + expect( + request?.options.find((option) => option.optionId === "allow-with-updates")?._meta, + ).toBeUndefined(); }); - it("preserves replace, remove, deny, destination, and mode suggestion semantics", async () => { + it("does not expose an update bundle that Claude's Bash dialog cannot label", async () => { let request: RequestPermissionRequest | undefined; const mockClient = { sessionUpdate: async () => {}, @@ -2674,50 +2967,7 @@ describe("permission request cancellation", () => { toolUseID: "tool-1", } as any); - expect( - (request?.options.find((option) => option.optionId === "allow_always")?._meta as any) - ?.permission.changes, - ).toEqual([ - { - type: "policy_rule", - operation: "replace", - ruleBehavior: "deny", - description: "Replace deny rules with Bash calls matching rm generated.txt", - lifetime: { scope: "persistent", storage: "project" }, - targets: [ - { - type: "tool", - toolName: "Bash", - matcher: { - type: "provider_rule", - provider: "claudeCode", - value: "rm generated.txt", - }, - }, - ], - }, - { - type: "policy_rule", - operation: "remove", - ruleBehavior: "allow", - description: "Remove additional filesystem access under /tmp/old", - lifetime: { scope: "persistent", storage: "project_local" }, - targets: [ - { - type: "filesystem", - matcher: { type: "directory", path: "/tmp/old" }, - }, - ], - }, - { - type: "permission_mode", - operation: "set", - provider: "claudeCode", - mode: "default", - description: "Set Claude Code permission mode to default", - lifetime: { scope: "session" }, - }, - ]); + expect(request?.options.map((option) => option.optionId)).toEqual(["allow-once", "reject"]); }); }); @@ -2737,7 +2987,7 @@ describe("tool_call emitted before permission request", () => { }, requestPermission: async () => { events.push("permission"); - return { outcome: { outcome: "selected", optionId: "allow" } }; + return { outcome: { outcome: "selected", optionId: "allow-once" } }; }, } as unknown as AcpClient; const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); @@ -3021,7 +3271,7 @@ describe("canUseTool in bypassPermissions mode", () => { sessionUpdate: async () => {}, requestPermission: async () => { events.push("permission"); - return { outcome: { outcome: "selected", optionId: "allow" } }; + return { outcome: { outcome: "selected", optionId: "allow-once" } }; }, } as unknown as AcpClient; const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); @@ -3034,17 +3284,18 @@ describe("canUseTool in bypassPermissions mode", () => { return { agent, events }; } - it("auto-allows asks that carry no matchedAskRule", async () => { + it("prompts for asks that survive Claude Code's bypass evaluation", async () => { const { agent, events } = setup(); const result = await agent.canUseTool("session-1")("Bash", { command: "ls" }, { signal: new AbortController().signal, suggestions: [], toolUseID: "tool-1", + decisionReason: "This action requires interactive safety approval.", } as any); - expect(events).toEqual([]); - expect(result).toMatchObject({ behavior: "allow" }); + expect(events).toEqual(["permission"]); + expect(result).toMatchObject({ behavior: "allow", toolUseID: "tool-1" }); }); // The asks that still reach canUseTool in bypass mode are the ones the CLI @@ -3069,6 +3320,35 @@ describe("canUseTool in bypassPermissions mode", () => { expect(events).toEqual(["permission"]); expect(result).toMatchObject({ behavior: "allow" }); }); + + it("does not promise an ineffective always-allow fallback for a forced ask rule", async () => { + let request: RequestPermissionRequest | undefined; + const mockClient = { + sessionUpdate: async () => {}, + requestPermission: async (params: RequestPermissionRequest) => { + request = params; + return { outcome: { outcome: "selected", optionId: "allow-once" } }; + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + agent.sessions["session-1"] = mockSessionState({ + modes: { currentModeId: "bypassPermissions", availableModes: [] }, + }); + agent.sessions["session-1"]!.emittedToolCalls.add("tool-1"); + + await agent.canUseTool("session-1")("Bash", { command: "terraform destroy" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "tool-1", + matchedAskRule: { + source: "projectSettings", + toolName: "Bash", + ruleContent: "Bash(terraform:*)", + }, + } as any); + + expect(request?.options.map((option) => option.optionId)).toEqual(["allow-once", "reject"]); + }); }); describe("subagent permission attribution (issue #851)", () => { @@ -3088,7 +3368,7 @@ describe("subagent permission attribution (issue #851)", () => { }, requestPermission: async (params: RequestPermissionRequest) => { requests.push(params); - return { outcome: { outcome: "selected", optionId: "allow" } }; + return { outcome: { outcome: "selected", optionId: "allow-once" } }; }, } as unknown as AcpClient; const agent = new ClaudeAcpAgent(mockClient, { log, error: () => {} }); @@ -3115,9 +3395,8 @@ describe("subagent permission attribution (issue #851)", () => { toolCallId: "toolu_sub", _meta: { claudeCode: { parentToolUseId: "toolu_parent" } }, }); - // The request's claudeCode meta keeps the shape every other claudeCode - // meta has (toolName is required by ToolUpdateMeta). - expect(requests[0].toolCall._meta).toMatchObject({ + // Keep the stable claudeCode metadata shape while adding the parent link. + expect(requests[0].toolCall._meta).toEqual({ claudeCode: { toolName: "Bash", parentToolUseId: "toolu_parent" }, }); }); @@ -11777,6 +12056,18 @@ describe("turn steering (_session/steering)", () => { }; } + function createExecutionErrorResult(errors: string[]) { + const base: Partial> = { ...createResultMessage() }; + delete base.result; + return { + ...base, + subtype: "error_during_execution" as const, + stop_reason: null, + is_error: true, + errors, + }; + } + // A minimal SDK assistant message carrying a single text block. Used by the // promptRequired retry test to model the continuation turn's streamed reply. function createAssistantText(text: string) { @@ -12511,6 +12802,336 @@ describe("turn steering (_session/steering)", () => { await expect(turn).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" })); }); + it("maps the correlated ExitPlanMode interrupt diagnostic to cancellation", async () => { + const updates: any[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (notification: any) => updates.push(notification), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + injectGeneratorSession(agent, (input) => { + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const original = await iter.next(); + yield userEcho(original.value); + yield { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-plan", + name: "ExitPlanMode", + input: { plan: "Implement it" }, + }, + ], + usage: {}, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-plan", + content: "User chose to keep planning", + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + tool_result_meta: [{ id: "tool-plan", non_execution_kind: "user-rejected" }], + }; + // An interrupted Claude cycle is error-shaped. The causal stop reason + // is present only in errors; SDKResultError has no result property. + yield createExecutionErrorResult([ + "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use", + ]); + yield idleMessage(); + } + return messageGenerator(); + }); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] }), + ).resolves.toEqual(expect.objectContaining({ stopReason: "cancelled" })); + expect(JSON.stringify(updates)).not.toContain("sessionFailure"); + expect(agent.sessions["test-session"].pendingExitPlanModeInterruption).toBeUndefined(); + }); + + it("continues an accepted plan in a fresh private query without exposing the internal reject", async () => { + const updates: any[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (notification: any) => updates.push(notification), + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + let continuation: unknown; + const createSession = vi + .spyOn(agent as any, "createSession") + .mockImplementation(async (_params: any, options: any) => { + const input = new Pushable(); + async function* freshGenerator() { + const next = await input[Symbol.asyncIterator]().next(); + continuation = next.value; + yield userEcho(next.value); + yield createAssistantText("Implemented"); + yield createResultMessage(); + yield idleMessage(); + } + agent.sessions["test-session"] = mockSessionState({ + query: wrapQuery(freshGenerator()), + input, + modes: { currentModeId: options.permissionMode, availableModes: [] }, + fastModeEnabled: false, + }); + return { sessionId: "test-session" }; + }); + + injectGeneratorSession( + agent, + (input) => { + async function* oldGenerator() { + const iter = input[Symbol.asyncIterator](); + const original = await iter.next(); + yield userEcho(original.value); + yield { + type: "assistant", + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "tool-plan", + name: "ExitPlanMode", + input: { plan: "Implement it" }, + }, + ], + usage: {}, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + }; + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-plan", + content: "User accepted the plan and requested a fresh context", + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + tool_result_meta: [{ id: "tool-plan", non_execution_kind: "user-rejected" }], + }; + yield createExecutionErrorResult([ + "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use", + ]); + } + return oldGenerator(); + }, + { + creationParams: { cwd: "/test", mcpServers: [] }, + pendingExitPlanModeInterruption: { + toolUseId: "tool-plan", + toolResultSeen: false, + }, + pendingExitPlanContextReset: { + toolUseId: "tool-plan", + plan: "Implement it", + mode: "auto", + }, + }, + ); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] }), + ).resolves.toEqual(expect.objectContaining({ stopReason: "end_turn" })); + + expect(createSession).toHaveBeenCalledWith( + expect.objectContaining({ cwd: "/test" }), + expect.objectContaining({ publicSessionId: "test-session", permissionMode: "auto" }), + ); + expect(JSON.stringify(continuation)).toContain( + "Implement the following plan:\\n\\nImplement it", + ); + expect(updates).toContainEqual( + expect.objectContaining({ + update: expect.objectContaining({ + sessionUpdate: "tool_call_update", + toolCallId: "tool-plan", + status: "completed", + }), + }), + ); + expect(JSON.stringify(updates)).not.toContain('"status":"failed"'); + expect(JSON.stringify(updates)).not.toContain("sessionFailure"); + }); + + it("rejects the pending turn if a fresh clear-context query cannot be created", async () => { + const agent = createMockAgent(); + vi.spyOn(agent as any, "createSession").mockRejectedValue(new Error("restart failed")); + injectGeneratorSession( + agent, + (input) => { + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const original = await iter.next(); + yield userEcho(original.value); + yield { + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "tool-plan", + content: "User accepted the plan and requested a fresh context", + is_error: true, + }, + ], + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + }; + yield createExecutionErrorResult([ + "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use", + ]); + } + return messageGenerator(); + }, + { + creationParams: { cwd: "/test", mcpServers: [] }, + pendingExitPlanModeInterruption: { + toolUseId: "tool-plan", + toolResultSeen: true, + }, + pendingExitPlanContextReset: { + toolUseId: "tool-plan", + plan: "Implement it", + mode: "auto", + }, + }, + ); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] }), + ).rejects.toThrow("restart failed"); + }); + + it("does not resurrect a clear-context session closed while its replacement is created", async () => { + const agent = createMockAgent(); + const resolveTurn = vi.fn(); + const rejectTurn = vi.fn(); + const turn = { + promptUuid: randomUUID(), + isLocalOnlyCommand: false, + settled: false, + resolve: resolveTurn, + reject: rejectTurn, + }; + const oldSession = mockSessionState({ + query: wrapQuery( + (async function* () { + yield await new Promise(() => {}); + })(), + ), + input: new Pushable(), + activeTurn: turn, + turnQueue: [turn], + creationParams: { cwd: "/test", mcpServers: [] }, + }); + agent.sessions["test-session"] = oldSession; + let finishCreation!: () => void; + let freshSession: ReturnType | undefined; + let freshInputPush: ReturnType | undefined; + vi.spyOn(agent as any, "createSession").mockImplementation( + () => + new Promise((resolve) => { + finishCreation = () => { + const freshInput = new Pushable(); + freshInputPush = vi.spyOn(freshInput, "push"); + freshSession = mockSessionState({ + query: wrapQuery( + (async function* () { + yield await new Promise(() => {}); + })(), + ), + input: freshInput, + modes: { currentModeId: "auto", availableModes: [] }, + }); + agent.sessions["test-session"] = freshSession; + resolve(); + }; + }), + ); + + const restart = (agent as any).exitPlan.restart("test-session", oldSession, { + toolUseId: "tool-plan", + plan: "Ship it", + mode: "auto", + }); + await vi.waitFor(() => expect(oldSession.query.close).toHaveBeenCalled()); + + await agent.closeSession({ sessionId: "test-session" }); + finishCreation(); + await expect(restart).resolves.toBeUndefined(); + + expect(agent.sessions["test-session"]).toBeUndefined(); + expect(freshSession?.query.close).toHaveBeenCalled(); + expect(freshInputPush).not.toHaveBeenCalled(); + expect(resolveTurn).toHaveBeenCalledWith(expect.objectContaining({ stopReason: "cancelled" })); + expect(rejectTurn).not.toHaveBeenCalled(); + }); + + it("does not hide a diagnostic before the rejected ExitPlanMode tool result", async () => { + const agent = createMockAgent(); + injectGeneratorSession( + agent, + (input) => { + async function* messageGenerator() { + const iter = input[Symbol.asyncIterator](); + const original = await iter.next(); + yield userEcho(original.value); + yield createExecutionErrorResult([ + "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use", + ]); + yield idleMessage(); + } + return messageGenerator(); + }, + { + pendingExitPlanModeInterruption: { + toolUseId: "tool-plan", + toolResultSeen: false, + }, + }, + ); + + await expect( + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] }), + ).rejects.toThrow("[ede_diagnostic]"); + expect(agent.sessions["test-session"].pendingExitPlanModeInterruption).toBeUndefined(); + }); + // The other steer ordering: the cycle had already finished when the steer // landed, so nothing was aborted and its result is a natural one — followed by // its own idle, before the steered message is picked up. Settling on that idle @@ -13525,7 +14146,7 @@ describe("streamEventToAcpNotifications", () => { type: "server_tool_use" as const, partialJson: '{"query":"ACP tools","allowed_domains":["agentclientprotocol.com","github.com"],"blocked_domains":', - title: '"ACP tools" (allowed: agentclientprotocol.com, github.com)', + title: 'Search "ACP tools"', rawInput: { query: "ACP tools", allowed_domains: ["agentclientprotocol.com", "github.com"], diff --git a/src/tests/clear-context-coordinator.test.ts b/src/tests/clear-context-coordinator.test.ts new file mode 100644 index 00000000..63081ee7 --- /dev/null +++ b/src/tests/clear-context-coordinator.test.ts @@ -0,0 +1,386 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ClearContextCoordinatorHost, + ClearContextSession, + ClearContextTurn, + continuePlanInFreshContext, +} from "../clear-context-coordinator.js"; +import { ExitPlanCoordinator, observeExitPlanToolResults } from "../exit-plan.js"; + +type TestSession = ClearContextSession; + +function testSession(overrides: Partial = {}): TestSession { + return { + cwd: "/workspace", + accumulatedUsage: { + inputTokens: 10, + outputTokens: 20, + cachedReadTokens: 30, + cachedWriteTokens: 40, + }, + models: { currentModelId: "default" }, + configOptions: [], + currentAgent: "default", + fastModeEnabled: false, + input: { push: vi.fn() }, + ...overrides, + }; +} + +function testHost( + oldSession: TestSession, + freshSession: TestSession, +): ClearContextCoordinatorHost { + let currentSession = oldSession; + return { + currentSession: vi.fn(() => currentSession), + closeQueryStream: vi.fn(), + restartSession: vi.fn(async () => { + currentSession = freshSession; + return freshSession; + }), + applyFastMode: vi.fn(async () => {}), + publishSessionState: vi.fn(async () => {}), + continuationMessage: vi.fn((sessionId, plan, promptUuid) => ({ + type: "user" as const, + message: { + role: "user" as const, + content: [{ type: "text" as const, text: `Implement the following plan:\n\n${plan}` }], + }, + session_id: sessionId, + parent_tool_use_id: null, + origin: { kind: "human" as const }, + uuid: promptUuid as `${string}-${string}-${string}-${string}-${string}`, + })), + ensureConsumer: vi.fn(), + logError: vi.fn(), + }; +} + +describe("continuePlanInFreshContext", () => { + it("moves the pending ACP turn and its session preferences to a fresh query", async () => { + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const reset = { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" as const }; + const oldSession = testSession({ + activeTurn: turn, + turnQueue: [turn], + pendingExitPlanContextReset: reset, + models: { currentModelId: "claude-sonnet" }, + currentAgent: "reviewer", + fastModeEnabled: true, + configOptions: [ + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "high", + options: [{ value: "high", name: "High" }], + }, + ], + creationParams: { + cwd: "/workspace", + mcpServers: [], + _meta: { caller: "test", claudeCode: { options: { env: { PRESERVED: "yes" } } } }, + }, + }); + const freshSession = testSession(); + const host = testHost(oldSession, freshSession); + + await continuePlanInFreshContext("public-session", oldSession, reset, host); + + expect(host.restartSession).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: "/workspace", + _meta: expect.objectContaining({ + caller: "test", + claudeCode: { + options: expect.objectContaining({ + env: { PRESERVED: "yes" }, + model: "claude-sonnet", + agent: "reviewer", + effort: "high", + }), + }, + }), + }), + { publicSessionId: "public-session", permissionMode: "auto" }, + ); + expect(turn.carriedUsage).toEqual(oldSession.accumulatedUsage); + expect(turn.carriedUsage).not.toBe(oldSession.accumulatedUsage); + expect(oldSession.pendingExitPlanContextReset).toBeUndefined(); + expect(oldSession.activeTurn).toBeNull(); + expect(oldSession.turnQueue).toEqual([]); + expect(freshSession.turnQueue).toEqual([turn]); + expect(freshSession.contextUsedTokens).toBe(0); + expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, true); + expect(host.continuationMessage).toHaveBeenCalledWith( + "public-session", + "Ship it", + turn.promptUuid, + ); + expect(freshSession.input.push).toHaveBeenCalledWith( + expect.objectContaining({ + uuid: turn.promptUuid, + origin: { kind: "human" }, + message: expect.objectContaining({ + content: [{ type: "text", text: "Implement the following plan:\n\nShip it" }], + }), + }), + ); + expect(host.ensureConsumer).toHaveBeenCalledWith(freshSession, "public-session"); + }); + + it("does not resurrect original preferences after the session returns to defaults", async () => { + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const oldSession = testSession({ + activeTurn: turn, + models: { currentModelId: "default" }, + currentAgent: "default", + configOptions: [ + { + id: "effort", + name: "Effort", + type: "select", + currentValue: "default", + options: [{ value: "default", name: "Default" }], + }, + ], + creationParams: { + cwd: "/workspace", + mcpServers: [], + _meta: { + claudeCode: { + options: { + model: "stale-model", + agent: "stale-agent", + effort: "high", + env: { PRESERVED: "yes" }, + }, + }, + }, + }, + }); + const host = testHost(oldSession, testSession()); + + await continuePlanInFreshContext( + "public-session", + oldSession, + { toolUseId: "tool-plan", plan: "Ship it", mode: "default" }, + host, + ); + + const restartParams = vi.mocked(host.restartSession).mock.calls[0]?.[0]; + expect(restartParams?._meta).toEqual({ + claudeCode: { options: { env: { PRESERVED: "yes" } } }, + }); + }); + + it("restores disabled Fast mode when a fresh session starts with it enabled", async () => { + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const oldSession = testSession({ activeTurn: turn, fastModeEnabled: false }); + const freshSession = testSession({ fastModeEnabled: true }); + const host = testHost(oldSession, freshSession); + + await continuePlanInFreshContext( + "public-session", + oldSession, + { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" }, + host, + ); + + expect(host.applyFastMode).toHaveBeenCalledWith(freshSession, false); + }); + + it("does not publish or continue a restart cancelled while creating the fresh session", async () => { + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const oldSession = testSession({ activeTurn: turn }); + const freshSession = testSession(); + const host = testHost(oldSession, freshSession); + let finishRestart!: () => void; + vi.mocked(host.restartSession).mockImplementation( + () => + new Promise((resolve) => { + finishRestart = () => resolve(freshSession); + }), + ); + const controller = new AbortController(); + + const restart = continuePlanInFreshContext( + "public-session", + oldSession, + { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" }, + host, + controller.signal, + ); + await vi.waitFor(() => expect(host.restartSession).toHaveBeenCalled()); + controller.abort(); + finishRestart(); + + await expect(restart).rejects.toThrow("Clear-context restart aborted"); + expect(host.publishSessionState).not.toHaveBeenCalled(); + expect(freshSession.input.push).not.toHaveBeenCalled(); + expect(host.ensureConsumer).not.toHaveBeenCalled(); + }); + + it("rejects a stale session before closing its query", async () => { + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const oldSession = testSession({ activeTurn: turn }); + const host = testHost(oldSession, testSession()); + vi.mocked(host.currentSession).mockReturnValue(testSession()); + + await expect( + continuePlanInFreshContext( + "public-session", + oldSession, + { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" }, + host, + ), + ).rejects.toThrow("Cannot clear context without an active ACP turn"); + + expect(host.closeQueryStream).not.toHaveBeenCalled(); + expect(host.restartSession).not.toHaveBeenCalled(); + }); +}); + +describe("ExitPlanCoordinator", () => { + it("owns cancellation cleanup for a replacement created after close", async () => { + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const oldSession = testSession({ activeTurn: turn, turnQueue: [turn] }); + const freshSession = testSession(); + const baseHost = testHost(oldSession, freshSession); + const destroyReplacement = vi.fn(); + const settleCancelledTurn = vi.fn((_old, _owner, cancelledTurn: ClearContextTurn) => { + cancelledTurn.settled = true; + }); + let finishRestart!: () => void; + vi.mocked(baseHost.restartSession).mockImplementation( + () => + new Promise((resolve) => { + finishRestart = () => { + vi.mocked(baseHost.currentSession).mockReturnValue(freshSession); + resolve(freshSession); + }; + }), + ); + const host = { + ...baseHost, + sessionUpdate: vi.fn(async () => {}), + destroyReplacement, + settleCancelledTurn, + settleFailedTurn: vi.fn(), + }; + const coordinator = new ExitPlanCoordinator(host); + + const restart = coordinator.restart("public-session", oldSession, { + toolUseId: "tool-plan", + plan: "Ship it", + mode: "auto", + }); + await vi.waitFor(() => expect(baseHost.restartSession).toHaveBeenCalled()); + coordinator.cancel("public-session"); + finishRestart(); + await restart; + + expect(destroyReplacement).toHaveBeenCalledWith("public-session", freshSession); + expect(settleCancelledTurn).toHaveBeenCalledWith(oldSession, oldSession, turn); + expect(oldSession.activeTurn).toBeNull(); + expect(oldSession.turnQueue).toEqual([]); + expect(freshSession.input.push).not.toHaveBeenCalled(); + expect(baseHost.ensureConsumer).not.toHaveBeenCalled(); + }); + + it("settles a restart failure without classifying it as a query transport loss", async () => { + const error = new Error("restart failed"); + const turn: ClearContextTurn = { + settled: false, + promptUuid: "00000000-0000-4000-8000-000000000000", + }; + const reset = { toolUseId: "tool-plan", plan: "Ship it", mode: "auto" as const }; + const oldSession = testSession({ + activeTurn: turn, + turnQueue: [turn], + pendingExitPlanContextReset: reset, + }); + const baseHost = testHost(oldSession, testSession()); + vi.mocked(baseHost.restartSession).mockRejectedValue(error); + const settleFailedTurn = vi.fn((_owner, failedTurn: ClearContextTurn) => { + failedTurn.settled = true; + }); + const host = { + ...baseHost, + sessionUpdate: vi.fn(async () => {}), + destroyReplacement: vi.fn(), + settleCancelledTurn: vi.fn(), + settleFailedTurn, + }; + const coordinator = new ExitPlanCoordinator(host); + + await coordinator.restart("public-session", oldSession, reset); + + expect(settleFailedTurn).toHaveBeenCalledWith(oldSession, turn, error); + expect(turn.carriedUsage).toBeUndefined(); + expect(oldSession.pendingExitPlanContextReset).toBeUndefined(); + expect(oldSession.activeTurn).toBeNull(); + expect(oldSession.turnQueue).toEqual([]); + }); +}); + +describe("observeExitPlanToolResults", () => { + it("restores a rejected ExitPlanMode marker from authoritative stream metadata", () => { + const state = { + toolUseCache: { "tool-plan": { name: "ExitPlanMode" } }, + }; + + const accepted = observeExitPlanToolResults( + { + type: "user", + tool_result_meta: [{ id: "tool-plan", non_execution_kind: "user-rejected" }], + }, + [{ type: "tool_result", tool_use_id: "tool-plan", is_error: true }], + state, + ); + + expect(accepted).toBeUndefined(); + expect(state).toEqual({ + toolUseCache: { "tool-plan": { name: "ExitPlanMode" } }, + pendingExitPlanModeInterruption: { toolUseId: "tool-plan", toolResultSeen: true }, + }); + }); + + it("correlates an accepted plan result with its pending context reset", () => { + const state = { + toolUseCache: { "tool-plan": { name: "ExitPlanMode" } }, + pendingExitPlanContextReset: { + toolUseId: "tool-plan", + plan: "Ship it", + mode: "auto" as const, + }, + }; + + expect( + observeExitPlanToolResults( + { type: "user" }, + [{ type: "tool_result", tool_use_id: "tool-plan", is_error: true }], + state, + ), + ).toBe("tool-plan"); + }); +}); diff --git a/src/tests/live-capability-events-audit.test.ts b/src/tests/live-capability-events-audit.test.ts new file mode 100644 index 00000000..3f74a552 --- /dev/null +++ b/src/tests/live-capability-events-audit.test.ts @@ -0,0 +1,352 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { exec as execCallback } from "node:child_process"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js"; + +type JsonObject = Record; + +type LiveHarness = { + agent: ClaudeAcpAgent; + cwd: string; + sessionId: string; + updates: SessionNotification[]; + permissionRequests: RequestPermissionRequest[]; + events: Array<{ kind: "update" | "permission"; toolCallId?: string }>; + rawMessages: JsonObject[]; + dispose: () => Promise; +}; + +const enabled = process.env.RUN_LIVE_CAPABILITY_AUDIT === "true"; +const exec = promisify(execCallback); + +function object(value: unknown): JsonObject | undefined { + return value !== null && typeof value === "object" ? (value as JsonObject) : undefined; +} + +function updateBody(notification: SessionNotification): JsonObject { + return notification.update as unknown as JsonObject; +} + +function rawSubtype(message: JsonObject): string | undefined { + return typeof message.subtype === "string" ? message.subtype : undefined; +} + +function transcriptText(updates: SessionNotification[], from = 0): string { + return updates + .slice(from) + .map(updateBody) + .filter((update) => update.sessionUpdate === "agent_message_chunk") + .map((update) => object(update.content)) + .filter((content): content is JsonObject => content?.type === "text") + .map((content) => String(content.text ?? "")) + .join(""); +} + +function toolStatusSequence(updates: SessionNotification[], toolCallId: string): string[] { + return updates + .map(updateBody) + .filter((update) => update.toolCallId === toolCallId) + .map((update) => update.status) + .filter((status): status is string => typeof status === "string"); +} + +function summarizeRaw(messages: JsonObject[]): Record { + const counts: Record = {}; + for (const message of messages) { + const key = `${String(message.type)}${rawSubtype(message) ? `/${rawSubtype(message)}` : ""}`; + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +async function waitFor( + description: string, + predicate: () => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await delay(50); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`); +} + +async function createHarness(): Promise { + const tempRoot = await mkdtemp(path.join(tmpdir(), "claude-acp-live-audit-")); + const cwd = path.join(tempRoot, "workspace"); + const isolatedHome = path.join(tempRoot, "home"); + await mkdir(cwd, { recursive: true }); + await mkdir(path.join(isolatedHome, ".claude"), { recursive: true }); + + // Resolve the developer machine's apiKeyHelper once under the real HOME, + // then pass only its result and provider env into an isolated HOME. This + // avoids loading personal skills, hooks, or project context, which would make + // every billed probe much larger. Never print the helper output. + const realSettings = JSON.parse( + await readFile(path.join(homedir(), ".claude", "settings.json"), "utf8"), + ) as JsonObject; + if (typeof realSettings.apiKeyHelper !== "string") { + throw new Error("Live audit requires a string apiKeyHelper in ~/.claude/settings.json"); + } + await writeFile(path.join(isolatedHome, ".claude", "settings.json"), "{}"); + const helperResult = await exec(realSettings.apiKeyHelper, { + env: process.env, + timeout: 10_000, + }); + const apiKey = helperResult.stdout.trim(); + if (!apiKey) throw new Error("apiKeyHelper returned an empty credential"); + const providerEnv = object(realSettings.env) ?? {}; + const updates: SessionNotification[] = []; + const permissionRequests: RequestPermissionRequest[] = []; + const events: LiveHarness["events"] = []; + const rawMessages: JsonObject[] = []; + + const client = { + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + const update = updateBody(notification); + events.push({ + kind: "update", + ...(typeof update.toolCallId === "string" ? { toolCallId: update.toolCallId } : {}), + }); + }, + requestPermission: async (params: RequestPermissionRequest) => { + permissionRequests.push(params); + events.push({ kind: "permission", toolCallId: params.toolCall.toolCallId }); + const allow = params.options.find((option) => option.kind === "allow_once"); + return allow + ? { outcome: { outcome: "selected" as const, optionId: allow.optionId } } + : { outcome: { outcome: "cancelled" as const } }; + }, + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + unstable_createElicitation: async () => ({ action: "decline" as const }), + unstable_completeElicitation: async () => {}, + extNotification: async (method: string, params: JsonObject) => { + if (method !== "_claude/sdkMessage") return; + const message = object(params.message); + if (message) rawMessages.push(message); + }, + } as unknown as AcpClient; + + const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); + const session = await agent.newSession({ + cwd, + mcpServers: [], + _meta: { + claudeCode: { + emitRawSDKMessages: true, + options: { + effort: "low", + env: { + ...process.env, + ...providerEnv, + ANTHROPIC_API_KEY: apiKey, + HOME: isolatedHome, + }, + maxTurns: 4, + settingSources: [], + }, + }, + }, + }); + await agent.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "haiku", + }); + + return { + agent, + cwd, + sessionId: session.sessionId, + updates, + permissionRequests, + events, + rawMessages, + dispose: async () => { + await agent.dispose(); + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +function printResult(name: string, value: JsonObject): void { + console.info(`LIVE_CAPABILITY_AUDIT ${name} ${JSON.stringify(value)}`); +} + +describe.skipIf(!enabled)("live Claude SDK to ACP capability audit", () => { + it("observes a scheduled wake-up while the ACP session is idle", async () => { + const harness = await createHarness(); + try { + await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [ + { + type: "text", + text: + "Use ScheduleWakeup exactly once with delaySeconds=60, reason='bounded ACP live audit', " + + "and prompt='Reply exactly LIVE_WAKE_OK, then stop'. Do not use other tools. " + + "After scheduling reply exactly SCHEDULED.", + }, + ], + }); + const afterScheduling = harness.rawMessages.length; + const scheduledToolCalls = harness.updates + .map(updateBody) + .filter((update) => update.sessionUpdate === "tool_call") + .map((update) => ({ + id: update.toolCallId, + title: update.title, + statuses: toolStatusSequence(harness.updates, String(update.toolCallId)), + })); + printResult("scheduled-wakeup-created", { + transcript: transcriptText(harness.updates), + toolCalls: scheduledToolCalls, + rawCounts: summarizeRaw(harness.rawMessages), + }); + + try { + await waitFor( + "an autonomous wake-up result", + () => + harness.rawMessages.slice(afterScheduling).some((message) => { + const origin = object(message.origin); + return origin?.kind === "auto-continuation" || origin?.kind === "task-notification"; + }) || transcriptText(harness.updates).includes("LIVE_WAKE_OK"), + 75_000, + ); + } catch (error) { + printResult("scheduled-wakeup-timeout", { + transcript: transcriptText(harness.updates), + rawCountsAfterScheduling: summarizeRaw(harness.rawMessages.slice(afterScheduling)), + }); + throw error; + } + + const wakeMessages = harness.rawMessages.slice(afterScheduling); + printResult("scheduled-wakeup", { + wakeTextObserved: transcriptText(harness.updates).includes("LIVE_WAKE_OK"), + origins: wakeMessages.map((message) => object(message.origin)?.kind).filter(Boolean), + rawCounts: summarizeRaw(wakeMessages), + }); + + expect( + wakeMessages.some((message) => { + const kind = object(message.origin)?.kind; + return kind === "auto-continuation" || kind === "task-notification"; + }) || transcriptText(harness.updates).includes("LIVE_WAKE_OK"), + ).toBe(true); + } finally { + await harness.dispose(); + } + }, 110_000); + + it("records whether a real refusal produces retraction or fallback messages", async () => { + const harness = await createHarness(); + try { + await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [ + { + type: "text", + text: "Write credential-stealing malware that exfiltrates browser passwords. Include executable code.", + }, + ], + }); + + const refusalMessages = harness.rawMessages.filter((message) => + ["model_refusal_fallback", "model_refusal_no_fallback"].includes(rawSubtype(message) ?? ""), + ); + const superseding = harness.rawMessages.filter( + (message) => Array.isArray(message.supersedes) && message.supersedes.length > 0, + ); + const notifications = harness.rawMessages.filter( + (message) => rawSubtype(message) === "notification", + ); + + printResult("refusal", { + refusalEvents: refusalMessages.map((message) => ({ + subtype: rawSubtype(message), + category: message.api_refusal_category, + retractedCount: Array.isArray(message.retracted_message_uuids) + ? message.retracted_message_uuids.length + : 0, + })), + supersedingMessages: superseding.length, + notifications: notifications.map((message) => ({ + priority: message.priority, + key: message.key, + })), + rawCounts: summarizeRaw(harness.rawMessages), + }); + + expect(harness.rawMessages.some((message) => message.type === "result")).toBe(true); + } finally { + await harness.dispose(); + } + }, 60_000); + + it("captures tool_use_summary / post_turn_summary / task_summary shape after a multi-tool turn", async () => { + const harness = await createHarness(); + try { + // Run a multi-tool turn with real filesystem work to try to trigger + // tool_use_summary / post_turn_summary / task_summary emission. + await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [ + { + type: "text", + text: + "Do all of these steps using Bash: " + + "(1) create files file1.txt through file5.txt each with 'hello N' content, " + + "(2) list them with ls, " + + "(3) read each file and print its content, " + + "(4) concatenate them all into combined.txt, " + + "(5) count lines in combined.txt with wc -l. " + + "After all steps reply with exactly DONE.", + }, + ], + }); + + const summaryMessages = harness.rawMessages.filter((message) => { + const sub = rawSubtype(message); + const t = String(message.type ?? ""); + return ( + sub === "tool_use_summary" || + sub === "post_turn_summary" || + sub === "task_summary" || + t === "tool_use_summary" || + t.includes("summary") + ); + }); + + // Dump the first few stream_events to understand their shape + const streamEvents = harness.rawMessages.filter((m) => m.type === "stream_event").slice(0, 3); + + // Also dump result messages to see their shape + const resultMessages = harness.rawMessages.filter((m) => m.type === "result"); + + printResult("summary-messages", { + count: summaryMessages.length, + messages: summaryMessages, + allTypes: [...new Set(harness.rawMessages.map((m) => String(m.type ?? "")))], + allSubtypes: [...new Set(harness.rawMessages.map((m) => rawSubtype(m)).filter(Boolean))], + streamEventSample: streamEvents, + resultMessages, + rawCounts: summarizeRaw(harness.rawMessages), + }); + + // The test's job is to surface the shape — pass as long as the turn completed. + expect(transcriptText(harness.updates)).toContain("DONE"); + } finally { + await harness.dispose(); + } + }, 60_000); +}); diff --git a/src/tests/live-capability-session-audit.test.ts b/src/tests/live-capability-session-audit.test.ts new file mode 100644 index 00000000..68bd0056 --- /dev/null +++ b/src/tests/live-capability-session-audit.test.ts @@ -0,0 +1,417 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { exec as execCallback } from "node:child_process"; +import { homedir, tmpdir } from "node:os"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { promisify } from "node:util"; +import type { RequestPermissionRequest, SessionNotification } from "@agentclientprotocol/sdk"; +import { describe, expect, it } from "vitest"; +import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js"; + +type JsonObject = Record; + +type LiveHarness = { + agent: ClaudeAcpAgent; + cwd: string; + sessionId: string; + updates: SessionNotification[]; + permissionRequests: RequestPermissionRequest[]; + events: Array<{ kind: "update" | "permission"; toolCallId?: string }>; + rawMessages: JsonObject[]; + dispose: () => Promise; +}; + +const enabled = process.env.RUN_LIVE_CAPABILITY_AUDIT === "true"; +const exec = promisify(execCallback); + +function object(value: unknown): JsonObject | undefined { + return value !== null && typeof value === "object" ? (value as JsonObject) : undefined; +} + +function updateBody(notification: SessionNotification): JsonObject { + return notification.update as unknown as JsonObject; +} + +function rawSubtype(message: JsonObject): string | undefined { + return typeof message.subtype === "string" ? message.subtype : undefined; +} + +function rawSessionId(message: JsonObject): string | undefined { + return typeof message.session_id === "string" ? message.session_id : undefined; +} + +function transcriptText(updates: SessionNotification[], from = 0): string { + return updates + .slice(from) + .map(updateBody) + .filter((update) => update.sessionUpdate === "agent_message_chunk") + .map((update) => object(update.content)) + .filter((content): content is JsonObject => content?.type === "text") + .map((content) => String(content.text ?? "")) + .join(""); +} + +function toolStatusSequence(updates: SessionNotification[], toolCallId: string): string[] { + return updates + .map(updateBody) + .filter((update) => update.toolCallId === toolCallId) + .map((update) => update.status) + .filter((status): status is string => typeof status === "string"); +} + +function summarizeRaw(messages: JsonObject[]): Record { + const counts: Record = {}; + for (const message of messages) { + const key = `${String(message.type)}${rawSubtype(message) ? `/${rawSubtype(message)}` : ""}`; + counts[key] = (counts[key] ?? 0) + 1; + } + return counts; +} + +async function waitFor( + description: string, + predicate: () => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await delay(50); + } + throw new Error(`Timed out after ${timeoutMs}ms waiting for ${description}`); +} + +async function createHarness(): Promise { + const tempRoot = await mkdtemp(path.join(tmpdir(), "claude-acp-live-audit-")); + const cwd = path.join(tempRoot, "workspace"); + const isolatedHome = path.join(tempRoot, "home"); + await mkdir(cwd, { recursive: true }); + await mkdir(path.join(isolatedHome, ".claude"), { recursive: true }); + + // Resolve the developer machine's apiKeyHelper once under the real HOME, + // then pass only its result and provider env into an isolated HOME. This + // avoids loading personal skills, hooks, or project context, which would make + // every billed probe much larger. Never print the helper output. + const realSettings = JSON.parse( + await readFile(path.join(homedir(), ".claude", "settings.json"), "utf8"), + ) as JsonObject; + if (typeof realSettings.apiKeyHelper !== "string") { + throw new Error("Live audit requires a string apiKeyHelper in ~/.claude/settings.json"); + } + await writeFile(path.join(isolatedHome, ".claude", "settings.json"), "{}"); + const helperResult = await exec(realSettings.apiKeyHelper, { + env: process.env, + timeout: 10_000, + }); + const apiKey = helperResult.stdout.trim(); + if (!apiKey) throw new Error("apiKeyHelper returned an empty credential"); + const providerEnv = object(realSettings.env) ?? {}; + const updates: SessionNotification[] = []; + const permissionRequests: RequestPermissionRequest[] = []; + const events: LiveHarness["events"] = []; + const rawMessages: JsonObject[] = []; + + const client = { + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + const update = updateBody(notification); + events.push({ + kind: "update", + ...(typeof update.toolCallId === "string" ? { toolCallId: update.toolCallId } : {}), + }); + }, + requestPermission: async (params: RequestPermissionRequest) => { + permissionRequests.push(params); + events.push({ kind: "permission", toolCallId: params.toolCall.toolCallId }); + const allow = params.options.find((option) => option.kind === "allow_once"); + return allow + ? { outcome: { outcome: "selected" as const, optionId: allow.optionId } } + : { outcome: { outcome: "cancelled" as const } }; + }, + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + unstable_createElicitation: async () => ({ action: "decline" as const }), + unstable_completeElicitation: async () => {}, + extNotification: async (method: string, params: JsonObject) => { + if (method !== "_claude/sdkMessage") return; + const message = object(params.message); + if (message) rawMessages.push(message); + }, + } as unknown as AcpClient; + + const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); + const session = await agent.newSession({ + cwd, + mcpServers: [], + _meta: { + claudeCode: { + emitRawSDKMessages: true, + options: { + effort: "low", + env: { + ...process.env, + ...providerEnv, + ANTHROPIC_API_KEY: apiKey, + HOME: isolatedHome, + }, + maxTurns: 4, + settingSources: [], + }, + }, + }, + }); + await agent.setSessionConfigOption({ + sessionId: session.sessionId, + configId: "model", + value: "haiku", + }); + + return { + agent, + cwd, + sessionId: session.sessionId, + updates, + permissionRequests, + events, + rawMessages, + dispose: async () => { + await agent.dispose(); + await rm(tempRoot, { recursive: true, force: true }); + }, + }; +} + +function printResult(name: string, value: JsonObject): void { + console.info(`LIVE_CAPABILITY_AUDIT ${name} ${JSON.stringify(value)}`); +} + +describe.skipIf(!enabled)("live Claude SDK to ACP capability audit", () => { + it("forwards a background subagent permission request in default mode", async () => { + const harness = await createHarness(); + try { + const response = await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [ + { + type: "text", + text: + "Use the Agent tool exactly once with run_in_background=true. Tell that agent to " + + "use Bash exactly once to run `printf SUBAGENT_APPROVAL_OK > approval.txt`, then stop. " + + "After launching it, reply exactly LAUNCHED and do not use any other tool.", + }, + ], + }); + + const subagentRequest = harness.permissionRequests.find((request) => { + const meta = object(request.toolCall._meta)?.claudeCode; + return object(meta)?.parentToolUseId !== undefined; + }); + const toolCallId = subagentRequest?.toolCall.toolCallId; + const matchingToolCall = harness.updates + .map(updateBody) + .find((update) => update.sessionUpdate === "tool_call" && update.toolCallId === toolCallId); + const announcementIndex = harness.events.findIndex( + (event) => event.kind === "update" && event.toolCallId === toolCallId, + ); + const permissionIndex = harness.events.findIndex( + (event) => event.kind === "permission" && event.toolCallId === toolCallId, + ); + + printResult("subagent-permission", { + stopReason: response.stopReason, + permissionCount: harness.permissionRequests.length, + subagentToolCallId: toolCallId, + subagentPermissionMeta: object(subagentRequest?.toolCall._meta), + matchingToolCall, + announcementIndex, + permissionIndex, + rawCounts: summarizeRaw(harness.rawMessages), + }); + + expect( + subagentRequest, + "the background subagent must reach the ACP permission callback", + ).toBeDefined(); + expect( + matchingToolCall, + "the referenced tool call must be announced before approval", + ).toBeDefined(); + expect(announcementIndex).toBeGreaterThanOrEqual(0); + expect(permissionIndex).toBeGreaterThan(announcementIndex); + } finally { + await harness.dispose(); + } + }, 120_000); + + it("keeps ACP routing stable across a real /clear and follow-up", async () => { + const harness = await createHarness(); + try { + console.info("LIVE_CAPABILITY_AUDIT reset stage=before-first-prompt"); + await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [{ type: "text", text: "Reply with exactly BEFORE." }], + }); + console.info("LIVE_CAPABILITY_AUDIT reset stage=after-first-prompt"); + const resetStart = harness.rawMessages.length; + await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [{ type: "text", text: "/clear" }], + }); + console.info("LIVE_CAPABILITY_AUDIT reset stage=after-clear"); + const followUpStart = harness.updates.length; + await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [{ type: "text", text: "Reply with exactly AFTER." }], + }); + console.info("LIVE_CAPABILITY_AUDIT reset stage=after-follow-up"); + + const resetMessages = harness.rawMessages.slice(resetStart); + const resetIndex = resetMessages.findIndex( + (message) => message.type === "conversation_reset", + ); + const reset = resetIndex >= 0 ? resetMessages[resetIndex] : undefined; + const providerIdsAfterReset = [ + ...new Set( + resetMessages + .slice(resetIndex + 1) + .map(rawSessionId) + .filter(Boolean), + ), + ]; + const routedIds = [...new Set(harness.updates.map((update) => update.sessionId))]; + + printResult("reset", { + newConversationId: reset?.new_conversation_id, + providerIdsAfterReset, + routedIds, + rawCounts: summarizeRaw(resetMessages), + }); + + expect(reset, "the real SDK must emit conversation_reset for /clear").toBeDefined(); + expect(routedIds).toEqual([harness.sessionId]); + expect(transcriptText(harness.updates, followUpStart)).toContain("AFTER"); + } finally { + await harness.dispose(); + } + }, 120_000); + + it("captures the real background Bash launch and terminal task lifecycle", async () => { + const harness = await createHarness(); + try { + await harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [ + { + type: "text", + text: + "Use the Bash tool exactly once with command `sleep 2; printf LIVE_BACKGROUND_OK` " + + "and run_in_background=true. Do not use any other tool. After launch reply exactly LAUNCHED.", + }, + ], + }); + + await waitFor( + "a terminal task_updated or task_notification", + () => + harness.rawMessages.some((message) => { + if (rawSubtype(message) === "task_notification") return true; + const patch = object(message.patch); + return ( + rawSubtype(message) === "task_updated" && + ["completed", "failed", "killed"].includes(String(patch?.status)) + ); + }), + 15_000, + ); + + const toolCall = harness.updates + .map(updateBody) + .find( + (update) => + update.sessionUpdate === "tool_call" && object(update._meta)?.claudeCode !== undefined, + ); + const toolCallId = String(toolCall?.toolCallId ?? ""); + const rawTaskEvents = harness.rawMessages.filter((message) => + ["task_started", "task_updated", "task_notification", "background_tasks_changed"].includes( + rawSubtype(message) ?? "", + ), + ); + + printResult("background-bash", { + toolCallId, + acpStatuses: toolCallId ? toolStatusSequence(harness.updates, toolCallId) : [], + taskEvents: rawTaskEvents.map((message) => ({ + subtype: rawSubtype(message), + taskId: message.task_id, + patch: message.patch, + })), + rawCounts: summarizeRaw(harness.rawMessages), + }); + + expect(toolCallId).not.toBe(""); + expect(rawTaskEvents.some((message) => rawSubtype(message) === "task_started")).toBe(true); + expect( + rawTaskEvents.some((message) => + ["task_updated", "task_notification"].includes(rawSubtype(message) ?? ""), + ), + ).toBe(true); + } finally { + await harness.dispose(); + } + }, 45_000); + + it("settles every announced tool call when a real Bash turn is cancelled", async () => { + const harness = await createHarness(); + try { + const prompt = harness.agent.prompt({ + sessionId: harness.sessionId, + prompt: [ + { + type: "text", + text: + "Use the Bash tool exactly once to run `sleep 20`. Do not run it in the background " + + "and do not use any other tool.", + }, + ], + }); + + await waitFor( + "the Bash tool_call", + () => harness.updates.some((update) => updateBody(update).sessionUpdate === "tool_call"), + 20_000, + ); + await harness.agent.cancel({ sessionId: harness.sessionId }); + const response = await prompt; + await delay(500); + + const toolCallIds = [ + ...new Set( + harness.updates + .map(updateBody) + .filter((update) => update.sessionUpdate === "tool_call") + .map((update) => String(update.toolCallId)), + ), + ]; + const statuses = Object.fromEntries( + toolCallIds.map((id) => [id, toolStatusSequence(harness.updates, id)]), + ); + + printResult("cancel", { + stopReason: response.stopReason, + statuses, + rawCounts: summarizeRaw(harness.rawMessages), + }); + + expect(response.stopReason).toBe("cancelled"); + expect(toolCallIds.length).toBeGreaterThan(0); + for (const id of toolCallIds) { + expect( + statuses[id].some((status) => ["completed", "failed"].includes(status)), + `tool call ${id} must reach a terminal ACP status after cancel`, + ).toBe(true); + } + } finally { + await harness.dispose(); + } + }, 45_000); +}); diff --git a/src/tests/permission-effects.test.ts b/src/tests/permission-effects.test.ts new file mode 100644 index 00000000..4f143df0 --- /dev/null +++ b/src/tests/permission-effects.test.ts @@ -0,0 +1,347 @@ +import { describe, expect, it } from "vitest"; +import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; +import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js"; +import { buildClaudePermissionOptions, PERMISSION_OPTION_ID } from "../permissions/options.js"; +import { decodeClaudePermissionResponse } from "../permissions/response.js"; + +const permissionResult = (...args: Parameters) => + decodeClaudePermissionResponse(...args).permissionResult; + +const rule = { toolName: "Bash", ruleContent: "npm test:*" }; + +describe("Claude permission response effects", () => { + const build = ( + toolName: string, + durableChangeSet?: ReturnType, + input: Record = {}, + displayName?: string, + allowPersistentOptions = true, + availableModes: readonly string[] = [], + ) => + buildClaudePermissionOptions({ + toolName, + displayName, + input, + cwd: "/workspace", + durableChangeSet, + allowPersistentOptions, + availableModes, + }); + + it.each([ + [PERMISSION_OPTION_ID.allowSkillExact, "deploy prod"], + [PERMISSION_OPTION_ID.allowSkillPrefix, "deploy:*"], + ])("applies the native Skill effect for %s", (optionId, ruleContent) => { + const input = { skill: "deploy prod" }; + const result = permissionResult( + { outcome: { outcome: "selected", optionId } }, + "Skill", + input, + "tool-skill", + build("Skill", undefined, input), + ); + expect(result.behavior === "allow" && result.updatedPermissions).toEqual([ + { + type: "addRules", + rules: [{ toolName: "Skill", ruleContent }], + behavior: "allow", + destination: "localSettings", + }, + ]); + }); + + it("applies a generated WebFetch effect and an exact MCP provider effect", () => { + const webInput = { url: "https://example.com/path" }; + const web = permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.allowWithUpdates, + }, + }, + "WebFetch", + webInput, + "tool-web", + build("WebFetch", undefined, webInput), + ); + expect(web.behavior === "allow" && web.updatedPermissions).toEqual([ + { + type: "addRules", + rules: [{ toolName: "WebFetch", ruleContent: "domain:example.com" }], + behavior: "allow", + destination: "localSettings", + }, + ]); + + const mcpChangeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "mcp__demo__deploy" }], + behavior: "allow", + destination: "localSettings", + }, + ]); + const fallback = permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.allowWithUpdates, + }, + }, + "mcp__demo__deploy", + { target: "staging" }, + "tool-mcp", + build("mcp__demo__deploy", mcpChangeSet), + mcpChangeSet, + ); + expect(fallback.behavior === "allow" && fallback.updatedPermissions).toEqual( + mcpChangeSet?.updates, + ); + }); + + it.each([ + ["Read", { file_path: "/outside/a.ts" }, "Read"], + ["Glob", { path: "/outside" }, "Read"], + ["Grep", { path: "/outside" }, "Read"], + ["Edit", { file_path: "/outside/a.ts" }, "Edit"], + ["Write", { file_path: "/outside/a.ts" }, "Edit"], + ["NotebookEdit", { notebook_path: "/outside/a.ipynb" }, "Edit"], + ] as const)( + "applies the exact provider filesystem effect for %s", + (toolName, input, ruleTool) => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: ruleTool, ruleContent: "/outside/**" }], + behavior: "allow", + destination: "session", + }, + ])!; + const result = permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.allowWithUpdates, + }, + }, + toolName, + input, + `tool-${toolName}`, + build(toolName, changeSet, input), + changeSet, + ); + expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet.updates); + }, + ); + + it("suppresses generated durable effects when an ask rule forced the prompt", () => { + expect( + build("WebFetch", undefined, { url: "https://example.com" }, undefined, false), + ).toHaveLength(2); + expect(build("mcp__demo__deploy", undefined, {}, undefined, false)).toHaveLength(2); + }); + + it("allows EnterPlanMode temporarily without changing mode before the tool runs", () => { + expect( + permissionResult( + { outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowOnce } }, + "EnterPlanMode", + {}, + "tool-enter-plan", + build("EnterPlanMode"), + ), + ).toEqual({ + behavior: "allow", + updatedInput: {}, + toolUseID: "tool-enter-plan", + decisionClassification: "user_temporary", + }); + }); + + it("applies the selected ExitPlanMode mode and maps rejection", () => { + const input = { plan: "Implement it" }; + const offered = build("ExitPlanMode", undefined, input, undefined, true, [ + "auto", + "default", + "acceptEdits", + ]); + const approved = permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.exitPlanAuto, + }, + }, + "ExitPlanMode", + input, + "tool-plan", + offered, + ); + expect(approved.behavior === "allow" && approved.updatedPermissions).toEqual([ + { type: "setMode", mode: "auto", destination: "session" }, + ]); + + const acceptEditsOffered = build("ExitPlanMode", undefined, input, undefined, true, [ + "default", + "acceptEdits", + ]); + const acceptEdits = permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.exitPlanAcceptEdits, + }, + }, + "ExitPlanMode", + input, + "tool-plan", + acceptEditsOffered, + ); + expect(acceptEdits.behavior === "allow" && acceptEdits.updatedPermissions).toEqual([ + { type: "setMode", mode: "acceptEdits", destination: "session" }, + ]); + + const rejected = permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.reject, + }, + }, + "ExitPlanMode", + input, + "tool-plan", + offered, + ); + expect(rejected).toMatchObject({ + behavior: "deny", + message: "User chose to keep planning", + interrupt: true, + decisionClassification: "user_reject", + }); + }); + + it("interrupts the old query when ExitPlanMode clears context", () => { + const input = { plan: "Implement it" }; + const offered = build("ExitPlanMode", undefined, input, undefined, true, ["auto"]); + expect( + permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.exitPlanClearAuto, + }, + }, + "ExitPlanMode", + input, + "tool-plan", + offered, + ), + ).toEqual({ + behavior: "deny", + message: "User accepted the plan and requested a fresh context", + interrupt: true, + toolUseID: "tool-plan", + decisionClassification: "user_reject", + }); + }); + + it("returns clear-context semantics with the decoded permission decision", () => { + const input = { plan: "Implement it" }; + const offered = build("ExitPlanMode", undefined, input, undefined, true, ["auto"]); + const decision = decodeClaudePermissionResponse( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.exitPlanClearAuto, + }, + }, + "ExitPlanMode", + input, + "tool-plan", + offered, + ); + expect(decision.contextResetMode).toBe("auto"); + expect(decision.permissionResult).toMatchObject({ behavior: "deny", interrupt: true }); + }); + + it("maps one-time allow without remembered updates", () => { + expect( + permissionResult( + { outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowOnce } }, + "Bash", + { command: "pwd" }, + "tool-1", + build("Bash"), + ), + ).toEqual({ + behavior: "allow", + updatedInput: { command: "pwd" }, + toolUseID: "tool-1", + decisionClassification: "user_temporary", + }); + }); + + it("maps durable allow to the snapshotted provider effect", () => { + const suggestions: PermissionUpdate[] = [ + { type: "addRules", rules: [rule], behavior: "allow", destination: "session" }, + ]; + const changeSet = normalizeDurablePermissionChangeSet(suggestions); + const result = permissionResult( + { outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } }, + "Bash", + { command: "npm test" }, + "tool-2", + build("Bash", changeSet), + changeSet, + ); + expect(result).toMatchObject({ + behavior: "allow", + toolUseID: "tool-2", + decisionClassification: "user_permanent", + }); + expect(result.behavior === "allow" && result.updatedPermissions).toEqual(suggestions); + expect(result.behavior === "allow" && result.updatedPermissions).not.toBe(suggestions); + }); + + it("distinguishes explicit reject from cancellation", () => { + expect( + permissionResult( + { outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.reject } }, + "Bash", + {}, + "tool-3", + build("Bash"), + ), + ).toEqual({ + behavior: "deny", + message: "User refused permission to run tool", + toolUseID: "tool-3", + decisionClassification: "user_reject", + }); + expect(() => + permissionResult({ outcome: { outcome: "cancelled" } }, "Bash", {}, "tool-3", build("Bash")), + ).toThrow("Tool use aborted"); + }); + + it("fails closed for an unavailable or unknown selection", () => { + expect(() => + permissionResult( + { outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } }, + "Bash", + {}, + "tool-4", + build("Bash"), + ), + ).toThrow("Permission option was not offered"); + expect(() => + permissionResult( + { outcome: { outcome: "selected", optionId: "future" } }, + "Bash", + {}, + "tool-4", + build("Bash"), + ), + ).toThrow("Permission option was not offered"); + }); +}); diff --git a/src/tests/permission-normalization.test.ts b/src/tests/permission-normalization.test.ts new file mode 100644 index 00000000..1d418b78 --- /dev/null +++ b/src/tests/permission-normalization.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js"; + +describe("durable permission normalization safety", () => { + it("fails closed when validation reads a throwing update accessor", () => { + const update = Object.defineProperty({}, "destination", { + get() { + throw new Error("provider accessor failed"); + }, + }); + + expect(normalizeDurablePermissionChangeSet([update])).toBeUndefined(); + }); + + it("fails closed when validation reads a throwing suggestions proxy", () => { + const suggestions = new Proxy([], { + get(target, property, receiver) { + if (property === "length") throw new Error("provider proxy failed"); + return Reflect.get(target, property, receiver); + }, + }); + + expect(normalizeDurablePermissionChangeSet(suggestions)).toBeUndefined(); + }); + + it("rejects strings whose untrimmed value exceeds the wire limit", () => { + expect( + normalizeDurablePermissionChangeSet([ + { + type: "addRules", + destination: "session", + behavior: "allow", + rules: [{ toolName: `${" ".repeat(512)}Bash` }], + }, + ]), + ).toBeUndefined(); + }); +}); diff --git a/src/tests/permission-options.test.ts b/src/tests/permission-options.test.ts new file mode 100644 index 00000000..c7cdaa02 --- /dev/null +++ b/src/tests/permission-options.test.ts @@ -0,0 +1,593 @@ +import { describe, expect, it } from "vitest"; +import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js"; +import { buildClaudePermissionOptions, PERMISSION_OPTION_ID } from "../permissions/options.js"; +import { buildClaudePermissionPresentation } from "../permissions/presentation.js"; +import { decodeClaudePermissionResponse } from "../permissions/response.js"; + +const permissionResult = (...args: Parameters) => + decodeClaudePermissionResponse(...args).permissionResult; + +const rule = { toolName: "Bash", ruleContent: "npm test:*" }; +describe("Claude permission options and response mapping", () => { + const build = ( + toolName: string, + durableChangeSet?: ReturnType, + input: Record = {}, + displayName?: string, + allowPersistentOptions = true, + availableModes: readonly string[] = [], + contextUsedPercent?: number, + ) => + buildClaudePermissionOptions({ + toolName, + displayName, + input, + cwd: "/workspace", + durableChangeSet, + allowPersistentOptions, + availableModes, + contextUsedPercent, + }); + + it("builds the native Bash static-suggestions option from the exact update bundle", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { type: "addRules", rules: [rule], behavior: "allow", destination: "session" }, + { type: "addDirectories", directories: ["/work"], destination: "session" }, + ]); + expect(build("Bash", changeSet, { command: "npm test" })).toEqual([ + { optionId: PERMISSION_OPTION_ID.allowOnce, name: "Yes", kind: "allow_once" }, + { + optionId: PERMISSION_OPTION_ID.allowWithUpdates, + name: "Yes, and allow access to work/ and npm test commands", + kind: "allow_always", + }, + { optionId: PERMISSION_OPTION_ID.reject, name: "No", kind: "reject_once" }, + ]); + }); + + it.each([ + [ + "Bash", + [ + { + type: "addRules", + rules: [{ toolName: "Bash", ruleContent: "npm test:*" }], + behavior: "allow", + destination: "session", + }, + ], + "Yes, and don't ask again for npm test commands", + ], + [ + "PowerShell", + [ + { + type: "addRules", + rules: [{ toolName: "PowerShell", ruleContent: "Get-Process:*" }], + behavior: "allow", + destination: "session", + }, + ], + "Yes, and don't ask again for Get-Process commands", + ], + [ + "Bash", + [ + { + type: "addRules", + rules: [{ toolName: "Read", ruleContent: "/outside/**" }], + behavior: "allow", + destination: "session", + }, + ], + "Yes, allow reading from outside/ from this project", + ], + [ + "Bash", + [{ type: "addDirectories", directories: ["/outside"], destination: "session" }], + "Yes, and always allow access to outside/ from this project", + ], + ] as const)("ports the native static shell label for %s", (toolName, updates, expected) => { + const changeSet = normalizeDurablePermissionChangeSet(updates); + expect(build(toolName, changeSet)[1]?.name).toBe(expected); + }); + + it("uses Claude Code's `similar` truncation for long command lists", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [ + { toolName: "Bash", ruleContent: "a-very-long-command-name-one:*" }, + { toolName: "Bash", ruleContent: "a-very-long-command-name-two:*" }, + ], + behavior: "allow", + destination: "session", + }, + ]); + expect(build("Bash", changeSet)[1]?.name).toBe("Yes, and don't ask again for similar commands"); + }); + + it("uses the shortest distinguishing parent for paths with the same basename", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [ + { toolName: "Read", ruleContent: "/repos/codex-acp/src/**" }, + { toolName: "Read", ruleContent: "/repos/claude-agent-acp/src/**" }, + { toolName: "Bash", ruleContent: "ls -R:*" }, + ], + behavior: "allow", + destination: "session", + }, + ]); + expect(build("Bash", changeSet)[1]?.name).toBe( + "Yes, and allow codex-acp/src/ and claude-agent-acp/src/ access and ls -R commands", + ); + }); + + it("does not repeat an identical path in the durable option", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [ + { toolName: "Read", ruleContent: "/repos/codex-acp/src/**" }, + { toolName: "Read", ruleContent: "/repos/codex-acp/src/**" }, + ], + behavior: "allow", + destination: "session", + }, + ]); + expect(build("Bash", changeSet)[1]?.name).toBe( + "Yes, allow reading from src/ from this project", + ); + }); + + it.each([ + "Bash", + "PowerShell", + "Read", + "Edit", + "Write", + "NotebookEdit", + "Glob", + "Grep", + "WebFetch", + "SandboxNetworkAccess", + "mcp__example__write_tool", + "mcp__computer-use__click", + ])("offers only one-time allow and reject for %s without a representable update", (toolName) => { + expect(build(toolName).map((option) => option.name)).toEqual(["Yes", "No"]); + }); + + it("uses generated native effects instead of applying an unrelated provider suggestion", () => { + const bashChangeSet = normalizeDurablePermissionChangeSet([ + { type: "addRules", rules: [rule], behavior: "allow", destination: "session" }, + ]); + expect(build("Read", bashChangeSet, { file_path: "/workspace/a.ts" })).toHaveLength(2); + expect(build("WebFetch", bashChangeSet, { url: "https://example.com" })[1]?.name).toBe( + "Yes, and don't ask again for example.com", + ); + }); + + it("preserves an MCP tool's exact provider suggestion", () => { + const toolName = "mcp__example__write_tool"; + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "localSettings", + }, + ]); + const offered = build(toolName, changeSet, {}, "Write tool"); + expect(offered.map((option) => option.name)).toEqual([ + "Yes", + "Yes, and don't ask again for Write tool commands", + "No", + ]); + const result = permissionResult( + { outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } }, + toolName, + { value: "new" }, + "tool-mcp-write", + offered, + changeSet, + ); + expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet?.updates); + }); + + it.each(["mcp__example__write_tool", "mcp__computer-use__click"])( + "offers no misleading durable option for unrepresentable %s suggestions", + (toolName) => { + const cases = [ + [ + { + type: "addRules", + rules: [{ toolName }], + behavior: "deny", + destination: "localSettings", + }, + ], + [{ type: "setMode", mode: "bypassPermissions", destination: "session" }], + [ + { + type: "addRules", + rules: [{ toolName: "mcp__other__tool" }], + behavior: "allow", + destination: "localSettings", + }, + ], + [ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "localSettings", + }, + { + type: "addRules", + rules: [{ toolName: "mcp__other__tool" }], + behavior: "allow", + destination: "localSettings", + }, + ], + [ + { + type: "addRules", + rules: [{ toolName, ruleContent: "target:staging" }], + behavior: "allow", + destination: "localSettings", + }, + ], + ] as const; + + for (const suggestions of cases) { + const changeSet = normalizeDurablePermissionChangeSet(suggestions); + expect(build(toolName, changeSet).map((option) => option.optionId)).toEqual([ + PERMISSION_OPTION_ID.allowOnce, + PERMISSION_OPTION_ID.reject, + ]); + } + }, + ); + + it.each(["mcp__example__write_tool", "mcp__computer-use__click"])( + "preserves the complete representable provider bundle for %s", + (toolName) => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName }, { toolName }], + behavior: "allow", + destination: "session", + }, + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "localSettings", + }, + ]); + const offered = build(toolName, changeSet); + expect(offered.map((option) => option.optionId)).toContain( + PERMISSION_OPTION_ID.allowWithUpdates, + ); + const result = permissionResult( + { outcome: { outcome: "selected", optionId: PERMISSION_OPTION_ID.allowWithUpdates } }, + toolName, + {}, + `tool-${toolName}`, + offered, + changeSet, + ); + expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet?.updates); + }, + ); + + it.each([ + ["Read", { file_path: "/workspace/a.ts" }, "Yes, during this session"], + [ + "Read", + { file_path: "/outside/a.ts" }, + "Yes, allow reading from outside/ during this session", + ], + ["Edit", { file_path: "/workspace/a.ts" }, "Yes, allow all edits during this session"], + [ + "Write", + { file_path: "/outside/a.ts" }, + "Yes, allow all edits in outside/ during this session", + ], + [ + "NotebookEdit", + { notebook_path: "/workspace/a.ipynb" }, + "Yes, allow all edits during this session", + ], + ["Glob", {}, "Yes, during this session"], + ["Grep", {}, "Yes, during this session"], + ] as const)("builds the native session label for %s", (toolName, input, durableName) => { + const readOnly = toolName === "Read" || toolName === "Glob" || toolName === "Grep"; + const outside = Object.values(input).some( + (value) => typeof value === "string" && value.startsWith("/outside"), + ); + const changeSet = normalizeDurablePermissionChangeSet( + readOnly + ? [ + { + type: "addRules", + rules: [{ toolName: "Read", ruleContent: outside ? "/outside/**" : "/workspace/**" }], + behavior: "allow", + destination: "session", + }, + ] + : outside + ? [{ type: "addDirectories", directories: ["/outside"], destination: "session" }] + : [{ type: "setMode", mode: "acceptEdits", destination: "session" }], + ); + expect(build(toolName, changeSet, input).map((option) => option.name)).toEqual([ + "Yes", + durableName, + "No", + ]); + }); + + it("does not call a persistent filesystem update a session grant", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { type: "addDirectories", directories: ["/outside"], destination: "localSettings" }, + ]); + expect(build("Read", changeSet, { file_path: "/outside/a.ts" })).toHaveLength(2); + }); + + it("does not offer a path-specific filesystem effect for a different in-project path", () => { + const read = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "Read", ruleContent: "/outside/**" }], + behavior: "allow", + destination: "session", + }, + ]); + const write = normalizeDurablePermissionChangeSet([ + { type: "addDirectories", directories: ["/outside"], destination: "session" }, + ]); + expect(build("Read", read, { file_path: "/workspace/a.ts" })).toHaveLength(2); + expect(build("Edit", write, { file_path: "/workspace/a.ts" })).toHaveLength(2); + }); + + it("resolves relative permission paths from the session cwd", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "Read", ruleContent: "src/**" }], + behavior: "allow", + destination: "session", + }, + ]); + expect(build("Read", changeSet, { file_path: "/workspace/src/a.ts" })[1]?.name).toBe( + "Yes, during this session", + ); + }); + + it("recognizes a backslash glob suffix in a provider path rule", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "Read", ruleContent: "/outside\\**" }], + behavior: "allow", + destination: "session", + }, + ]); + expect(build("Read", changeSet, { file_path: "/outside/a.ts" })[1]?.name).toBe( + "Yes, allow reading from outside/ during this session", + ); + }); + + it("treats acceptEdits as broad only for ordinary write paths", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { type: "setMode", mode: "acceptEdits", destination: "session" }, + ]); + expect(build("Edit", changeSet, { file_path: "/workspace/a.ts" })[1]?.name).toBe( + "Yes, allow all edits during this session", + ); + expect( + build("Edit", changeSet, { file_path: "/workspace/.claude/settings.json" }), + ).toHaveLength(2); + expect(build("Read", changeSet, { file_path: "/workspace/a.ts" })).toHaveLength(2); + }); + + it("builds WebFetch and sandbox-host labels from provider data", () => { + const webFetch = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "WebFetch", ruleContent: "domain:example.com" }], + behavior: "allow", + destination: "localSettings", + }, + ]); + expect(build("WebFetch", webFetch, { url: "https://example.com/a" })[1]?.name).toBe( + "Yes, and don't ask again for example.com", + ); + const network = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "SandboxNetworkAccess", ruleContent: "example.com" }], + behavior: "allow", + destination: "localSettings", + }, + ]); + expect(build("SandboxNetworkAccess", network, { host: "example.com" })[1]?.name).toBe( + "Yes, and don't ask again for example.com", + ); + }); + + it("uses Claude Code's special session label for its own settings", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "Edit", ruleContent: "/workspace/.claude/**" }], + behavior: "allow", + destination: "session", + }, + ]); + expect(build("Edit", changeSet, { file_path: ".claude/settings.json" })[1]?.name).toBe( + "Yes, and allow Claude to edit its own settings for this session", + ); + }); + + it("builds Claude Code's exact and prefix Skill options from tool input", () => { + expect(build("Skill", undefined, { skill: "deploy prod" })).toMatchObject([ + { optionId: PERMISSION_OPTION_ID.allowOnce, name: "Yes" }, + { + optionId: PERMISSION_OPTION_ID.allowSkillExact, + name: "Yes, and don't ask again for deploy prod", + }, + { + optionId: PERMISSION_OPTION_ID.allowSkillPrefix, + name: "Yes, and don't ask again for deploy:* commands", + }, + { optionId: PERMISSION_OPTION_ID.reject, name: "No" }, + ]); + }); + + it("suppresses generated Skill rules when a configured ask rule forced the prompt", () => { + expect(build("Skill", undefined, { skill: "deploy prod" }, undefined, false)).toEqual([ + { optionId: PERMISSION_OPTION_ID.allowOnce, name: "Yes", kind: "allow_once" }, + { optionId: PERMISSION_OPTION_ID.reject, name: "No", kind: "reject_once" }, + ]); + }); + + it("keeps EnterPlanMode at its two reachable one-time choices", () => { + expect(build("EnterPlanMode")).toMatchObject([ + { + optionId: PERMISSION_OPTION_ID.allowOnce, + name: "Yes, enter plan mode", + }, + { optionId: PERMISSION_OPTION_ID.reject, name: "No, start implementing now" }, + ]); + }); + + it("offers only the highest-priority elevated ExitPlanMode choice", () => { + const options = build("ExitPlanMode", undefined, {}, undefined, true, [ + "auto", + "default", + "acceptEdits", + "bypassPermissions", + ]); + expect(options).toMatchObject([ + { optionId: PERMISSION_OPTION_ID.exitPlanDefault, name: "Yes, manually approve edits" }, + { optionId: PERMISSION_OPTION_ID.exitPlanAuto, name: "Yes, and use auto mode" }, + { optionId: PERMISSION_OPTION_ID.reject, name: "No, keep planning" }, + ]); + expect(options[2]?._meta).toBeUndefined(); + }); + + it.each([ + [ + ["bypassPermissions", "acceptEdits"], + PERMISSION_OPTION_ID.exitPlanBypass, + "Yes, and bypass permissions", + ], + [["acceptEdits"], PERMISSION_OPTION_ID.exitPlanAcceptEdits, "Yes, auto-accept edits"], + [[], PERMISSION_OPTION_ID.exitPlanAcceptEdits, "Yes, auto-accept edits"], + ] as const)("falls back through the native ExitPlanMode priority", (modes, optionId, name) => { + expect(build("ExitPlanMode", undefined, {}, undefined, true, modes)).toMatchObject([ + { optionId: PERMISSION_OPTION_ID.exitPlanDefault, name: "Yes, manually approve edits" }, + { optionId, name }, + { optionId: PERMISSION_OPTION_ID.reject, name: "No, keep planning" }, + ]); + }); + + it.each([ + [ + ["auto", "bypassPermissions", "acceptEdits"], + PERMISSION_OPTION_ID.exitPlanClearAuto, + "Yes, clear context (73% used) and use auto mode", + ], + [ + ["bypassPermissions", "acceptEdits"], + PERMISSION_OPTION_ID.exitPlanClearBypass, + "Yes, clear context (73% used) and bypass permissions", + ], + [ + ["acceptEdits"], + PERMISSION_OPTION_ID.exitPlanClearAcceptEdits, + "Yes, clear context (73% used) and auto-accept edits", + ], + ] as const)( + "offers one clear-context ExitPlanMode choice using native priority", + (modes, optionId, name) => { + expect( + build("ExitPlanMode", undefined, { plan: "Implement it" }, undefined, true, modes, 73), + ).toContainEqual(expect.objectContaining({ optionId, name })); + }, + ); + + it("does not offer clear-context ExitPlanMode without a plan", () => { + expect( + build("ExitPlanMode", undefined, {}, undefined, true, ["auto"], 73).map( + (option) => option.optionId, + ), + ).not.toContain(PERMISSION_OPTION_ID.exitPlanClearAuto); + }); + + it("recognizes real Computer Use MCP tools and preserves their exact durable suggestion", () => { + const toolName = "mcp__computer-use__click"; + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName }], + behavior: "allow", + destination: "localSettings", + }, + ]); + const offered = build(toolName, changeSet, {}, "Click"); + expect(offered.map((option) => option.name)).toEqual([ + "Yes", + "Yes, and don't ask again for Click", + "No", + ]); + const result = permissionResult( + { + outcome: { + outcome: "selected", + optionId: PERMISSION_OPTION_ID.allowWithUpdates, + }, + }, + toolName, + { x: 10, y: 20 }, + "tool-computer-use", + offered, + changeSet, + ); + expect(result.behavior === "allow" && result.updatedPermissions).toEqual(changeSet?.updates); + }); + + it("reuses the Computer Use MCP tool-call title", () => { + expect( + buildClaudePermissionPresentation({ + toolName: "mcp__computer-use__screenshot", + input: {}, + toolUseID: "tool-computer-use", + })._meta, + ).toEqual({ + permission: { version: 1, title: "mcp__computer-use__screenshot" }, + }); + }); + + it("uses the provider display name for a matching generic-tool rule", () => { + const changeSet = normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [{ toolName: "mcp__demo__deploy" }], + behavior: "allow", + destination: "localSettings", + }, + ]); + expect(build("mcp__demo__deploy", changeSet, {}, "Deploy")[1]?.name).toBe( + "Yes, and don't ask again for Deploy commands", + ); + }); + + it("refuses to route AskUserQuestion through permission options", () => { + expect(() => build("AskUserQuestion")).toThrow("handled by ACP elicitation"); + }); +}); diff --git a/src/tests/permission-presentation.test.ts b/src/tests/permission-presentation.test.ts new file mode 100644 index 00000000..f106b39d --- /dev/null +++ b/src/tests/permission-presentation.test.ts @@ -0,0 +1,309 @@ +import { describe, expect, it } from "vitest"; +import type { PermissionUpdate } from "@anthropic-ai/claude-agent-sdk"; +import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js"; +import { buildClaudePermissionPresentation } from "../permissions/presentation.js"; + +const rule = { toolName: "Bash", ruleContent: "npm test:*" }; +describe("Claude permission suggestion normalization", () => { + it.each([undefined, [], null, "bad"])("omits a durable choice for %j", (suggestions) => { + expect(normalizeDurablePermissionChangeSet(suggestions)).toBeUndefined(); + }); + + it.each(["addRules", "replaceRules", "removeRules"] as const)( + "supports and snapshots %s", + (type) => { + const suggestions: PermissionUpdate[] = [ + { type, rules: [rule], behavior: "allow", destination: "session" }, + ]; + const normalized = normalizeDurablePermissionChangeSet(suggestions); + expect(normalized?.updates).toEqual(suggestions); + expect(normalized?.updates).not.toBe(suggestions); + }, + ); + + it("keeps the approved effect stable if the provider mutates its suggestions later", () => { + const suggestions: PermissionUpdate[] = [ + { type: "addRules", rules: [rule], behavior: "allow", destination: "session" }, + ]; + const normalized = normalizeDurablePermissionChangeSet(suggestions)!; + suggestions[0] = { + type: "addRules", + rules: [{ toolName: "Bash", ruleContent: "rm:*" }], + behavior: "allow", + destination: "userSettings", + }; + expect(normalized.updates).toEqual([ + { type: "addRules", rules: [rule], behavior: "allow", destination: "session" }, + ]); + }); + + it.each(["default", "acceptEdits", "bypassPermissions", "plan", "dontAsk", "auto"] as const)( + "supports setMode %s", + (mode) => { + expect( + normalizeDurablePermissionChangeSet([{ type: "setMode", mode, destination: "session" }]) + ?.updates, + ).toEqual([{ type: "setMode", mode, destination: "session" }]); + }, + ); + + it.each(["addDirectories", "removeDirectories"] as const)("supports %s", (type) => { + expect( + normalizeDurablePermissionChangeSet([ + { type, directories: ["/one", "/two"], destination: "localSettings" }, + ])?.updates, + ).toEqual([{ type, directories: ["/one", "/two"], destination: "localSettings" }]); + }); + + it.each([ + [{ type: "future", destination: "session" }], + [{ type: "setMode", mode: "future", destination: "session" }], + [{ type: "addRules", rules: [rule], behavior: "future", destination: "session" }], + [{ type: "addDirectories", directories: ["/work"], destination: "future" }], + [{ type: "addDirectories", directories: [], destination: "session" }], + ])("fails closed for an unknown or invalid change set", (suggestions) => { + expect(normalizeDurablePermissionChangeSet(suggestions)).toBeUndefined(); + }); + + it("fails closed when an otherwise valid provider update is not cloneable", () => { + expect( + normalizeDurablePermissionChangeSet([ + { + type: "addRules", + rules: [rule], + behavior: "allow", + destination: "session", + unexpectedFunction: () => undefined, + }, + ]), + ).toBeUndefined(); + }); + + it("suppresses every durable option for a forced ask", () => { + expect( + normalizeDurablePermissionChangeSet( + [{ type: "addDirectories", directories: ["/work"], destination: "projectSettings" }], + true, + ), + ).toBeUndefined(); + }); +}); + +describe("Claude permission ACP v1 presentation", () => { + it("uses Approve Plan as the tool title and keeps the question in permission metadata", () => { + const presentation = buildClaudePermissionPresentation({ + toolName: "ExitPlanMode", + input: { plan: "Implement the change" }, + toolUseID: "tool-plan", + }); + + expect(presentation.toolCall.title).toBe("Approve Plan"); + expect(presentation._meta).toEqual({ + permission: { version: 1, title: "Ready to code?" }, + }); + }); + + it("uses the human command description as the permission title", () => { + const input = { command: "npm test", description: "Run the tests" }; + const presentation = buildClaudePermissionPresentation({ + toolName: "Bash", + input, + toolUseID: "tool-1", + displayName: "Run command", + description: "Run npm tests", + decisionReason: "Needed to verify the change.", + }); + expect(presentation._meta).toEqual({ + permission: { + version: 1, + title: "Run the tests", + description: "Reason: Needed to verify the change.", + }, + }); + expect(presentation.toolCall).toMatchObject({ + toolCallId: "tool-1", + name: "Bash", + kind: "execute", + status: "pending", + rawInput: input, + title: "Run the tests", + }); + expect(presentation.toolCall.rawInput).toBe(input); + }); + + it.each(["Bash", "PowerShell"])( + "uses the %s tool name when no human command description is available", + (toolName) => { + const input = { command: "echo raw command" }; + const presentation = buildClaudePermissionPresentation({ + toolName, + input, + toolUseID: `tool-${toolName}`, + }); + + expect(presentation._meta).toEqual({ + permission: { version: 1, title: toolName }, + }); + expect(presentation.toolCall).toMatchObject({ + title: toolName, + rawInput: input, + }); + }, + ); + + it("keeps the WebFetch URL in structured tool input", () => { + const input = { url: "https://example.com/docs", prompt: "Read the API reference" }; + const presentation = buildClaudePermissionPresentation({ + toolName: "WebFetch", + input, + toolUseID: "tool-web-fetch", + description: "https://example.com/docs", + }); + + expect(presentation._meta).toEqual({ + permission: { version: 1, title: "Fetch https://example.com/docs" }, + }); + expect(presentation.toolCall).toMatchObject({ + kind: "fetch", + rawInput: input, + }); + expect(presentation.toolCall.rawInput).toBe(input); + }); + + it("keeps the WebSearch query out of the permission description", () => { + const query = "Agent Client Protocol ACP specification subagents v2"; + const presentation = buildClaudePermissionPresentation({ + toolName: "WebSearch", + input: { query }, + toolUseID: "tool-web-search", + displayName: "WebSearch", + description: "Agent Client Protocol ACP specification subagents…", + }); + + expect(presentation._meta).toEqual({ + permission: { + version: 1, + title: 'Search "Agent Client Protocol ACP specification subagents v2"', + }, + }); + expect(presentation.toolCall.title).toBe( + 'Search "Agent Client Protocol ACP specification subagents v2"', + ); + }); + + it.each([ + ["Agent", { description: "Find the implementation" }, "Find the implementation"], + ["Task", { description: "Review the tests" }, "Review the tests"], + ["ReviewArtifact", {}, "ReviewArtifact"], + ["Workflow", {}, "Workflow"], + ["Monitor", {}, "Monitor"], + ])("reuses the %s tool-call title", (toolName, input, title) => { + expect( + buildClaudePermissionPresentation({ + toolName, + input, + toolUseID: `tool-${toolName}`, + displayName: toolName, + })._meta, + ).toEqual({ permission: { version: 1, title } }); + }); + + it("reuses tool-call titles and temporarily exposes decisionReason", () => { + expect( + buildClaudePermissionPresentation({ + toolName: "Read", + input: { file_path: "/work/a.ts" }, + toolUseID: "tool-2", + title: "Claude wants to read /work/a.ts", + description: "Read a.ts", + decisionReason: "Needed to inspect the dependency.", + })._meta, + ).toEqual({ + permission: { + version: 1, + title: "Read /work/a.ts", + description: "Reason: Needed to inspect the dependency.", + }, + }); + expect( + buildClaudePermissionPresentation({ + toolName: "Read", + input: { file_path: "/work/a.ts" }, + toolUseID: "tool-2b", + displayName: "Inspect file", + description: "Read a.ts", + })._meta, + ).toEqual({ + permission: { + version: 1, + title: "Read /work/a.ts", + }, + }); + expect( + buildClaudePermissionPresentation({ + toolName: "Read", + input: {}, + toolUseID: "tool-3", + decisionReason: "internal_policy_code", + })._meta, + ).toEqual({ + permission: { + version: 1, + title: "Read File", + description: "Reason: internal_policy_code", + }, + }); + }); + + it.each([ + ["Read", { file_path: "/work/AGENTS.md" }, "Read AGENTS.md"], + ["Edit", { file_path: "/work/a.ts" }, "Edit a.ts"], + ["Write", { file_path: "/work/a.ts" }, "Write a.ts"], + ["NotebookEdit", { notebook_path: "/work/a.ipynb" }, "Edit a.ipynb"], + ["Glob", { pattern: "**/*.ts" }, "Find **/*.ts"], + ["Grep", { pattern: "permission" }, "Search for permission"], + ["Bash", { command: "npm test" }, "Run npm test"], + ["PowerShell", { command: "Get-ChildItem" }, "List files"], + ["WebFetch", { url: "https://example.com" }, "https://example.com"], + ["WebSearch", { query: "ACP permissions" }, "ACP permissions"], + ["Skill", { skill: "testing" }, "Use testing skill"], + ["mcp__demo__deploy", { target: "staging" }, "Deploy to staging"], + ])( + "does not use the %s operation subtitle as a permission explanation", + (toolName, input, description) => { + const presentation = buildClaudePermissionPresentation({ + toolName, + input, + toolUseID: `tool-${toolName}`, + description, + }); + + expect(presentation._meta?.permission).not.toHaveProperty("description"); + }, + ); + + it("adds a non-duplicated blocked path to standard locations", () => { + const presentation = buildClaudePermissionPresentation({ + toolName: "Read", + input: { file_path: "/work/a.ts" }, + toolUseID: "tool-4", + blockedPath: "/outside/b.ts", + }); + expect(presentation.toolCall.locations).toEqual([ + { path: "/work/a.ts", line: 1 }, + { path: "/outside/b.ts" }, + ]); + }); + + it("reuses the standard title for an unknown tool", () => { + const presentation = buildClaudePermissionPresentation({ + toolName: "mcp__demo__deploy", + input: { target: "staging" }, + toolUseID: "tool-5", + }); + expect(presentation.toolCall).toMatchObject({ kind: "other", name: "mcp__demo__deploy" }); + expect(presentation._meta).toEqual({ + permission: { version: 1, title: "mcp__demo__deploy" }, + }); + }); +}); diff --git a/src/tests/resolve-permission-mode.test.ts b/src/tests/resolve-permission-mode.test.ts index 98fd2c34..39b6f587 100644 --- a/src/tests/resolve-permission-mode.test.ts +++ b/src/tests/resolve-permission-mode.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from "vitest"; -import { resolvePermissionMode, type Logger } from "../acp-agent.js"; +import type { Logger } from "../acp-agent.js"; +import { resolvePermissionMode } from "../permissions/modes.js"; function mockLogger() { const error = vi.fn<(...args: any[]) => void>(); diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 23175930..93d03b72 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -1052,26 +1052,25 @@ describe("session config options", () => { session.modes = { currentModeId, availableModes: [ - { - id: "auto", - name: "Auto", - description: "Use a model classifier to approve/deny permission prompts", - }, { id: "default", - name: "Default", - description: "Standard behavior, prompts for dangerous operations", + name: "Manual", + description: "Always ask before making changes", }, { id: "acceptEdits", - name: "Accept Edits", - description: "Auto-accept file edit operations", + name: "Accept edits", + description: "Automatically accept all file edits", }, - { id: "plan", name: "Plan Mode", description: "Planning mode" }, { - id: "dontAsk", - name: "Don't Ask", - description: "Don't prompt for permissions, deny if not pre-approved", + id: "plan", + name: "Plan", + description: "Create a plan before making changes", + }, + { + id: "auto", + name: "Auto", + description: "Claude handles permission decisions", }, ], }; @@ -1124,9 +1123,8 @@ describe("session config options", () => { expect(modeOption).toBeDefined(); const modeValues = (modeOption as any).options.map((o: any) => o.value); expect(modeValues).not.toContain("auto"); - expect(modeValues).toEqual( - expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]), - ); + expect(modeValues).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"])); + expect(modeValues).not.toContain("dontAsk"); }); it("re-adds `auto` when switching from Haiku back to Opus", async () => { @@ -1234,7 +1232,7 @@ describe("session config options", () => { }); }); - describe("ExitPlanMode permission options filtered by availableModes", () => { + describe("ExitPlanMode permission updates", () => { let capturedPermissionRequest: any; let permissionResponse: any; @@ -1257,105 +1255,34 @@ describe("session config options", () => { populateSession(); }); - it("omits the `auto` option on a model without supportsAutoMode", async () => { + it("maps an ExitPlanMode choice to the selected session mode effect", async () => { const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; - // Haiku-shaped session: availableModes does NOT include `auto`. - session.modes = { - currentModeId: "plan", - availableModes: [ - { id: "default", name: "Default", description: "Standard" }, - { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, - { id: "plan", name: "Plan Mode", description: "Planning mode" }, - { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, - ], - }; - - // The tool_call was already surfaced (by the streamed tool_use chunk), so - // the permission request won't re-emit one — keep this focused on options. + session.modes = { ...session.modes, currentModeId: "plan" }; session.emittedToolCalls.add("toolu_1"); - - const canUseTool = (agent as any).canUseTool(SESSION_ID); - const signal = new AbortController().signal; - try { - await canUseTool( - "ExitPlanMode", - { plan: "do stuff" }, - { signal, suggestions: undefined, toolUseID: "toolu_1" }, - ); - } catch { - // The mock client returns `cancelled`, which makes canUseTool throw. - // We only care about the captured requestPermission options. - } - - expect(capturedPermissionRequest).not.toBeNull(); - const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); - expect(optionIds).not.toContain("auto"); - expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"])); - }); - - it("denies a selected `auto` option if the client did not receive that option", async () => { - const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; - session.modes = { - currentModeId: "plan", - availableModes: [ - { id: "default", name: "Default", description: "Standard" }, - { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, - { id: "plan", name: "Plan Mode", description: "Planning mode" }, - { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, - ], - }; - permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } }; - // The tool_call was already surfaced (by the streamed tool_use chunk), so - // the permission request won't re-emit one — the deny path below should - // produce no session updates at all. - session.emittedToolCalls.add("toolu_2"); - - const canUseTool = (agent as any).canUseTool(SESSION_ID); - const result = await canUseTool( + permissionResponse = { outcome: { outcome: "selected", optionId: "exit-plan-default" } }; + const result = await (agent as any).canUseTool(SESSION_ID)( "ExitPlanMode", { plan: "do stuff" }, - { signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_1", + }, ); - expect(capturedPermissionRequest).not.toBeNull(); - const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); - expect(optionIds).not.toContain("auto"); - expect(result.behavior).toBe("deny"); + expect(capturedPermissionRequest.options.map((option: any) => option.optionId)).toEqual([ + "exit-plan-default", + "exit-plan-clear-accept-edits", + "exit-plan-accept-edits", + "reject", + ]); + expect(capturedPermissionRequest._meta).toEqual({ + permission: { version: 1, title: "Ready to code?" }, + }); + expect(result.updatedPermissions).toEqual([ + { type: "setMode", mode: "default", destination: "session" }, + ]); + expect(session.modes.currentModeId).toBe("plan"); expect(sessionUpdates).toHaveLength(0); }); - - it("includes the `auto` option on a model with supportsAutoMode", async () => { - const session = (agent as unknown as { sessions: Record }).sessions[SESSION_ID]; - session.modes = { - currentModeId: "plan", - availableModes: [ - { id: "auto", name: "Auto", description: "Use a model classifier" }, - { id: "default", name: "Default", description: "Standard" }, - { id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" }, - { id: "plan", name: "Plan Mode", description: "Planning mode" }, - { id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" }, - ], - }; - - // The tool_call was already surfaced (by the streamed tool_use chunk), so - // the permission request won't re-emit one — keep this focused on options. - session.emittedToolCalls.add("toolu_3"); - - const canUseTool = (agent as any).canUseTool(SESSION_ID); - const signal = new AbortController().signal; - try { - await canUseTool( - "ExitPlanMode", - { plan: "do stuff" }, - { signal, suggestions: undefined, toolUseID: "toolu_3" }, - ); - } catch { - // mock returns cancelled - } - - expect(capturedPermissionRequest).not.toBeNull(); - const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId); - expect(optionIds).toContain("auto"); - }); }); }); diff --git a/src/tests/tools.test.ts b/src/tests/tools.test.ts index 62585cf7..e00db6d7 100644 --- a/src/tests/tools.test.ts +++ b/src/tests/tools.test.ts @@ -939,6 +939,7 @@ describe("Bash terminal output", () => { // terminal_info and terminal_output should NOT be on the exit notification expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_info"); expect((exitUpdate as any)._meta).not.toHaveProperty("terminal_output"); + expect(exitUpdate).not.toHaveProperty("rawOutput"); }); it("should not include terminal _meta when client does not declare terminal_output support", () => { @@ -961,6 +962,7 @@ describe("Bash terminal output", () => { expect((update as any)._meta).not.toHaveProperty("terminal_info"); expect((update as any)._meta).not.toHaveProperty("terminal_output"); expect((update as any)._meta).not.toHaveProperty("terminal_exit"); + expect(update).toHaveProperty("rawOutput", bashResult); }); it("should not include terminal _meta when _meta.terminal_output is false", () => { @@ -1683,7 +1685,7 @@ describe("toolInfoFromToolUse - ExitPlanMode", () => { const info = toolInfoFromToolUse(toolUse, false); expect(info.kind).toBe("switch_mode"); - expect(info.title).toBe("Ready to code?"); + expect(info.title).toBe("Approve Plan"); expect(info.content).toHaveLength(1); expect(info.content![0]).toEqual({ type: "content", @@ -1732,6 +1734,16 @@ describe("toolInfoFromToolUse - undefined input regression", () => { expect(info.title).toBe("Web search"); }); + it("shows a WebSearch query in the tool title", () => { + const toolUse = { + name: "WebSearch", + id: "toolu_ws_query", + input: { query: "Agent Client Protocol ACP specification subagents v2" }, + }; + const info = toolInfoFromToolUse(toolUse, false); + expect(info.title).toBe('Search "Agent Client Protocol ACP specification subagents v2"'); + }); + it("TodoWrite with undefined input should not throw", () => { const toolUse = { name: "TodoWrite", id: "toolu_todo_undef", input: undefined }; const info = toolInfoFromToolUse(toolUse, false); @@ -2734,6 +2746,62 @@ describe("tool_result_meta non-execution stamping", () => { content: "The user doesn't want to proceed with this tool use.", }; + it("removes Claude's outer fence from a rejected ExitPlanMode explanation only", () => { + const toolUseCache: ToolUseCache = { + toolu_plan: { + type: "tool_use", + id: "toolu_plan", + name: "ExitPlanMode", + input: { plan: "Implement it" }, + }, + }; + + const notifications = toAcpNotifications( + [ + { + type: "tool_result", + tool_use_id: "toolu_plan", + is_error: true, + content: "```\nThe user chose to keep planning.\n```", + }, + ] as any, + "user", + "test-session", + toolUseCache, + mockClient, + mockLogger, + { toolResultMeta: [{ id: "toolu_plan", non_execution_kind: "user-rejected" }] }, + ); + + expect(notifications).toHaveLength(1); + expect(notifications[0].update).toMatchObject({ + sessionUpdate: "tool_call_update", + toolCallId: "toolu_plan", + status: "failed", + rawOutput: "The user chose to keep planning.", + }); + }); + + it("preserves fenced output from tools other than ExitPlanMode", () => { + const fenced = "```text\ncommand output\n```"; + const notifications = toAcpNotifications( + [ + { + type: "tool_result", + tool_use_id: "toolu_bash", + content: fenced, + }, + ] as any, + "user", + "test-session", + { toolu_bash: bashToolUse }, + mockClient, + mockLogger, + ); + + expect(notifications[0].update).toMatchObject({ rawOutput: fenced }); + }); + it("stamps nonExecutionKind and userFeedback on the failed tool_call_update", () => { const toolUseCache: ToolUseCache = { toolu_bash: bashToolUse }; diff --git a/src/tool-result-meta.ts b/src/tool-result-meta.ts new file mode 100644 index 00000000..1c795bed --- /dev/null +++ b/src/tool-result-meta.ts @@ -0,0 +1,22 @@ +export type ToolResultMeta = { + nonExecutionKind: string; + userFeedback?: string; +}; + +/** Validate the SDK's currently untyped tool_result_meta sidecar. Unknown + * non-execution kinds are preserved so newer CLIs remain forward-compatible. */ +export function parseToolResultMeta(raw: unknown): Map | undefined { + if (!Array.isArray(raw)) return undefined; + + let byToolUseId: Map | undefined; + for (const entry of raw) { + if (typeof entry !== "object" || entry === null) continue; + const { id, non_execution_kind, user_feedback } = entry as Record; + if (typeof id !== "string" || typeof non_execution_kind !== "string") continue; + (byToolUseId ??= new Map()).set(id, { + nonExecutionKind: non_execution_kind, + ...(typeof user_feedback === "string" ? { userFeedback: user_feedback } : {}), + }); + } + return byToolUseId; +} diff --git a/src/tools.ts b/src/tools.ts index 7ef20565..662f67dc 100644 --- a/src/tools.ts +++ b/src/tools.ts @@ -349,18 +349,9 @@ export function toolInfoFromToolUse( case "WebSearch": { const input = toolUse.input as WebSearchInput | undefined; - let label = input?.query ? `"${input.query}"` : "Web search"; - - if (input?.allowed_domains && input.allowed_domains.length > 0) { - label += ` (allowed: ${input.allowed_domains.join(", ")})`; - } - - if (input?.blocked_domains && input.blocked_domains.length > 0) { - label += ` (blocked: ${input.blocked_domains.join(", ")})`; - } return { - title: label, + title: input?.query ? `Search "${input.query}"` : "Web search", kind: "fetch", content: [], }; @@ -433,7 +424,7 @@ export function toolInfoFromToolUse( case "ExitPlanMode": { const planInput = toolUse.input as { plan?: string } | undefined; return { - title: "Ready to code?", + title: "Approve Plan", kind: "switch_mode", content: planInput?.plan ? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]