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)
This commit is contained in:
nikita-ashihmin
2026-08-22 14:59:03 +04:00
committed by GitHub
parent ee9f300db4
commit 996d488589
30 changed files with 5492 additions and 648 deletions

View File

@@ -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/).

View File

@@ -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:<hostname>` 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
```

View File

@@ -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<string>;
/** 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<string, PermissionMode> = {
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<string, string> {
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<string, unknown> {
const effectiveSuggestions =
suggestions && suggestions.length > 0
? suggestions
: [
{
type: "addRules" as const,
rules: [{ toolName }],
behavior: "allow" as const,
destination: "session" as const,
},
];
const changes: Array<Record<string, unknown>> = [];
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<T>(operation: Promise<T>, signal: AbortSignal): Promise<T> {
return new Promise<T>((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<void> | null = null;
private readonly exitPlan: ExitPlanCoordinator<Session, Turn>;
/** 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<Session, Turn>({
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<InitializeResponse> {
@@ -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<void> {
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<RequestPermissionResponse> {
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<void> {
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<NewSessionResponse> {
// 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<AgentInfo[]> {
* 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<string, { nonExecutionKind: string; userFeedback?: string }> | undefined {
if (!Array.isArray(raw)) {
return undefined;
}
let byToolUseId: Map<string, { nonExecutionKind: string; userFeedback?: string }> | undefined;
for (const entry of raw) {
if (typeof entry !== "object" || entry === null) {
continue;
}
const { id, non_execution_kind, user_feedback } = entry as Record<string, unknown>;
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,
};
}

View File

@@ -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<Turn extends ClearContextTurn = ClearContextTurn> = {
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>,
Turn extends ClearContextTurn,
> = {
currentSession(sessionId: string): Session | undefined;
closeQueryStream(session: Session): void;
restartSession(
params: NewSessionRequest,
options: { publicSessionId: string; permissionMode: PermissionMode },
): Promise<Session>;
applyFastMode(session: Session, enabled: boolean): Promise<void>;
publishSessionState(
sessionId: string,
mode: PermissionMode,
configOptions: SessionConfigOption[],
): Promise<void>;
continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage;
ensureConsumer(session: Session, sessionId: string): void;
logError(message: string, error: unknown): void;
};
function restartParams<Session extends ClearContextSession>(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<string, unknown>) | 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<Turn>,
>(
sessionId: string,
oldSession: Session,
reset: ClearContextReset,
host: ClearContextCoordinatorHost<Session, Turn>,
signal?: AbortSignal,
): Promise<void> {
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);
}

217
src/exit-plan.ts Normal file
View File

@@ -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<string, { name: string } | undefined>;
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<string, unknown>;
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>,
Turn extends ClearContextTurn,
> = Omit<
ClearContextCoordinatorHost<Session, Turn>,
"publishSessionState" | "continuationMessage"
> & {
sessionUpdate(notification: SessionNotification): Promise<void>;
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>,
Turn extends ClearContextTurn,
> {
private readonly restarts = new Map<string, AbortController>();
constructor(private readonly host: ExitPlanRestartHost<Session, Turn>) {}
cancel(sessionId: string): void {
this.restarts.get(sessionId)?.abort();
}
async restart(sessionId: string, oldSession: Session, reset: ClearContextReset): Promise<void> {
this.cancel(sessionId);
const controller = new AbortController();
this.restarts.set(sessionId, controller);
try {
const clearContextHost: ClearContextCoordinatorHost<Session, Turn> = {
...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);
}
}
}
}

223
src/permissions/effects.ts Normal file
View File

@@ -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<string, unknown>;
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, unknown>): 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<string, unknown>): 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),
);
}
}

59
src/permissions/modes.ts Normal file
View File

@@ -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<string, PermissionMode> = {
// 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;
}

View File

@@ -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<PermissionUpdateDestination>([
"session",
"cliArg",
"userSettings",
"projectSettings",
"localSettings",
]);
const BEHAVIORS = new Set<PermissionBehavior>(["allow", "deny", "ask"]);
const MODES = new Set<PermissionMode>([
"default",
"acceptEdits",
"bypassPermissions",
"plan",
"dontAsk",
"auto",
]);
function isRecord(value: unknown): value is Record<string, unknown> {
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}

View File

@@ -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<string, unknown>;
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)];
}

View File

@@ -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");
}

View File

@@ -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,
);
}

View File

@@ -0,0 +1,112 @@
import type { RequestPermissionRequest, ToolCallLocation } from "@agentclientprotocol/sdk";
import { toolInfoFromToolUse } from "../tools.js";
export interface ClaudePermissionPresentationInput {
toolName: string;
input: Record<string, unknown>;
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<RequestPermissionRequest, "toolCall" | "_meta"> {
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 } : {}),
},
},
}
: {}),
};
}

View File

@@ -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<string, unknown>,
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 } : {}),
};
}

View File

@@ -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";

View File

@@ -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 () => {

View File

@@ -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<RequestPermissionResponse>(() => {}),
} 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<void>(() => {}),
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<ReturnType<typeof createResultMessage>> = { ...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<any>();
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<never>(() => {});
})(),
),
input: new Pushable(),
activeTurn: turn,
turnQueue: [turn],
creationParams: { cwd: "/test", mcpServers: [] },
});
agent.sessions["test-session"] = oldSession;
let finishCreation!: () => void;
let freshSession: ReturnType<typeof mockSessionState> | undefined;
let freshInputPush: ReturnType<typeof vi.fn> | undefined;
vi.spyOn(agent as any, "createSession").mockImplementation(
() =>
new Promise<void>((resolve) => {
finishCreation = () => {
const freshInput = new Pushable();
freshInputPush = vi.spyOn(freshInput, "push");
freshSession = mockSessionState({
query: wrapQuery(
(async function* () {
yield await new Promise<never>(() => {});
})(),
),
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"],

View File

@@ -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<ClearContextTurn>;
function testSession(overrides: Partial<TestSession> = {}): 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<TestSession, ClearContextTurn> {
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<TestSession>((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<TestSession>((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<TestSession, ClearContextTurn>(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<TestSession, ClearContextTurn>(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");
});
});

View File

@@ -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<string, unknown>;
type LiveHarness = {
agent: ClaudeAcpAgent;
cwd: string;
sessionId: string;
updates: SessionNotification[];
permissionRequests: RequestPermissionRequest[];
events: Array<{ kind: "update" | "permission"; toolCallId?: string }>;
rawMessages: JsonObject[];
dispose: () => Promise<void>;
};
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<string, number> {
const counts: Record<string, number> = {};
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<void> {
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<LiveHarness> {
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);
});

View File

@@ -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<string, unknown>;
type LiveHarness = {
agent: ClaudeAcpAgent;
cwd: string;
sessionId: string;
updates: SessionNotification[];
permissionRequests: RequestPermissionRequest[];
events: Array<{ kind: "update" | "permission"; toolCallId?: string }>;
rawMessages: JsonObject[];
dispose: () => Promise<void>;
};
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<string, number> {
const counts: Record<string, number> = {};
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<void> {
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<LiveHarness> {
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);
});

View File

@@ -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<typeof decodeClaudePermissionResponse>) =>
decodeClaudePermissionResponse(...args).permissionResult;
const rule = { toolName: "Bash", ruleContent: "npm test:*" };
describe("Claude permission response effects", () => {
const build = (
toolName: string,
durableChangeSet?: ReturnType<typeof normalizeDurablePermissionChangeSet>,
input: Record<string, unknown> = {},
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");
});
});

View File

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

View File

@@ -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<typeof decodeClaudePermissionResponse>) =>
decodeClaudePermissionResponse(...args).permissionResult;
const rule = { toolName: "Bash", ruleContent: "npm test:*" };
describe("Claude permission options and response mapping", () => {
const build = (
toolName: string,
durableChangeSet?: ReturnType<typeof normalizeDurablePermissionChangeSet>,
input: Record<string, unknown> = {},
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");
});
});

View File

@@ -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" },
});
});
});

View File

@@ -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>();

View File

@@ -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<string, any> }).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<string, any> }).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<string, any> }).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");
});
});
});

View File

@@ -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 };

22
src/tool-result-meta.ts Normal file
View File

@@ -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<string, ToolResultMeta> | undefined {
if (!Array.isArray(raw)) return undefined;
let byToolUseId: Map<string, ToolResultMeta> | undefined;
for (const entry of raw) {
if (typeof entry !== "object" || entry === null) continue;
const { id, non_execution_kind, user_feedback } = entry as Record<string, unknown>;
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;
}

View File

@@ -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 } }]