mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
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:
@@ -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/).
|
||||
|
||||
|
||||
243
docs/permission-extension.md
Normal file
243
docs/permission-extension.md
Normal 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
|
||||
```
|
||||
697
src/acp-agent.ts
697
src/acp-agent.ts
@@ -27,7 +27,6 @@ import {
|
||||
ndJsonStream,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PermissionOption,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
ProviderInfo,
|
||||
@@ -72,7 +71,6 @@ import {
|
||||
Options,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
PermissionUpdate,
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessageError,
|
||||
@@ -120,6 +118,11 @@ import {
|
||||
refusalFallbackResultFromResponse,
|
||||
refusalFallbackToCreateRequest,
|
||||
} from "./elicitation.js";
|
||||
import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js";
|
||||
import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js";
|
||||
import { buildClaudePermissionOptions } from "./permissions/options.js";
|
||||
import { buildClaudePermissionPresentation } from "./permissions/presentation.js";
|
||||
import { decodeClaudePermissionResponse } from "./permissions/response.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
activeUsageLimitMessage,
|
||||
@@ -169,6 +172,17 @@ import {
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import {
|
||||
acceptedPlanToolResult,
|
||||
ExitPlanCoordinator,
|
||||
executionDiagnostic,
|
||||
exitPlanModeRawOutput,
|
||||
observeExitPlanToolResults,
|
||||
} from "./exit-plan.js";
|
||||
import { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
import { parseToolResultMeta } from "./tool-result-meta.js";
|
||||
|
||||
export { DEFAULT_AGENT_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
|
||||
export const CLAUDE_CONFIG_DIR =
|
||||
process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
@@ -419,6 +433,7 @@ type Turn = {
|
||||
/** What a steered turn settles with once its steered work has run: the outcome
|
||||
* of its latest result, so its usage covers every cycle the turn ran. */
|
||||
steeredSettle?: PromptResponse;
|
||||
carriedUsage?: AccumulatedUsage;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
/** Settles after the ACP prompt request completes, regardless of outcome. */
|
||||
@@ -576,6 +591,7 @@ export type Session = {
|
||||
* fresh session it stalls until the first turn runs (see the seeding call
|
||||
* sites and `contextWindowCache`). */
|
||||
contextWindowSize: number;
|
||||
contextUsedTokens?: number;
|
||||
/** Whether `contextWindowSize` came from an authoritative source (the
|
||||
* cross-session cache, a resumed session's `getContextUsage` report, or a
|
||||
* `result.modelUsage`) rather than the text heuristic / default. Guards the
|
||||
@@ -610,6 +626,17 @@ export type Session = {
|
||||
* tool_use block streams; this set makes the two paths converge regardless of
|
||||
* order. Pruned at `tool_result` time alongside `toolUseCache`. */
|
||||
emittedToolCalls: Set<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,
|
||||
};
|
||||
}
|
||||
|
||||
163
src/clear-context-coordinator.ts
Normal file
163
src/clear-context-coordinator.ts
Normal 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
217
src/exit-plan.ts
Normal 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
223
src/permissions/effects.ts
Normal 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
59
src/permissions/modes.ts
Normal 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;
|
||||
}
|
||||
130
src/permissions/normalization.ts
Normal file
130
src/permissions/normalization.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
86
src/permissions/options.ts
Normal file
86
src/permissions/options.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
186
src/permissions/options/filesystem.ts
Normal file
186
src/permissions/options/filesystem.ts
Normal 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");
|
||||
}
|
||||
105
src/permissions/options/shared.ts
Normal file
105
src/permissions/options/shared.ts
Normal 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)];
|
||||
}
|
||||
121
src/permissions/options/shell.ts
Normal file
121
src/permissions/options/shell.ts
Normal 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");
|
||||
}
|
||||
170
src/permissions/options/tools.ts
Normal file
170
src/permissions/options/tools.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
112
src/permissions/presentation.ts
Normal file
112
src/permissions/presentation.ts
Normal 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 } : {}),
|
||||
},
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
36
src/permissions/response.ts
Normal file
36
src/permissions/response.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
4
src/session-config-ids.ts
Normal file
4
src/session-config-ids.ts
Normal 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";
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"],
|
||||
|
||||
386
src/tests/clear-context-coordinator.test.ts
Normal file
386
src/tests/clear-context-coordinator.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
352
src/tests/live-capability-events-audit.test.ts
Normal file
352
src/tests/live-capability-events-audit.test.ts
Normal 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);
|
||||
});
|
||||
417
src/tests/live-capability-session-audit.test.ts
Normal file
417
src/tests/live-capability-session-audit.test.ts
Normal 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);
|
||||
});
|
||||
347
src/tests/permission-effects.test.ts
Normal file
347
src/tests/permission-effects.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
38
src/tests/permission-normalization.test.ts
Normal file
38
src/tests/permission-normalization.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
593
src/tests/permission-options.test.ts
Normal file
593
src/tests/permission-options.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
309
src/tests/permission-presentation.test.ts
Normal file
309
src/tests/permission-presentation.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>();
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
22
src/tool-result-meta.ts
Normal 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;
|
||||
}
|
||||
13
src/tools.ts
13
src/tools.ts
@@ -349,18 +349,9 @@ export function toolInfoFromToolUse(
|
||||
|
||||
case "WebSearch": {
|
||||
const input = toolUse.input as WebSearchInput | undefined;
|
||||
let label = input?.query ? `"${input.query}"` : "Web search";
|
||||
|
||||
if (input?.allowed_domains && input.allowed_domains.length > 0) {
|
||||
label += ` (allowed: ${input.allowed_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
if (input?.blocked_domains && input.blocked_domains.length > 0) {
|
||||
label += ` (blocked: ${input.blocked_domains.join(", ")})`;
|
||||
}
|
||||
|
||||
return {
|
||||
title: label,
|
||||
title: input?.query ? `Search "${input.query}"` : "Web search",
|
||||
kind: "fetch",
|
||||
content: [],
|
||||
};
|
||||
@@ -433,7 +424,7 @@ export function toolInfoFromToolUse(
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string } | undefined;
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
title: "Approve Plan",
|
||||
kind: "switch_mode",
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
|
||||
Reference in New Issue
Block a user