diff --git a/AGENTS.md b/AGENTS.md index 99ea733a..06f82773 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -58,11 +58,14 @@ opening a PR. - If a result is meant to match another request's output, reuse that request's logic. - The adapter serves ACP v1 and v2. A change that is not meant for one protocol - version only must work correctly in both. `src/v2/` converts v2 requests to v1 - and reuses the v1 logic; keep v2-only differences in the conversion. -- A session runs one turn at a time. "Claude is working" means `startTurn`'s - check (an unsettled turn in `turnQueue`, or `foregroundWork` for the session); - don't write a narrower one. Anything that sends `session/update` must go + version only must work correctly in both. `ClaudeAcpAgent` serves v1, and + `src/v2/` serves v2; they share modules (the SDK mapping, tool call + rendering, permissions, config options). Put a change meant for both versions + in the shared module, or make it in both cores. +- A session runs one turn at a time. On v1, "Claude is working" means + `startTurn`'s check (an unsettled turn in `turnQueue`, or `foregroundWork` for + the session); on v2, an open turn of the v2 session. Don't write a + narrower one. Anything that sends `session/update` must go through the turn queue or be refused while the session is busy, or its output lands inside another turn. Keep the session marked busy across awaited network calls. diff --git a/README.md b/README.md index 0c44487e..4ac5e82b 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,11 @@ On v2 the adapter serves the session baseline (`session/new`, `session/list`, `s with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`, `session/fork`, additional directories, config options (the mode is one), plans, notices, compaction, display terminals for commands, permission requests, elicitation, providers, auth, and -[subagents](#subagent-sessions). Steering and the AIR extensions are v1 only for now. +[subagents](#subagent-sessions). Steering is v1 only for now. + +v2 is served by the v2 agent in `src/v2/`, which runs straight on Claude +Code instead of translating to v1. A v2 client that declares JetBrains AIR +(`_meta.jetbrains.air`) also gets the AIR extensions that ACP v2 does not cover. ## Contribution Policy diff --git a/docs/air-extensions.md b/docs/air-extensions.md index e5ed2c7a..0d505cc5 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -145,6 +145,15 @@ The agent list does not depend on the capabilities that AIR declares, except `se An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`. The response to a client that is not AIR has no `_meta.jetbrains` key. +### ACP v2 + +The v2 core (`src/v2/`) serves an AIR client only the extensions that ACP v2 does not cover. +Its `initialize` response lists `agentFileChangeReport` and `recommendedValue`. +It also serves the [permission presentation](#permission-presentation), the [question custom answers](#question-custom-answers), the [predicted follow-up prompt](#predicted-follow-up-prompt), and [load replay end](#load-replay-end). +The [agent file-change report](#agent-file-change-report) goes out at the end of the turn that serves the prompt, before the turn's idle `state_update`, because a v2 `session/prompt` is answered when Claude Code takes the prompt in. +ACP v2 covers subagents, background work, file plans, patches, compaction, the system prompt, and turn failures itself, so the v2 core serves no `nativeSubagentSessions`, `backgroundSubagents`, `asyncTasks`, `planFile`, `diffPatch`, `contextCompaction`, `customInstructions`, or `sessionFailure`. +It does not serve the [tool call contract](#tool-call-contract) yet. + ### Capabilities | Capability | Advertised | What the adapter does when the client declares it | Section | @@ -1062,7 +1071,8 @@ The adapter ignores a malformed request and a repeated request id. ### Report -The adapter sends one `session_info_update` before the `PromptResponse`: +The adapter sends one `session_info_update` before the `PromptResponse`. +On ACP v2 it comes before the idle `state_update` of the turn that serves the prompt (see [ACP v2](#acp-v2)): ```json { @@ -1638,7 +1648,8 @@ The hold protects a client that stops listening at the prompt response. ### Behavior with the capability AIR declares `backgroundSubagents` in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`. -The capability applies to ACP v1 only. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2. +The capability applies to ACP v1 only, and the agent does not advertise it to ACP v2. +ACP v2 has no hold: the v2 agent answers `session/prompt` when Claude Code takes the prompt in, lists a background subagent in its parent session's `background` while it works, and reports each subagent's work in the state of its child session (see [ACP v2](#acp-v2)). - The turn ends at its result with the stop reason of the result, usually `end_turn`. - The subagent updates, permission requests and elicitations go out after the prompt response. diff --git a/src/acp-agent.ts b/src/acp-agent.ts index f1b41d02..2203062c 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -19,7 +19,6 @@ import { ListProvidersRequest, ListProvidersResponse, McpServer, - LlmProtocol, ListSessionsRequest, ListSessionsResponse, LoadSessionRequest, @@ -31,7 +30,6 @@ import { NewSessionResponse, PromptRequest, PromptResponse, - ProviderInfo, ReadTextFileRequest, ReadTextFileResponse, RequestError, @@ -56,30 +54,23 @@ import { WriteTextFileResponse, ToolCallContent, } from "@agentclientprotocol/sdk/unstable"; -import type { ForegroundEvents, StopReason, TurnEvents, TurnOutcome } from "./turn-events.js"; +import type { StopReason, TurnEvents, TurnOutcome } from "./turn-events.js"; import { - AccountInfo, AgentInfo, CanUseTool, FastModeDisabledReason, FastModeState, - filterEscalatingDefaultMode, getSessionMessages, HookCallback, listSessions, - McpServerConfig, - McpServerStatus, ModelInfo, - ModelUsage, OnElicitation, OnUserDialog, Options, PermissionMode, PermissionResult, - PreToolUseHookInput, Query, query, - resolveSettings, SDKAssistantMessageError, SDKActiveGoalMessage, SDKMessage, @@ -88,10 +79,8 @@ import { SDKSystemMessage, SessionMessage, SDKUserMessage, - SDKUserMessageReplay, Settings, SlashCommand, - ThinkingConfig, } from "@anthropic-ai/claude-agent-sdk"; import { GOAL_ACTIONS, @@ -112,7 +101,7 @@ import { todoWritePlanUpdate, type SubagentChecklists, } from "./plans.js"; -import { promptSuggestionsMeta, promptSuggestionText } from "./prompt-suggestions.js"; +import { PromptSuggestions } from "./prompt-suggestions.js"; import { sanitizeTitle, SessionTitles } from "./session-titles.js"; import { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY, @@ -169,56 +158,44 @@ import { backgroundBashTaskFromToolResult, backgroundedBashToolCallIds, clientSupportsAsyncTasks, - taskProgressFields, } from "./async-tasks.js"; -import { workflowSnapshot } from "./workflow-progress.js"; +import { SessionBackgroundWork } from "./background-work.js"; import { - type BackgroundSink, - type BackgroundTaskEnd, - BackgroundWorkReporter, - cancelBackgroundWork, - SessionBackgroundWork, -} from "./background-work.js"; -import { - AUTH_STATUS_PROBE_TIMEOUT_MS, AUTH_STATUS_UPDATE_METHOD, type AuthStatus, type AuthStatusKind, authStatusCapability, fromAccountInfo, - fromCliStatus, gatewayAuthStatus, - mergeAuthStatus, notLoggedInAuthStatus, - sameAuthStatus, } from "./auth-status.js"; -import { agentCommand } from "./agent-command.js"; +import { AuthStatusReporter, cliLogout } from "./auth-status-reporter.js"; +import { terminalAuthMethods as authMethodsForTerminal } from "./auth-methods.js"; +import { sdkMcpServers } from "./mcp-servers.js"; import { claudeCliPath } from "./claude-cli.js"; import { ContentBlockParam } from "@anthropic-ai/sdk/resources"; import { BetaContentBlock, BetaRawContentBlockDelta } from "@anthropic-ai/sdk/resources/beta.mjs"; -import { execFile } from "node:child_process"; import { randomUUID } from "node:crypto"; import type { Stats } from "node:fs"; import * as fs from "node:fs/promises"; import * as path from "node:path"; -import { promisify } from "node:util"; import packageJson from "../package.json" with { type: "json" }; import { - applyAskElicitationResponse, - askUserQuestionsToCreateRequest, - createElicitationResponseToElicitResult, + answerAskUserQuestion, + answerMcpElicitation, + answerUserDialog, ElicitationSupport, - extractAskUserQuestions, - extractRefusalFallbackPrompt, - mcpElicitationToCreateRequest, + elicitUnlessWithdrawn, REFUSAL_FALLBACK_DIALOG_KIND, - refusalFallbackResultFromResponse, - refusalFallbackToCreateRequest, } from "./elicitation.js"; +import { + startMcpServerAuthentication as startSharedMcpServerAuthentication, + supportsMcpOAuth, +} from "./mcp-oauth.js"; import { forkSession } from "./fork-session.js"; import { clientSystemPrompt, - sdkSystemPrompt, + sessionSystemPrompt, SYSTEM_PROMPT_CAPABILITY, } from "./client-system-prompt.js"; import { @@ -246,6 +223,7 @@ import { buildClaudePermissionOptions } from "./permissions/options.js"; import { type AcpPermissionRequest, buildClaudePermissionPresentation, + permissionToolCallMeta, } from "./permissions/presentation.js"; import { decodeClaudePermissionResponse } from "./permissions/response.js"; import { SettingsManager } from "./settings.js"; @@ -273,6 +251,17 @@ import { shouldHideClaudeAuth, warnClaudeSubscriptionGuardDegraded, } from "./hide-claude-auth.js"; +import { + adapterRemoteControlCommand, + claudeCodeOffersCommand, + isRemoteHumanMessage, + parseRemoteControlCommand, + REMOTE_CONTROL_COMMAND_NAME, + type RemoteControlCommand, + type RemoteControlQuery, + runRemoteControlCommand, + supportsRemoteControl, +} from "./remote-control.js"; import { AGENT_FILE_CHANGE_REPORT_CAPABILITY, agentFileChangeReportMeta, @@ -287,18 +276,19 @@ import { ContextCompactionLifecycle, clientSupportsCompactionUpdates, contextCompactionMetadataFromBoundary, - isCompactSummaryMessage, + replayCompactSummary, } from "./context-compaction.js"; import { IdleCompaction } from "./idle-compaction.js"; import { clientSupportsNotices, collapseHookSpeaker, + informationalNotice, MAX_NOTICE_TITLE_LENGTH, + mcpServerStartupNotice, + modelRefusalFallback, normalizeNoticeText, noticeOrTranscriptUpdate, sentenceCase, - type SessionNotice, - splitNoticeText, } from "./session-notices.js"; import { applyTaskCreate, @@ -320,12 +310,25 @@ import { TaskState, unregisterHookCallback, } from "./tools.js"; -import { previewPatchContent } from "./diff.js"; import { - backgroundedBashToolCall, - withRunningTerminal, - withRunningToolCall, -} from "./tool-calls/background.js"; + addModelUsage, + DEFAULT_CONTEXT_WINDOW, + getMatchingModelUsage, + inferContextWindowFromModel, + modelUsageIncrement, + normalizeModelUsage, + quotaMeta, + resumedFirstResultModelUsage, + snapshotFromUsage, + tallyTotal, + totalTokens, + ZERO_USAGE, + type AccumulatedUsage, + type ModelTokenTally, + type UsageSnapshot, +} from "./session-usage.js"; +import { previewPatchContent } from "./diff.js"; +import { backgroundedBashToolCall, withRunningTerminal } from "./tool-calls/background.js"; import { ChangedMetaFilter } from "./tool-calls/changed-meta-filter.js"; import { ToolCallFieldTracker } from "./tool-calls/field-tracker.js"; import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js"; @@ -352,7 +355,7 @@ import { observeExitPlanToolResults, planModeUpdate, } from "./exit-plan.js"; -import { DEFAULT_AGENT_ID } from "./session-config-ids.js"; +import { AGENT_CONFIG_ID, DEFAULT_AGENT_ID } from "./session-config-ids.js"; import { parseToolResultMeta } from "./tool-result-meta.js"; import { markerTagText, @@ -364,6 +367,7 @@ import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./u export { DEFAULT_AGENT_ID } from "./session-config-ids.js"; import { MCP_AVAILABLE_COMMAND, parseMcpCommand, runMcpCommand } from "./mcp-command.js"; +import { configuredSettings, resolveThinkingConfig } from "./session-env.js"; import { MODE_CONFIG_ID, resolveInitialPermissionMode, @@ -372,7 +376,6 @@ import { import { aliasResumedModelRow, applyAvailableModelsAllowlist, - buildModelConfigOption, getAvailableModels, MODEL_CONFIG_ID, modelDisplayName, @@ -380,33 +383,86 @@ import { narrowToGatewayModels, resolveModelPreference, type SessionModelState, + withCurrentModelInfo, } from "./session-model.js"; import { - GatewayListingError, - listBedrockModels, - listGatewayModels, - PROVIDER_AUTH_TOKEN_PLACEHOLDER, - selectBedrockRegion, -} from "./gateway-models.js"; + createEnvForProvider, + defaultProviderConfig, + gatewayRequestToProviderConfig, + type GatewayAuthRequest, + isGatewayAuthMethod, + listProvidersResponse, + loggableBaseUrl, + PROVIDER_ID, + type ProviderConfig, + providerConfigFromSetRequest, + validatedGatewayAuthRequest, + withListedModels, +} from "./providers.js"; import { - buildEffortConfigOption, EFFORT_CONFIG_ID, effortFlagSettings, - effortLevelName, + effortForModelSwitch, mergeEffortSettings, + settleEffortAfterModelSwitch, settingsEffortForModel, + withoutEffort, } from "./session-effort.js"; import { createUltracodeConfigOption, readUltracodeSettings, - resolveUltracodeEnabled, ULTRACODE_CONFIG_ID, ultracodeSupported, ultracodeWorkflowsEnabled, type UltracodeOptionState, } from "./session-ultracode.js"; +import { + buildConfigOptions, + clientSupportsBooleanConfigOptions, + clientSupportsRecommendedConfigValue, + configOptionValue, + discoverCustomAgents, +} from "./session-config-options.js"; +import { + createFastModeConfigOption, + FAST_MODE_CONFIG_ID, + fastModeStateChange, + fastModeStateEnabled, + normalizeFastModeDisabledReason, + type FastModeOptionState, +} from "./session-fast-mode.js"; +import { + cacheResultContextWindow, + clearContextWindowCache, + immediateContextWindow, + providerCacheKeyFor, + readContextWindow, +} from "./context-window.js"; +import { isLoginRequiredText, resultEnd } from "./result-end.js"; import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "./startup-failure.js"; +import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "./streamed-text.js"; +import { subagentHookOptions } from "./subagent-configuration.js"; +export { + AGENT_CONFIG_ID, + BUILTIN_AGENT_NAMES, + buildConfigOptions, + clientSupportsBooleanConfigOptions, + clientSupportsRecommendedConfigValue, + type ConfigOptionPresentation, + discoverCustomAgents, + MODE_CONFIG_ID, +} from "./session-config-options.js"; +export { + createFastModeConfigOption, + FAST_MODE_CONFIG_ID, + FAST_MODE_OFF, + FAST_MODE_ON, + fastModeStateEnabled, + type FastModeOptionState, + normalizeFastModeDisabledReason, + resolveFastModeEnabled, +} from "./session-fast-mode.js"; export { EFFORT_CONFIG_ID, settingsEffortForModel } from "./session-effort.js"; export { applyAvailableModelsAllowlist, @@ -414,8 +470,8 @@ export { MODEL_CONFIG_ID, resolveModelPreference, } from "./session-model.js"; - -const execFileAsync = promisify(execFile); +// Moved to result-end.ts; kept as an export for consumers that deep-import dist/acp-agent.js. +export { isLoginRequiredText } from "./result-end.js"; /** Claude CLI emits this synthetic result when an interrupted cycle ends on * queued user input before producing any assistant content. It is a hand-off @@ -446,33 +502,6 @@ export interface Logger { warn?: (...args: any[]) => void; } -type AccumulatedUsage = { - inputTokens: number; - outputTokens: number; - cachedReadTokens: number; - cachedWriteTokens: number; -}; - -/** Per-model token tallies keyed by the model id the SDK reported them under - * (its resolved spelling, e.g. "claude-opus-5[1m]"). */ -type ModelTokenTally = Record; - -type UsageSnapshot = { - input_tokens: number; - output_tokens: number; - cache_read_input_tokens: number; - cache_creation_input_tokens: number; -}; - -const ZERO_USAGE = Object.freeze({ - input_tokens: 0, - output_tokens: 0, - cache_read_input_tokens: 0, - cache_creation_input_tokens: 0, -}); - -const DEFAULT_CONTEXT_WINDOW = 200000; - /** Floor after `session/cancel` before the adapter forces the active prompt * loop to return "cancelled". `query.interrupt()` normally makes the SDK * yield a trailing idle within milliseconds, and the loop returns through its @@ -590,110 +619,6 @@ async function structuredUsageMarkdown( } } -/** Claude Code keeps the OAuth callback listener open in the background after - * `mcpAuthenticate` returns the authorization URL. The SDK does not expose - * that listener's completion promise, so watch the server status while the - * ACP client has the URL elicitation open. */ -const MCP_OAUTH_STATUS_POLL_MS = 1_000; -const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000; - -/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in - * the public `Query` declaration, even though the method is present on the - * SDK query object and backed by Claude Code's `mcp_authenticate` control. */ -type McpOAuthQuery = Query & { - mcpAuthenticate( - serverName: string, - redirectUri?: string, - ): Promise<{ - authUrl?: string; - requiresUserAction: boolean; - callbackExpected: boolean; - redirectScheme?: "localhost" | "custom"; - callbackPort?: number; - state?: string; - }>; -}; - -function supportsMcpOAuth(query: Query): query is McpOAuthQuery { - return typeof (query as Partial).mcpAuthenticate === "function"; -} - -/** Runtime Remote Control control exposed by the pinned Agent SDK. Like - * `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's - * own `/remote-control` is terminal-bound and not offered to SDK hosts, so the - * adapter handles the command itself and answers with the session URL. */ -type RemoteControlQuery = Query & { - enableRemoteControl( - enabled: boolean, - name?: string, - ): Promise<{ session_url?: string; connect_url?: string }>; -}; - -function supportsRemoteControl(query: Query): query is RemoteControlQuery { - return typeof (query as Partial).enableRemoteControl === "function"; -} - -const REMOTE_CONTROL_COMMAND_NAME = "remote-control"; - -type RemoteControlCommand = { action: "connect"; name?: string } | { action: "disconnect" }; - -/** Parses `/remote-control [name]` and `/remote-control off` from a single-text - * prompt. Connecting is idempotent, so the command never needs to know whether - * Remote Control is already on. */ -function parseRemoteControlCommand(prompt: PromptRequest["prompt"]): RemoteControlCommand | null { - const [first] = prompt; - if (prompt.length !== 1 || first?.type !== "text") return null; - const match = /^\/remote-control(?:\s+([\s\S]*))?$/.exec(first.text.trim()); - if (!match) return null; - const argument = match[1]?.trim(); - if (argument?.toLowerCase() === "off") return { action: "disconnect" }; - return argument ? { action: "connect", name: argument } : { action: "connect" }; -} - -/** A top-level message a person typed in another client of the session, such - * as claude.ai or the Claude mobile app over Remote Control. Tool results and - * the CLI's own synthetic messages are user-role too, but carry no human - * origin. */ -function isRemoteHumanMessage(message: SDKUserMessage | SDKUserMessageReplay): boolean { - return ( - message.type === "user" && - message.origin?.kind === "human" && - message.parent_tool_use_id === null && - !message.isSynthetic - ); -} - -/** Whether Claude Code offers a slash command of this name to an ACP client. - * This is the rule `getAvailableSlashCommands` applies when it lists the - * commands, so the adapter stands in for the command exactly when the client - * sees the adapter's entry: Claude Code's terminal-bound commands are not - * offered, and a user's own command or skill of the name is never shadowed. */ -function claudeCodeOffersCommand( - commands: readonly SlashCommand[], - terminalCommands: readonly string[] | undefined, - name: string, -): boolean { - return commands.some( - (command) => command.name === name && !terminalCommands?.includes(command.name), - ); -} - -/** Wait for a polling interval, resolving false when the session is aborted. */ -function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve(false); - return new Promise((resolve) => { - const timer = setTimeout(() => { - signal.removeEventListener("abort", onAbort); - resolve(true); - }, milliseconds); - const onAbort = () => { - clearTimeout(timer); - resolve(false); - }; - signal.addEventListener("abort", onAbort, { once: true }); - }); -} - /** Error surfaced when the SDK declares a turn over (`session_state_changed: * idle`, its authoritative turn-over signal) without ever emitting the turn's * `result` — a model stream that dropped mid-turn, or an async agent that @@ -1051,9 +976,6 @@ type OpenUserInput = { /** The subagent that asked: absent for the main conversation and for a * request that names no agent. */ agentID?: string; - /** Set by a cancel that comes while the request is open (see - * `withPendingUserInput`). */ - cancelled?: boolean; }; export type Session = { @@ -1097,15 +1019,8 @@ export type Session = { /** Last goal snapshot sent to the ACP client, used to roll back an * optimistic `/goal` update when the command itself fails. */ lastPublishedGoal?: GoalSnapshot | null; - /** Last follow-up prompt sent to the ACP client (`null` after a clear), so an - * unchanged prediction is not re-notified. Undefined until the first push. */ - lastPublishedPromptSuggestion?: string | null; - /** Whether an arriving `prompt_suggestion` still answers the turn the user is - * waiting on: set at a turn's result, cleared when the next prompt arrives. - * A prediction the user outran — the next prompt was sent before the - * prediction was read off the stream — follows a turn that is already - * answered, so it is dropped instead of published mid-turn. */ - promptSuggestionExpected?: boolean; + /** The follow-up prompt the client holds, for an AIR client only. */ + promptSuggestions?: PromptSuggestions; /** Count of result messages the consumer should treat as orphans and skip * (not promote/attribute to the current head). When cancel() settles+removes * a queued turn, that turn's user message was already pushed to the SDK, so @@ -1317,8 +1232,7 @@ export type Session = { forwardSubagentText: boolean; /** The ACP permission and elicitation requests that await user input. * Parallel subagents can ask concurrently. Steering stays non-interrupting - * while any request is open; the foreground work awaits the user while one - * that blocks it is open (see {@link blocksForeground}). */ + * while any request is open, and the idle compaction waits until none is. */ openUserInputs?: Set; /** Context window size of the session's current model, carried across * prompts so mid-stream usage_update notifications report a correct `size` @@ -1382,8 +1296,8 @@ export type Session = { /** The tool uses that the consumer reached in the stream, each with whether * it dropped it as output of the work a cancel stopped (`true`, see * `dropsStoppedWorkOutput`). The first sight of a tool use decides: its - * streamed start, or its complete message. `canUseTool` and the v2 preview - * hook read it (see `isStoppedWorkToolUse`). The oldest entries are + * streamed start, or its complete message. `canUseTool` reads it (see + * `isStoppedWorkToolUse`). The oldest entries are * dropped. */ reachedToolUses?: Map; /** What waits for the consumer to reach each tool use (see @@ -1492,17 +1406,14 @@ export type Session = { * `perTaskStopAffordance`: then it spares both, and the client is expected * to stop each one itself. A client that can cancel one subagent gets the * declaration, but it has no control for a workflow unless it is AIR with - * `asyncTasks` (`_session/async_task/stop`), or gets the session's - * background work (`background.reported`, with `tool_call/cancel`), which - * `session/cancel` must leave running. So for any other such client the + * `asyncTasks` (`_session/async_task/stop`). So for any other such client the * adapter stops the workflows after the interrupt, as the interrupt did * before. */ stopsWorkflowsOnCancel?: boolean; - /** The tasks that Claude Code runs in the background for the session: the - * terminals of commands, kept for a client that renders terminals and is - * not AIR (AIR shows a command through its async tasks), and, when the - * client gets the session's background work, the tool calls of tasks and - * the `background` lists. Per CLI process, as is a session. */ + /** The terminals of the commands that Claude Code runs in the background + * for the session, kept for a client that renders terminals and is not + * AIR (AIR shows a command through its async tasks). Per CLI process, as + * is a session. */ background: SessionBackgroundWork; /** The parent tool call of each subagent that a settle bookend removed from * `liveBackgroundTasks`. The SDK can resume such a subagent under the same @@ -1657,7 +1568,7 @@ export type Session = { * Exception: a result from one of these origins that names a pending prompt * in `user_message_uuids` consumed that folded prompt and takes the user * lane (see the result handler). */ -const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet = new Set([ +export const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet = new Set([ "task-notification", "peer", "coordinator", @@ -1665,22 +1576,14 @@ const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet = new Set "observer-activity", ]); -/** The foreground work of a session (see ForegroundEvents): a prompt's turn, - * or a model cycle that Claude Code started on its own. At most one cycle - * runs at a time, so a constant names it. */ +/** The foreground work of a session: a prompt's turn, from its insertion + * until its result, or a model cycle that Claude Code started on its own + * (see `startAutonomousCycle`). At most one cycle runs at a time, so a + * constant names it. The client is told nothing of the work itself, but it + * decides what the window after a cancel drops (see `workEndReported`). */ const AUTONOMOUS_CYCLE = "autonomous-cycle"; type ForegroundOwner = Turn | typeof AUTONOMOUS_CYCLE; -/** The events of a connection that reports no foreground work: v1, whose - * client has no session state. The agent still tracks the work, which - * decides what the window after a cancel drops (see `workEndReported`). */ -const UNREPORTED_FOREGROUND: ForegroundEvents = { - running: () => {}, - awaitingUser: () => {}, - ended: () => {}, - failed: () => {}, -}; - /** Whether this turn's terminal result arrived but its settlement is being * held for background subagents it spawned (see Turn.deferredSettle). The * single spelling of the hold predicate, shared by the consumer's settle @@ -1914,144 +1817,12 @@ export type NewSessionMeta = { additionalRoots?: string[]; }; -/** `params` without `_meta.claudeCode.options.effort`. */ -function withoutEffort(params: NewSessionRequest): NewSessionRequest { - const meta = params._meta as NewSessionMeta | undefined; - if (meta?.claudeCode?.options?.effort === undefined) { - return params; - } - const options = { ...meta.claudeCode.options }; - delete options.effort; - return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } }; -} - -/** - * Extra metadata for 'gateway' authentication requests. - */ -type GatewayAuthMeta = { - /** - * These parameters are mapped to environment variables to: - * - Redirect API calls via baseUrl - * - Inject custom headers - * - Bypass the default Claude login requirement - * - * Both members are optional in the type because the payload arrives - * unvalidated from the client. `authenticate` rejects a request that lacks a - * usable `baseUrl`. - */ - gateway?: { - baseUrl?: string; - headers?: Record; - }; -}; - -type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta }; - /** The JSON-RPC code ACP assigns to `authRequired`. Read from the SDK so it * cannot drift from the errors this agent throws. */ const AUTH_REQUIRED_CODE = RequestError.authRequired().code; -const SUPPORTED_PROTOCOLS: LlmProtocol[] = ["anthropic", "bedrock", "vertex"]; -const PROVIDER_ID = "main"; -const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"; -const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com"; - -/** - * Vertex needs project + region that the standard `providers/set` payload - * (`apiType`/`baseUrl`/`headers`) does not model, so clients pass them through - * `_meta.claudeCode.vertex`. Required only when `apiType === "vertex"`. - */ -type SetProviderMeta = { - claudeCode?: { - vertex?: { - projectId: string; - region: string; - }; - }; -}; - -/** - * Resolved, non-secret + secret routing config for the `main` provider. This is - * the shared shape produced by both `providers/set` and the legacy gateway auth - * path, and consumed by {@link createEnvForProvider}. `null` means the provider - * is unconfigured (no client-managed routing in effect). - */ -type ProviderConfig = { - apiType: LlmProtocol; - baseUrl: string; - headers: Record; - /** Present only for `apiType === "vertex"`. */ - vertex?: { - projectId: string; - region: string; - }; - /** - * Model ids an `anthropic` or `bedrock` gateway listed when it was set via - * `providers/set`; the model picker offers only these. Absent when the - * listing failed or the config came from elsewhere (gateway `authenticate`), - * so nothing is narrowed. - */ - listedModels?: string[]; - /** - * The cross-region prefix of the model ids a `bedrock` gateway - * listed (`eu` for `eu.anthropic.claude-sonnet-5`), passed to the CLI as - * `ANTHROPIC_BEDROCK_REGION_PREFIX` so it calls the models under that prefix. - */ - bedrockRegionPrefix?: string; -}; - export type { ToolUpdateMeta } from "./tool-calls/renderer.js"; -/** Text or thinking that streamed live as deltas, accumulated per block. */ -type StreamedBlock = { index: number; type: "text" | "thinking"; text: string }; - -/** - * The blocks of a consolidated assistant message without the text that - * already streamed as deltas. - * - * Each assembled text/thinking block is diffed against the streamed blocks in - * document order: nothing is left if it streamed in full (the common case), - * the whole block if it never streamed (a non-streaming gateway), and just the - * tail if the stream was cut short mid-block. Matching on content rather than - * the message id keeps the dedupe robust for gateways without a stable id. - * Tool-use and other blocks pass through untouched. - */ -function unstreamedRemainder( - blocks: Block[], - streamedBlocks: StreamedBlock[], -): Block[] { - const kept: Block[] = []; - let streamPos = 0; - for (const item of blocks) { - if (item.type !== "text" && item.type !== "thinking") { - kept.push(item); - continue; - } - const block = item as Block & { text?: string; thinking?: string }; - const full = (item.type === "text" ? block.text : block.thinking) ?? ""; - // Empty assembled blocks carry nothing: drop them. - if (full.length === 0) continue; - // A streamed block of the same type whose text is a prefix of this one - // was already delivered, at least partly. A non-empty streamed text is - // required so an empty or aborted streamed block does not swallow it. - const streamed = streamedBlocks[streamPos]; - if ( - streamed && - streamed.type === item.type && - streamed.text.length > 0 && - full.startsWith(streamed.text) - ) { - streamPos++; - const remainder = full.slice(streamed.text.length); - if (remainder.length === 0) continue; - kept.push({ ...item, [item.type === "text" ? "text" : "thinking"]: remainder }); - continue; - } - kept.push(item); - } - return kept; -} - /** Attributes a report to the Agent/Task tool call of the subagent that made it. */ function stampParentToolUseId(update: SessionNotification["update"], parentToolUseId: string) { update._meta = { @@ -2342,16 +2113,12 @@ function isTaskNotificationRecord(message: unknown): boolean { return origin?.kind === "task-notification" && origin.subkind === undefined; } -/** - * True when CLI error text tells the user to sign in with `/login`. The CLI - * words this several ways — "Not logged in · Please run /login", "Not logged - * in · Run /login", "Login expired · Run /login to sign in again, or - * re-authenticate your Anthropic profile" — so match the "run /login" - * instruction rather than one sentence. - */ -export function isLoginRequiredText(text: string): boolean { - return /\brun \/login\b/i.test(text); -} +/** The session failure of an error result at one of Claude Code's limits. */ +const LIMIT_FAILURE_CATEGORY = { + error_max_budget_usd: "budget_exhausted", + error_max_turns: "context_exhausted", + error_max_structured_output_retries: "provider_error", +} as const satisfies Record; /** * True for the synthetic assistant message the CLI injects into the transcript @@ -2588,10 +2355,6 @@ type McpAuthenticationHost = { logger: Logger; }; -/** Background OAuth work is implementation state, not part of the public - * `Session` shape exposed through `ClaudeAcpAgent.sessions`. */ -const mcpAuthentications = new WeakMap>(); - /** Start OAuth for ACP-provided MCP servers that Claude reported as needing * authentication. This runs after session creation has returned so an * interactive browser flow never delays `session/new`. */ @@ -2605,147 +2368,30 @@ function startMcpAuthentication( } /** Start OAuth in the background for the servers in `requestedServers` that - * Claude reports as needing authentication. Startup and `/mcp` use it. It - * does nothing while another OAuth run of the session is in progress. */ + * Claude reports as needing authentication (see `mcp-oauth.ts`). Startup and + * `/mcp` use it. */ function startMcpServerAuthentication( host: McpAuthenticationHost, sessionId: string, requestedServers: Set, ): void { - if (!host.clientCapabilities?.elicitation?.url) return; - - const session = host.sessions[sessionId]; - if (!session || mcpAuthentications.has(session)) return; - - const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers) - .catch((error) => { - if (!session.abortController.signal.aborted) { - host.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`); - } - }) - .finally(() => { - if (mcpAuthentications.get(session) === authentication) { - mcpAuthentications.delete(session); - } - }); - mcpAuthentications.set(session, authentication); -} - -async function authenticateMcpServers( - host: McpAuthenticationHost, - sessionId: string, - query: Query, - requestedServers: Set, -): Promise { - if (!supportsMcpOAuth(query)) { - host.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication."); - return; - } - - const statuses = await query.mcpServerStatus(); - for (const status of statuses) { - if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue; - try { - await authenticateMcpServer(host, sessionId, query, status.name); - } catch (error) { - const session = host.sessions[sessionId]; - if (session && !session.abortController.signal.aborted) { - host.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`); - } - } - } -} - -/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation. - * Claude opens and owns the localhost callback listener; the ACP client only - * needs to present the returned authorization URL. */ -async function authenticateMcpServer( - host: McpAuthenticationHost, - sessionId: string, - query: McpOAuthQuery, - serverName: string, -): Promise { const session = host.sessions[sessionId]; if (!session) return; - - const login = await query.mcpAuthenticate(serverName); - if (!login.requiresUserAction) return; - if (!login.authUrl) { - throw new Error("Claude Code requested user action without returning an authorization URL"); - } - - const elicitationId = `mcp-oauth-${randomUUID()}`; - const flowAbort = new AbortController(); - const abortFlow = () => flowAbort.abort(session.abortController.signal.reason); - session.abortController.signal.addEventListener("abort", abortFlow, { once: true }); - if (session.abortController.signal.aborted) abortFlow(); - - try { - const completed = waitForMcpAuthentication( - host.sessions, + startSharedMcpServerAuthentication( + { + urlElicitation: !!host.clientCapabilities?.elicitation?.url, + createElicitation: (params, signal) => host.client.createElicitation(params, signal), + completeElicitation: (params) => host.client.completeElicitation(params), + logger: host.logger, + }, + { + key: session, sessionId, - query, - serverName, - flowAbort.signal, - ); - const elicitation = host.client.createElicitation( - { - mode: "url", - sessionId, - message: `Authenticate with MCP server ${serverName}`, - url: login.authUrl, - elicitationId, - }, - flowAbort.signal, - ); - const first = await Promise.race([ - completed.then((authenticated) => ({ type: "completed" as const, authenticated })), - elicitation.then((response) => ({ type: "elicitation" as const, response })), - ]); - - // A url-mode accept carries no content to check. The guard - // `CreateElicitationResponse.isAccept` would also reject an accept whose - // `content` holds a v2-only value, such as an object. - if (first.type === "elicitation" && first.response.action !== "accept") { - return; - } - - if (first.type === "elicitation") { - await completed; - } - try { - await host.client.completeElicitation({ elicitationId }); - } catch (error) { - if (!flowAbort.signal.aborted) { - host.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`); - } - } - } finally { - flowAbort.abort(); - session.abortController.signal.removeEventListener("abort", abortFlow); - } -} - -async function waitForMcpAuthentication( - sessions: Record, - sessionId: string, - query: Query, - serverName: string, - signal: AbortSignal, -): Promise { - const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS; - while (!signal.aborted && Date.now() < deadline) { - const session = sessions[sessionId]; - if (!session || session.query !== query) return false; - - const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find( - (server) => server.name === serverName, - ); - if (!status || status.status === "failed" || status.status === "disabled") return false; - if (status.status === "connected") return true; - if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false; - } - return false; + currentQuery: () => host.sessions[sessionId]?.query, + closed: session.abortController.signal, + }, + requestedServers, + ); } export class ClaudeAcpAgent { @@ -2766,31 +2412,22 @@ export class ClaudeAcpAgent { /** Per session: queue that runs mode/config changes and query restarts one at a time. */ private readonly configurationChanges = new Map>(); private readonly exitPlan: ExitPlanCoordinator; - /** Last auth identity reported to the client, connection-scoped like - * `authenticate`/`logout`. Undefined means "not determined yet". */ - currentAuthStatus?: AuthStatus; - private authStatusSettings?: { - manager: SettingsManager; - override?: Settings; - env?: Options["env"]; - }; - private authStatusHelper?: string; - /** In-flight `claude auth status --json` probe, shared by every caller so - * a concurrent `initialize` and start-of-prompt read never spawn two CLI - * processes. */ - private cliAuthProbe: Promise | null = null; - /** Counts auth-affecting events (`authenticate`, `logout`) on this - * connection. A probe records it at start and publishes only if it has not - * moved, so a slow read can never overwrite a newer login or logout. */ - private authEpoch = 0; - /** Keeps the "session account told us nothing" note to one line per process - * instead of one per session. */ - private loggedUninformativeAccount = false; - /** Same, for the "client provider override active" note. */ - private loggedOverriddenAccount = false; - /** Same, for the "the CLI probe timed out" warning: a wedged CLI stays wedged - * and would otherwise warn once per probe, i.e. once per user prompt. */ - private loggedProbeTimeout = false; + /** The connection's `authStatus` state and pushes, connection-scoped like + * `authenticate`/`logout`. */ + private readonly authStatus = new AuthStatusReporter({ + push: (authStatus) => this.client.extNotification(AUTH_STATUS_UPDATE_METHOD, { authStatus }), + logger: { + log: (...args) => this.logger.log(...args), + error: (...args) => this.logger.error(...args), + }, + gatewayLogin: () => this.gatewayAuthRequest !== undefined, + providerOverride: () => this.providerConfig !== undefined, + onProbe: (kind) => this.markSessionsWhoseAccountKindChanged(kind), + }); + /** Last auth identity reported to the client. Undefined means "not determined yet". */ + get currentAuthStatus(): AuthStatus | undefined { + return this.authStatus.current; + } /** 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. */ @@ -2798,12 +2435,6 @@ export class ClaudeAcpAgent { /** How long `canUseTool` or a hook waits for the consumer to reach a tool * use (see `isStoppedWorkToolUse`). Mutable so tests can shrink it. */ toolUseWaitMs: number = TOOL_USE_WAIT_MS; - /** - * The connection serves ACP v2 (`src/v2/`). The agent still speaks v1 - * types, except for the tool call content that v2 needs and v1 cannot - * express: see {@link ToolCallClientCapabilities.v2}. - */ - private readonly v2: boolean; /** * The subagent replay of each session's last load, until * {@link publishCurrentSubagentStates} sends its children's current states. @@ -2818,10 +2449,6 @@ export class ClaudeAcpAgent { string, Map >(); - /** See the `foreground` constructor option. */ - private readonly foregroundEvents?: (sessionId: string) => ForegroundEvents; - /** See the `background` constructor option. */ - private readonly backgroundWork: BackgroundWorkReporter; /** Counts the prompts `startTurn()` has received, across sessions. */ private promptsReceived = 0; /** Per session, the `promptsReceived` count when its last cancel started. @@ -2829,16 +2456,13 @@ export class ClaudeAcpAgent { * yet ends as cancelled instead of being queued. */ private readonly cancelledThrough = new Map(); /** - * The foreground work of each session while it runs, by the session id that - * the client knows, on every connection: only the `foreground` option - * reports it. A clear-context restart replaces the Claude Code session - * behind that id and moves the running turn to it, so the work belongs to the - * id, not to a {@link Session}. + * The owner of the foreground work of each session while it runs (see + * {@link ForegroundOwner}), by the session id that the client knows. A + * clear-context restart replaces the Claude Code session behind that id and + * moves the running turn to it, so the work belongs to the id, not to a + * {@link Session}. */ - private readonly foregroundWork = new Map< - string, - { owner: ForegroundOwner; events: ForegroundEvents; awaitingUser: boolean } - >(); + private readonly foregroundWork = new Map(); /** The session index (docs/air-extensions.md, "Session index"). */ private readonly sessionIndex = new sessionIndex.SessionIndexConnection({ agent: this, @@ -2849,41 +2473,18 @@ export class ClaudeAcpAgent { /** * Whether a turn ends at its result while background subagents that it - * spawned or resumed still run. Only an ACP v1 AIR client that declares + * spawned or resumed still run. Only an AIR client that declares * `backgroundSubagents` gets this. Every other client keeps the hold of * `Turn.deferredSettle`. */ private endsTurnsWithLiveSubagents(): boolean { - return ( - !this.v2 && - clientSupportsAirCapability(this.clientCapabilities, AIR_BACKGROUND_SUBAGENTS_CAPABILITY) + return clientSupportsAirCapability( + this.clientCapabilities, + AIR_BACKGROUND_SUBAGENTS_CAPABILITY, ); } - constructor( - client: AcpClient, - logger?: Logger, - options: { - v2?: boolean; - /** - * Where the agent reports a session's foreground work (see - * {@link ForegroundEvents}), called each time work starts in the session - * `sessionId`. Without it, nothing reports the work, which the agent - * still tracks: a v1 client has no session state, and learns that a turn - * ended from its prompt response. - */ - foreground?: (sessionId: string) => ForegroundEvents; - /** - * Where the agent reports the background work of each session (see - * `background-work.ts`): once when the session starts, and each time - * the work changes. Without it, nothing reports the work. - */ - background?: BackgroundSink; - } = {}, - ) { - this.v2 = options.v2 ?? false; - this.foregroundEvents = options.foreground; - this.backgroundWork = new BackgroundWorkReporter(options.background); + constructor(client: AcpClient, logger?: Logger) { this.sessions = {}; this.client = new ChangedMetaClient(client, () => this.toolCallCapabilities.air.client); this.logger = logger ?? console; @@ -2944,17 +2545,15 @@ export class ClaudeAcpAgent { async initialize(request: InitializeRequest): Promise { this.clientCapabilities = request.clientCapabilities; - this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities, { - v2: this.v2, - }); - this.sessionIndex.negotiate(request, { v2: this.v2 }); + this.toolCallCapabilities = ToolCallClientCapabilities.from(request.clientCapabilities); + this.sessionIndex.negotiate(request); // Learn the auth identity in the background: `initialize` never waits on // the CLI probe, and no snapshot rides in its response. When the probe // lands it calls `setAuthStatus`, which pushes `_auth/status_update` — the // connection's first push, and unconditional, because nothing was reported // before it. It is therefore sent after this response, never before it. - void this.probeCliAuthStatus(); + void this.authStatus.probe(); // Bypasses standard auth by routing requests through a custom Anthropic-protocol gateway. // Only offered when the client advertises `auth._meta.gateway` capability. @@ -2985,87 +2584,11 @@ export class ClaudeAcpAgent { const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true; const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true; - // Detect remote environments where the OAuth browser redirect to localhost - // won't work. This matches the SDK's internal isRemote check. In these cases, - // the `auth login` subcommand would fall back to a device-code-like manual - // flow, which doesn't work well over ACP, so we offer the TUI login instead. - const isRemote = !!( - process.env.NO_BROWSER || - process.env.SSH_CONNECTION || - process.env.SSH_CLIENT || - process.env.SSH_TTY || - process.env.CLAUDE_CODE_REMOTE - ); - const terminalAuthMethods: AuthMethod[] = []; + const terminalAuthMethods = authMethodsForTerminal({ + terminal: supportsTerminalAuth, + metaTerminal: supportsMetaTerminalAuth, + }); - if (isRemote) { - const remoteLoginMethod: AuthMethod = { - description: "Run `claude /login` in the terminal", - name: "Log in with Claude", - id: "claude-login", - type: "terminal", - args: ["--cli"], - }; - - if (supportsMetaTerminalAuth) { - const agent = agentCommand(); - remoteLoginMethod._meta = { - "terminal-auth": { - command: agent.command, - args: [...agent.args, "--cli"], - label: "Claude Login", - }, - }; - } - - if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) { - terminalAuthMethods.push(remoteLoginMethod); - } - } else { - const claudeLoginMethod: AuthMethod = { - description: "Use Claude subscription ", - name: "Claude Subscription", - id: "claude-ai-login", - type: "terminal", - args: ["--cli", "auth", "login", "--claudeai"], - }; - - const consoleLoginMethod: AuthMethod = { - description: "Use Anthropic Console (API usage billing)", - name: "Anthropic Console", - id: "console-login", - type: "terminal", - args: ["--cli", "auth", "login", "--console"], - }; - - if (supportsMetaTerminalAuth) { - const agent = agentCommand(); - claudeLoginMethod._meta = { - "terminal-auth": { - command: agent.command, - args: [...agent.args, "--cli", "auth", "login", "--claudeai"], - label: "Claude Login", - }, - }; - consoleLoginMethod._meta = { - "terminal-auth": { - command: agent.command, - args: [...agent.args, "--cli", "auth", "login", "--console"], - label: "Anthropic Console Login", - }, - }; - } - - if (!shouldHideClaudeAuth() && (supportsTerminalAuth || supportsMetaTerminalAuth)) { - terminalAuthMethods.push(claudeLoginMethod); - } - if (supportsTerminalAuth || supportsMetaTerminalAuth) { - terminalAuthMethods.push(consoleLoginMethod); - } - } - - // The subagents RFD has only the client capability. AIR's draft also had - // this agent capability, which AIR gets (`docs/air-extensions.md`). const sessionCapabilities: SubagentAwareSessionCapabilities = { additionalDirectories: {}, close: {}, @@ -3133,8 +2656,7 @@ export class ClaudeAcpAgent { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, - // ACP v2 keeps the hold (see `src/v2/prompt.ts`). - ...(this.v2 ? [] : [AIR_BACKGROUND_SUBAGENTS_CAPABILITY]), + AIR_BACKGROUND_SUBAGENTS_CAPABILITY, ...this.sessionIndex.capabilities(), ), AIR_GOAL_KEY, @@ -3363,39 +2885,17 @@ export class ClaudeAcpAgent { /** * `authenticate` — the legacy gateway methods store a provider override that - * every later session reads. Validate the payload here, with the same base - * URL rule as `providers/set`. An unchecked payload either throws a - * `TypeError` deep in session creation, or installs an empty base URL that - * silently turns the `--hide-claude-auth` subscription guard off. - * - * A call that carries no gateway payload at all keeps its historical - * meaning: it installs no override and succeeds. That has always been a - * no-op here, and a client that probes the method this way must keep - * working. Only a payload that IS present has to be usable. + * every later session reads, once {@link validatedGatewayAuthRequest} accepts + * its payload. */ async authenticate(_params: AuthenticateRequest): Promise { - if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") { - const gateway = (_params as GatewayAuthRequest)._meta?.gateway; - if (gateway !== undefined && gateway !== null) { - if (typeof gateway !== "object" || !isValidBaseUrl(gateway.baseUrl)) { - throw RequestError.invalidParams( - { baseUrl: (gateway as { baseUrl?: unknown }).baseUrl }, - "`_meta.gateway.baseUrl` must be a non-empty absolute http(s) URL.", - ); - } - if (gateway.headers !== undefined && typeof gateway.headers !== "object") { - throw RequestError.invalidParams( - undefined, - "`_meta.gateway.headers` must be an object of header names to values.", - ); - } - } - this.gatewayAuthRequest = _params as GatewayAuthRequest; + if (isGatewayAuthMethod(_params.methodId)) { + this.gatewayAuthRequest = validatedGatewayAuthRequest(_params); // The gateway holds the credentials from here on, so it replaces // whatever the CLI store reported. Bumping the epoch discards any probe // that started before this login: its answer is now stale. - this.authEpoch += 1; - this.setAuthStatus( + this.authStatus.changed(); + this.authStatus.set( gatewayAuthStatus(gatewayRequestToProviderConfig(this.gatewayAuthRequest)?.baseUrl), ); return; @@ -3406,205 +2906,9 @@ export class ClaudeAcpAgent { ); } - /** - * Stores `next` and pushes it to the client. - * - * A push goes out only when the payload changed. The identity is read on many - * occasions — each session create, each guarded turn, the start of each user - * prompt — and almost all of them see the same login. Clients replace their - * whole state on each update and tolerate duplicates, so a repeat is - * harmless, but it is also pure noise; {@link sameAuthStatus} drops it. - * - * What the agent owes is truth — a stale or uninformative source must not - * reach here at all (see the epoch check in the probe and the guards at - * session create). - */ + /** Stores `next` and pushes it to the client when it changed (see {@link AuthStatusReporter.set}). */ setAuthStatus(next: AuthStatus | undefined): void { - if (!next) { - return; - } - if (sameAuthStatus(this.currentAuthStatus, next)) { - return; - } - this.currentAuthStatus = next; - // ACP notifications get no reply, and clients that do not know the method - // drop it silently — send unconditionally and never fail a caller on it. - void Promise.resolve() - .then(() => this.client.extNotification(AUTH_STATUS_UPDATE_METHOD, { authStatus: next })) - .catch((error) => { - this.logger.error("Failed to send _auth/status_update:", error); - }); - } - - /** - * Report the identity behind a session's `AccountInfo`. - * - * `AccountInfo` is richer than the CLI probe (it is what the live query - * actually authenticates with). Called before the `--hide-claude-auth` guard - * may refuse the session or the turn: that flag blocks the *use* of a - * subscription, it does not make the state secret, and a refusal is when the - * client most needs to know the account it was refused for. - * - * Two cases skip it: - * - * - ACP gateway *authentication* (`gatewayAuthRequest`): the gateway, not - * this account, is the agent-owned identity — already reported as - * `kind: "gateway"` by `authenticate`. - * - A client-driven provider override (`providers/set`): the session then - * routes through the client's endpoint and `AccountInfo` describes that - * route (e.g. `apiProvider: "gateway"`), which is NOT agent-owned state. - * `authStatus` reports the agent's own login only, so the CLI probe — - * which reads the credential store the override never touches — stays - * authoritative. - * - * An account with no identity signal (e.g. `{apiProvider: "firstParty"}` - * under an apiKeyHelper) means "nothing to add", not "logged out" — keep - * what the CLI probe already established instead of overwriting it. - */ - private useAuthStatusSettings( - manager: SettingsManager, - settingsOverride?: Settings, - launchEnv?: Options["env"], - ): void { - const helper = manager.getApiKeyHelper(settingsOverride); - const changed = - manager.getCwd() !== (this.authStatusSettings?.manager.getCwd() ?? process.cwd()) || - helper !== this.authStatusHelper || - JSON.stringify(launchEnv) !== JSON.stringify(this.authStatusSettings?.env); - this.authStatusSettings = { manager, override: settingsOverride, env: launchEnv }; - this.authStatusHelper = helper; - if (changed) { - // The epoch bump discards any probe that read the old context, and the - // session account may carry no identity signal (an apiKeyHelper), so - // only a probe in the new context can classify it. - this.authEpoch += 1; - void this.probeCliAuthStatus({ fresh: true }); - } - } - - private publishSessionAccountIdentity( - account: AccountInfo | undefined, - apiKeyHelper?: string, - ): void { - if (this.providerConfig) { - if (!this.loggedOverriddenAccount) { - this.loggedOverriddenAccount = true; - this.logger.log( - "[authStatus] client provider override active; keeping agent-owned probe state", - ); - } - return; - } - if (this.gatewayAuthRequest) { - return; - } - const fromSession = fromAccountInfo(account, apiKeyHelper); - if (fromSession) { - this.setAuthStatus(fromSession); - } else if (!this.loggedUninformativeAccount) { - this.loggedUninformativeAccount = true; - this.logger.log("[authStatus] session account carries no identity signal; keeping probe"); - } - } - - /** Shares one in-flight `claude auth status --json` run between callers. The - * promise is released once settled so a later call re-probes instead of - * replaying a stale verdict. `fresh` forces a new run even when one is in - * flight — `logout` needs a read that started after the credentials were - * cleared. */ - private probeCliAuthStatus(options?: { fresh?: boolean }): Promise { - if (!options?.fresh && this.cliAuthProbe) { - return this.cliAuthProbe; - } - const probe = this.runCliAuthProbe(); - this.cliAuthProbe = probe; - void probe.finally(() => { - if (this.cliAuthProbe === probe) { - this.cliAuthProbe = null; - } - }); - return probe; - } - - /** Never rejects: an unavailable CLI means "not reported", not an error. */ - private async runCliAuthProbe(): Promise { - // Monotonicity: a read that started before the connection's latest - // auth-affecting event describes a world that no longer exists. Remember - // which one this read belongs to and drop the answer if it moved on. - const epoch = this.authEpoch; - const context = this.authStatusSettings; - const apiKeyHelper = context?.manager.getApiKeyHelper(context.override); - // ACP gateway auth bypasses the CLI credential store entirely, so the - // probe would report an identity that is not the one being used. A mere - // client provider override (`providers/set`) does NOT skip the probe: it - // reroutes traffic without touching the credential store, and the store is - // exactly the agent-owned login `authStatus` reports. - if (this.gatewayAuthRequest) { - return this.currentAuthStatus; - } - let stdout: string; - try { - const cliPath = await claudeCliPath(); - const args = ["auth", "status", "--json"]; - // `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects - // it with "unknown option '--settings'", so it has to come first. - if (context?.override) args.unshift("--settings", JSON.stringify(context.override)); - ({ stdout } = await execFileAsync(cliPath, args, { - timeout: AUTH_STATUS_PROBE_TIMEOUT_MS, - cwd: context?.manager.getCwd() ?? process.cwd(), - env: context?.env ? { ...process.env, ...context.env } : process.env, - })); - } catch (error) { - const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null; - // Node killed the child on the timeout. Whatever it printed so far is a - // truncated fragment, never valid JSON: report nothing and keep the last - // known state, so nothing regresses and no push goes out. - if (failed?.killed === true && failed.signal) { - if (!this.loggedProbeTimeout) { - this.loggedProbeTimeout = true; - this.logger.error( - "claude auth status did not answer within 5 s; the identity is not refreshed", - ); - } - return this.currentAuthStatus; - } - // The logged-out case exits 1 while still printing valid JSON, so the - // stdout of a failed exec is parsed just like a successful one. - if (typeof failed?.stdout !== "string" || failed.stdout.trim().length === 0) { - this.logger.error( - "claude auth status failed:", - error instanceof Error ? error.message : String(error), - ); - return undefined; - } - stdout = failed.stdout; - } - let status = fromCliStatus(stdout, apiKeyHelper); - if (!context && status?.kind === "api_key" && status.detail === "API key helper") { - try { - const resolved = await resolveSettings({ cwd: process.cwd() }); - status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper); - } catch { - this.logger.error("Could not resolve auth source settings"); - } - } - if (!status) { - this.logger.error("claude auth status returned unparseable output"); - return undefined; - } - if (epoch !== this.authEpoch) { - // An `authenticate` or `logout` landed while this probe was running; the - // newer state wins and this answer is discarded, never published. - this.logger.log("[authStatus] discarding a probe that predates the latest auth change"); - return this.currentAuthStatus; - } - // The CLI probe can be poorer than the session `AccountInfo` for the very - // same login (no organization, say). Keep those extra fields rather than - // regressing the payload; a different identity replaces it wholesale. - const merged = mergeAuthStatus(this.currentAuthStatus, status); - this.setAuthStatus(merged); - this.markSessionsWhoseAccountKindChanged(status.kind); - return merged; + this.authStatus.set(next); } /** @@ -3643,72 +2947,22 @@ export class ClaudeAcpAgent { } async unstable_listProviders(_params: ListProvidersRequest): Promise { - const config = this.providerConfig ?? this.defaultProviderConfig(); + const config = this.providerConfig ?? defaultProviderConfig(this.gatewayAuthRequest); this.logger.log( - `[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`, + `[providers/list] apiType=${config.apiType} baseUrl=${loggableBaseUrl(config.baseUrl)} overridden=${this.providerConfig !== undefined}`, ); - const provider: ProviderInfo = { - providerId: PROVIDER_ID, - supported: SUPPORTED_PROTOCOLS, - required: false, - current: { apiType: config.apiType, baseUrl: config.baseUrl }, - }; - return { providers: [provider] }; + return listProvidersResponse(config); } /** - * `providers/set` — replace the full configuration for the `main` provider. - * Rejects unknown IDs, unsupported protocols, and empty/invalid base URLs with - * `invalid_params`. Config is process-scoped and applies to sessions created or - * loaded after this call. + * `providers/set` — replace the full configuration for the `main` provider + * (validated by {@link providerConfigFromSetRequest}). Config is + * process-scoped and applies to sessions created or loaded after this call. */ async unstable_setProvider(params: SetProviderRequest): Promise { - if (params.providerId !== PROVIDER_ID) { - throw RequestError.invalidParams( - { providerId: params.providerId }, - `Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`, - ); - } - if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) { - throw RequestError.invalidParams( - { apiType: params.apiType, supported: SUPPORTED_PROTOCOLS }, - `Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`, - ); - } - if (!isValidBaseUrl(params.baseUrl)) { - throw RequestError.invalidParams( - { baseUrl: params.baseUrl }, - "baseUrl must be a non-empty absolute http(s) URL.", - ); - } - - const config: ProviderConfig = { - apiType: params.apiType, - baseUrl: params.baseUrl, - headers: params.headers ?? {}, - }; - - // Vertex requires project + region, which the standard payload cannot - // carry, so they arrive via `_meta.claudeCode.vertex`. - if (params.apiType === "vertex") { - const vertex = (params._meta as SetProviderMeta | undefined)?.claudeCode?.vertex; - if ( - !vertex || - typeof vertex.projectId !== "string" || - vertex.projectId.trim() === "" || - typeof vertex.region !== "string" || - vertex.region.trim() === "" - ) { - throw RequestError.invalidParams( - undefined, - "vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.", - ); - } - config.vertex = { projectId: vertex.projectId, region: vertex.region }; - } - + const config = providerConfigFromSetRequest(params); this.logger.log( - `[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`, + `[providers/set] apiType=${config.apiType} baseUrl=${loggableBaseUrl(config.baseUrl)} sessions=${Object.keys(this.sessions).length}`, ); await this.enqueueProviderUpdate(config); return {}; @@ -3727,78 +2981,10 @@ export class ClaudeAcpAgent { return {}; } - /** - * The model ids the gateway in `config` lists, read before `providers/set` - * returns so the sessions it recreates already offer only those, and for - * `bedrock` the region prefix picked from them. Setting the same gateway - * again reuses the previous listing. `vertex` gateways are not listed. - * Throws a {@link GatewayListingError} when the listing fails. - */ - private async listProviderModels( - config: ProviderConfig, - ): Promise> { - // Reuse needs the very same headers, so a rotated token lists again, and a - // failed listing then fails that `providers/set`. Deliberate: AIR keeps its - // proxy key for the IDE's lifetime and does not rotate it. - const current = this.providerConfig; - if ( - current?.listedModels && - current.apiType === config.apiType && - current.baseUrl === config.baseUrl && - sameHeaders(current.headers, config.headers) - ) { - return { - listedModels: current.listedModels, - bedrockRegionPrefix: current.bedrockRegionPrefix, - }; - } - if (config.apiType === "anthropic") { - return { listedModels: await listGatewayModels(config.baseUrl, config.headers, this.logger) }; - } - if (config.apiType === "bedrock") { - const modelIds = await listBedrockModels(config.baseUrl, config.headers, this.logger); - if (!modelIds) return {}; - const region = selectBedrockRegion(modelIds); - if (region.modelIds.length < modelIds.length) { - this.logger.log( - `[providers/set] Bedrock models span several region prefixes; using ${region.regionPrefix} and dropping ${modelIds.length - region.modelIds.length} id(s) of other prefixes`, - ); - } - return { listedModels: region.modelIds, bedrockRegionPrefix: region.regionPrefix }; - } - return {}; - } - resolveProviderConfig(): ProviderConfig | null { return this.providerConfig ?? gatewayRequestToProviderConfig(this.gatewayAuthRequest); } - private defaultProviderConfig(): ProviderConfig { - const gatewayConfig = gatewayRequestToProviderConfig(this.gatewayAuthRequest); - if (gatewayConfig) { - return gatewayConfig; - } - if (process.env.CLAUDE_CODE_USE_BEDROCK) { - return { - apiType: "bedrock", - baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com", - headers: {}, - }; - } - if (process.env.CLAUDE_CODE_USE_VERTEX) { - return { - apiType: "vertex", - baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? DEFAULT_VERTEX_BASE_URL, - headers: {}, - }; - } - return { - apiType: "anthropic", - baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL, - headers: {}, - }; - } - async logout(_params: LogoutRequest): Promise { // Clear in-memory gateway credentials supplied via `authenticate` and any // provider routing set via `providers/set`. Neither touches the on-disk @@ -3812,29 +2998,17 @@ export class ClaudeAcpAgent { this.providerConfig = undefined; // Any probe already running read the pre-logout world; the bump makes its // answer unpublishable so it cannot resurrect the identity being cleared. - this.authEpoch += 1; + this.authStatus.changed(); // Learned context windows are per-account state too: 1M-context // entitlement is gated per org/tier, and an OAuth re-login is invisible to // the env-derived provider cache key, so windows learned under the old // login must not seed sessions under the next. Worst case of clearing is // re-learning on each model's next turn. - contextWindowCache.clear(); + clearContextWindowCache(); - // For the Claude/Console login methods the credentials live in the native - // CLI's store (keychain or config dir), which only the binary can clear. - // `claude auth logout` is non-interactive and idempotent. + // The Claude and Console logins live in the CLI's store. try { - const cliPath = await claudeCliPath(); - await execFileAsync(cliPath, ["auth", "logout"]); - } catch (error) { - const stderr = - typeof error === "object" && error && "stderr" in error - ? String((error as { stderr: unknown }).stderr).trim() - : undefined; - throw RequestError.internalError( - { stderr: stderr || undefined }, - `claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`, - ); + await cliLogout(); } finally { // The cleared provider or gateway routing is baked into the env of every // live query, so recreate them the way `providers/disable` does: after @@ -3852,8 +3026,8 @@ export class ClaudeAcpAgent { // credentials were cleared, so it never joins a probe that is already in // flight. If it can't be read, the logout still happened — drop the // now-stale identity instead of reporting it further. - if (!(await this.probeCliAuthStatus({ fresh: true }))) { - this.setAuthStatus(notLoggedInAuthStatus()); + if (!(await this.authStatus.probe({ fresh: true }))) { + this.authStatus.set(notLoggedInAuthStatus()); } } @@ -3906,8 +3080,7 @@ export class ClaudeAcpAgent { * * A content block that the agent does not advertise, such as `audio`, fails * the request with invalid params (-32602) before anything is queued: - * `promptToClaude` would drop it. v2 prompts reach `startTurn` through - * `v1PromptRequest`, which rejects these blocks itself. + * `promptToClaude` would drop it. */ async prompt(params: PromptRequest, origin: TurnOrigin = "client"): Promise { for (const block of params.prompt) { @@ -3955,12 +3128,12 @@ export class ClaudeAcpAgent { // that finds the new credential must not be the one the refusal skipped. // The guard itself consumes the result only at the next turn boundary (see // `markSessionsWhoseAccountKindChanged`). - this.useAuthStatusSettings( + this.authStatus.useSettings( session.settingsManager, session.effortSettingsOverride, (session.creationParams?._meta as NewSessionMeta | undefined)?.claudeCode?.options?.env, ); - void this.probeCliAuthStatus(); + void this.authStatus.probe(); const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session); if (signOutRespawn) { await signOutRespawn; @@ -3977,19 +3150,19 @@ export class ClaudeAcpAgent { throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE); } - const remoteControlCommand = parseRemoteControlCommand(params.prompt); - if ( - remoteControlCommand && - supportsRemoteControl(session.query) && - !claudeCodeOffersCommand( - await session.query.supportedCommands(), - session.terminalSlashCommands, - REMOTE_CONTROL_COMMAND_NAME, - ) - ) { + // Only a prompt that is the command awaits here: an await on every prompt + // would let other work run between its checks and its turn's enqueue. + const remoteControl = parseRemoteControlCommand(params.prompt) + ? await adapterRemoteControlCommand( + params.prompt, + session.query, + session.terminalSlashCommands, + ) + : null; + if (remoteControl) { // The reply bypasses the turn queue, so it would land inside the output of - // a turn that is still running, and v2 would report no `running` state - // around it. Refuse instead of interleaving. `foregroundWork` also covers + // a turn that is still running. Refuse instead of interleaving. + // `foregroundWork` also covers // a cycle that Claude Code started on its own, such as its follow-up // after a background subagent finished, which is not in `turnQueue`. if (this.claudeIsWorking(params.sessionId, session)) { @@ -4009,17 +3182,12 @@ export class ClaudeAcpAgent { // those to reach this client, and a command that answers without a turn // would otherwise leave the stream unread until the next prompt. this.ensureConsumer(session, params.sessionId); - // Answered by the adapter without a model turn; v2 clients treat an - // `ended` without a prior `inserted` as a refused prompt. + // Answered by the adapter without a model turn. events.inserted(randomUUID()); // The request goes over the network. Until it and its reply are done, // `remoteControlCommand` holds back the prompts that follow, which would // otherwise start a turn that the reply lands in. - const command = this.runRemoteControlCommand( - params.sessionId, - session.query, - remoteControlCommand, - ); + const command = this.runRemoteControlCommand(params.sessionId, remoteControl); session.remoteControlCommand = command; try { await command; @@ -4067,11 +3235,8 @@ export class ClaudeAcpAgent { // the usage limit) from leaving the stale prediction in the composer. A // session that holds no prediction needs no clear, and the prompt path // then keeps the ordering it has without this feature. - if (session.lastPublishedPromptSuggestion != null) { - await this.publishPromptSuggestion(params.sessionId, null); - } else { - session.promptSuggestionExpected = false; - } + const cleared = session.promptSuggestions?.clear(); + if (cleared) await this.publishPromptSuggestion(params.sessionId, cleared); } // A `/remote-control` command still in flight answers into the session's @@ -4081,8 +3246,8 @@ export class ClaudeAcpAgent { if (session.remoteControlCommand) await session.remoteControlCommand.catch(() => {}); // A `session/close` during the awaits above tore the session down, and - // its consumer is gone, so the prompt would never be inserted. End it as - // the close ends a queued turn: `cancelled` on v1, -32800 on v2. A stream + // its consumer is gone, so the prompt would never be inserted. End it + // `cancelled`, as the close ends a queued turn. A stream // that ended meanwhile fails the prompt as the check above does. // A `session/cancel` that arrived after this prompt, during the awaits // above, ends it the same way. @@ -4113,12 +3278,12 @@ export class ClaudeAcpAgent { completeTurn = resolve; }), resolve: (outcome) => { - this.endForeground(params.sessionId, turn, { outcome }); + this.endForeground(params.sessionId, turn); events.ended(outcome); completeTurn(); }, reject: (error) => { - this.endForeground(params.sessionId, turn, { error }); + this.endForeground(params.sessionId, turn); events.failed(error); completeTurn(); }, @@ -4137,32 +3302,14 @@ export class ClaudeAcpAgent { ); } - /** Connects Remote Control (again, if it is already on) or disconnects it. */ + /** Runs the command, and sends its reply (see `runRemoteControlCommand`). */ private async runRemoteControlCommand( sessionId: string, - query: RemoteControlQuery, - command: RemoteControlCommand, + { query, command }: { query: RemoteControlQuery; command: RemoteControlCommand }, ): Promise { - let text: string; - if (command.action === "disconnect") { - try { - await query.enableRemoteControl(false); - text = "Remote Control is off. Run /remote-control to reconnect."; - } catch (error) { - this.logger.error(`Failed to disable Remote Control: ${error}`); - text = `Could not stop Remote Control: ${error instanceof Error ? error.message : error}`; - } - } else { - try { - const result = await query.enableRemoteControl(true, command.name); - text = result.session_url - ? `Remote Control is active. Continue this session from claude.ai/code or the Claude mobile app:\n\n${result.session_url}\n\nRun /remote-control off to disconnect.` - : "Remote Control is active, but Claude Code did not return a session URL."; - } catch (error) { - this.logger.error(`Failed to enable Remote Control: ${error}`); - text = `Could not start Remote Control: ${error instanceof Error ? error.message : error}`; - } - } + const text = await runRemoteControlCommand(query, command, (message) => + this.logger.error(message), + ); await this.client.sessionUpdate({ sessionId, update: { sessionUpdate: "agent_message_chunk", content: { type: "text", text } }, @@ -4298,22 +3445,8 @@ export class ClaudeAcpAgent { if (!clientServers.has(server.name) || session.reportedMcpServers?.has(server.name)) { continue; } - let notice: SessionNotice; - if (server.status === "failed") { - notice = { - severity: "warning", - title: `MCP server "${server.name}" failed to start`, - description: "Its tools are unavailable in this session. Run /mcp for the error.", - }; - } else if (server.status === "needs-auth" && !oauthAvailable) { - notice = { - severity: "info", - title: `MCP server "${server.name}" needs authentication`, - description: "Its tools are unavailable until the server is authenticated.", - }; - } else { - continue; - } + const notice = mcpServerStartupNotice(server, oauthAvailable); + if (!notice) continue; (session.reportedMcpServers ??= new Set()).add(server.name); await sendUpdate({ sessionId, @@ -4409,7 +3542,7 @@ export class ClaudeAcpAgent { // The guard reads the account anyway; reuse that read to keep the // reported identity current, refusal or not. onAccount: (account) => - this.publishSessionAccountIdentity( + this.authStatus.publishSessionAccount( account, session.settingsManager.getApiKeyHelper(session.effortSettingsOverride), ), @@ -4447,27 +3580,18 @@ export class ClaudeAcpAgent { } /** - * Publishes the follow-up prompt the CLI predicted for the next turn, or - * `null` to clear the one the client holds. Only AIR renders it, so only AIR - * gets the key; see `docs/air-extensions.md`. + * Sends the `_meta` of a session's {@link PromptSuggestions}: the follow-up + * prompt the CLI predicted for the next turn, or the clear of the one the + * client holds. Only AIR renders it, so only an AIR session has the state; + * see `docs/air-extensions.md`. */ - private async publishPromptSuggestion(sessionId: string, text: string | null): Promise { - const session = this.sessions[sessionId]; - if (!session) return; - // A clear ends the window the turn's result opened: whatever prediction is - // still in flight answers a turn the user has already moved past. - if (text === null) session.promptSuggestionExpected = false; - // A session that never published one holds nothing, so a clear before the - // first prediction is already true on the client and goes nowhere. - if ((session.lastPublishedPromptSuggestion ?? null) === text) return; - if (!this.toolCallCapabilities.air.client) return; - session.lastPublishedPromptSuggestion = text; + private async publishPromptSuggestion( + sessionId: string, + meta: Record, + ): Promise { await this.client.sessionUpdate({ sessionId, - update: { - sessionUpdate: "session_info_update", - _meta: promptSuggestionsMeta(text), - }, + update: { sessionUpdate: "session_info_update", _meta: meta }, }); } @@ -5110,9 +4234,6 @@ export class ClaudeAcpAgent { // handler), activation is the first sign that Claude Code took the // prompt in: its echo, or for an echo-less command, its result. reportInserted(turn); - // A request can already be open: Claude Code asks for a queued prompt - // before the consumer reaches its echo. - this.syncAwaitingUser(params.sessionId, session); }; /** Report that Claude Code took the turn's prompt in, once. A turn can be @@ -5394,19 +4515,15 @@ export class ClaudeAcpAgent { if (isSubagent && session.activeTurn && !session.activeTurn.settled) { (session.activeTurn.spawnedTaskIds ??= new Set()).add(taskId); } - this.syncAwaitingUser(params.sessionId, session); }; - /** Ends the terminal and the tool call of a task that ran in the - * background, once the task ends (see `Session.background`). */ - const endBackgroundTask = (taskId: string, end: BackgroundTaskEnd) => - session.background.taskEnded(taskId, end, sendUpdate); + /** Ends the terminal of a command that ran in the background, once its + * task ends (see `Session.background`). */ + const endBackgroundTask = (taskId: string) => session.background.taskEnded(taskId, sendUpdate); - /** Settles a task. Its request may still be open, and an agent without a - * record no longer blocks the foreground work, so that is checked again. */ + /** Settles a task. */ const settleLiveTask = (taskId: string) => { settleLiveBackgroundTask(session, taskId); - this.syncAwaitingUser(params.sessionId, session); }; /** Registers a settled subagent again when the SDK resumes it without a @@ -5481,7 +4598,7 @@ export class ClaudeAcpAgent { ) { session.activeTurn.deferredSettle = outcome; // The hold delays v1's answer; the turn's work ended at its result. - this.endForeground(params.sessionId, session.activeTurn, { outcome }); + this.endForeground(params.sessionId, session.activeTurn); } else { await settleActive(outcome); } @@ -5749,8 +4866,7 @@ export class ClaudeAcpAgent { } } // The client is told that the work a cancel stopped ended (the prompt - // response on v1, `idle` on v2) only after every tool call that work - // opened has ended. + // response) only after every tool call that work opened has ended. if (result.stopReason === "cancelled" && session.cancelled) { await closeCancelledToolCalls(turn); // `cancelTurns` can settle the turn while the reports are in flight. @@ -5875,10 +4991,7 @@ export class ClaudeAcpAgent { * error is the cancel's, not the work's. */ const failAllTurns = (error: unknown) => { disarmForceCancel(session); - this.endAutonomousCycle( - params.sessionId, - session.cancelled ? { outcome: { stopReason: "cancelled" } } : { error }, - ); + this.endAutonomousCycle(params.sessionId); const active = session.activeTurn; const turns = active ? [active, ...(session.turnQueue ?? []).filter((t) => t !== active)] @@ -5931,11 +5044,6 @@ export class ClaudeAcpAgent { // pulled only after the pause. const pause = messageLoopYielder.maybeYield(); if (pause) await pause; - // The last message may have changed the background work, and its - // updates are sent, so the list follows them. A task that needs a - // tool call of its own gets it first, so the list can name it. - for (const call of session.background.adoptTasks(session)) await sendUpdate(call); - this.backgroundWork.sync(params.sessionId, session, this.foregroundState(params.sessionId)); pendingNext ??= session.query .next() .then((result) => ({ kind: "message" as const, result })); @@ -6063,12 +5171,7 @@ export class ClaudeAcpAgent { session.cancelled ? "stopped" : "failed", "at end of stream", ); - this.endAutonomousCycle( - params.sessionId, - session.cancelled - ? { outcome: { stopReason: "cancelled" } } - : { error: RequestError.internalError(undefined, SESSION_ENDED_MESSAGE) }, - ); + this.endAutonomousCycle(params.sessionId); await settleActive( session.cancelled ? cancelledOutcome(session, inFlight) @@ -6471,9 +5574,7 @@ export class ClaudeAcpAgent { await asyncTasks.autonomousTurnEnded("completed"); } await closeCycleToolCalls(); - this.endAutonomousCycle(params.sessionId, { - outcome: { stopReason: session.cancelled ? "cancelled" : "end_turn" }, - }); + this.endAutonomousCycle(params.sessionId); } // A non-cancelled turn normally settled at its terminal // `result` already (issue #773), and that result recorded an @@ -6555,7 +5656,7 @@ export class ClaudeAcpAgent { const steered: Turn = session.activeTurn; steered.deferredSettle = outcome; // A hold delays v1's answer; the turn's work ends here. - this.endForeground(params.sessionId, steered, { outcome }); + this.endForeground(params.sessionId, steered); leaveSteerLane(steered); await settleDeferredIfDrained(); } else if (session.owedTrailingIdles > 0) { @@ -6741,7 +5842,6 @@ export class ClaudeAcpAgent { noticedToolUses.add(message.tool_use_id); } } - const severity = message.level === "warning" ? "warning" : "info"; const transcriptContent = collapseHookSpeaker(message.content); const transcriptText = message.level === "info" @@ -6750,15 +5850,7 @@ export class ClaudeAcpAgent { await sendUpdate({ sessionId: params.sessionId, update: noticeOrTranscriptUpdate( - { - severity, - ...splitNoticeText( - message.content, - severity === "warning" - ? "Claude reported a warning" - : "Claude reported a notice", - ), - }, + informationalNotice(message), supportsNotices, transcriptText, { claudeCode: { kind: "informational", level: message.level } }, @@ -6775,22 +5867,13 @@ export class ClaudeAcpAgent { case "hook_response": case "files_persisted": break; - case "task_progress": { + case "task_progress": if (isLateTaskRun(session, message.task_id, message.run_id)) { this.logger.log( `Ignoring task_progress for task ${message.task_id} from earlier run ${message.run_id}`, ); break; } - // The SDK does not type `workflow_progress`; see workflow-progress.ts. - const workflow = workflowSnapshot( - (message as { workflow_progress?: unknown }).workflow_progress, - ); - const progress = session.background.taskProgress(message.task_id, { - ...taskProgressFields(message), - ...(workflow ? { workflow } : {}), - }); - if (progress) await sendUpdate(progress); await asyncTasks.taskProgress({ task_id: message.task_id, description: message.description, @@ -6800,7 +5883,6 @@ export class ClaudeAcpAgent { tool_use_id: message.tool_use_id, }); break; - } case "task_started": if (isLateTaskRun(session, message.task_id, message.run_id)) { this.logger.log( @@ -6885,7 +5967,7 @@ export class ClaudeAcpAgent { streamedBlocksByParent.delete(message.tool_use_id); droppedStreams.delete(message.tool_use_id); } - await endBackgroundTask(message.task_id, message.status); + await endBackgroundTask(message.task_id); if (message.status === "stopped") { await closeStoppedSubagentToolCalls(message.tool_use_id); } @@ -6900,11 +5982,10 @@ export class ClaudeAcpAgent { } await asyncTasks.taskUpdated(message.task_id, message.patch); // A task moved to the background no longer blocks the tool - // call that spawned it, nor its requests the foreground work. + // call that spawned it. if (message.patch.is_backgrounded !== undefined) { const task = session.liveBackgroundTasks.get(message.task_id); if (task) task.backgrounded = message.patch.is_backgrounded; - this.syncAwaitingUser(params.sessionId, session); } // terminal-status task_updated patch and a (deduplicated) // task_notification when a task settles, but only the patch is @@ -6930,23 +6011,15 @@ export class ClaudeAcpAgent { streamedBlocksByParent.delete(parentToolUseId); droppedStreams.delete(parentToolUseId); } - await endBackgroundTask( - message.task_id, - message.patch.status === "killed" ? "stopped" : message.patch.status, - ); + await endBackgroundTask(message.task_id); if (message.patch.status === "killed") { await closeStoppedSubagentToolCalls(parentToolUseId); } settleLiveTask(message.task_id); - } else if (message.patch.status === "paused") { - const pause = session.background.taskPaused(message.task_id, true); - if (pause) await sendUpdate(pause); } else if ( message.patch.status === "running" || message.patch.status === "pending" ) { - const resume = session.background.taskPaused(message.task_id, false); - if (resume) await sendUpdate(resume); // The SDK can resume a finished subagent under the same // agent id without a new task_started. resumeLiveTask(message.task_id); @@ -7014,42 +6087,13 @@ export class ClaudeAcpAgent { break; } case "model_refusal_fallback": { - // The SDK retried a refused turn on the fallback model and made - // the swap persistent for the session. Without a notice the - // user just sees regenerated output; without the state sync the - // client's model picker (and the model-dependent options - // rebuilt from it) keeps advertising a model the session is no - // longer running. - // - // Current CLIs only emit direction "retry" (persistent swap). - // "revert"/"sticky" are retained in the SDK enum for older - // CLIs, where "revert" marked a turn-only fallback — for that - // direction the session stays on the original model, so skip - // the persistent-swap claim and the state sync. - // - // `scope` (CLI 2.1.232+) marks WHERE the fallback happened: - // "local" means a subagent / side-question / background fork - // response fell back and the session model is unchanged, so - // syncing the picker would advertise a model the session - // isn't running. Absent scope means an older CLI, where every - // retry was a session-level swap — treat as "session". - const local = message.scope === "local"; - const persistent = message.direction !== "revert" && !local; - const category = message.api_refusal_category - ? ` (${message.api_refusal_category})` - : ""; - const outcome = persistent - ? `The session will continue on ${message.fallback_model}.` - : local - ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.` - : `The session stays on ${message.original_model}.`; - const fallbackSummary = - `${message.original_model} declined this request${category}; ` + - `retried with ${message.fallback_model}. ${outcome}`; - const explanation = message.api_refusal_explanation || undefined; - const fallbackNotice = explanation - ? `${fallbackSummary}\n\n${explanation}` - : fallbackSummary; + // The SDK retried a refused turn on the fallback model. Without + // a notice the user just sees regenerated output; without the + // state sync the client's model picker (and the model-dependent + // options rebuilt from it) keeps advertising a model the + // session is no longer running (see `modelRefusalFallback`). + const fallback = modelRefusalFallback(message); + const { summary: fallbackSummary, explanation } = fallback; // A silent model swap is a session-level advisory, not something the model said. // Clients on the ACP notice contract get it as a `notice`; clients that negotiated // AIR typed records get it as one of those; the rest keep the bold-label transcript @@ -7059,30 +6103,24 @@ export class ClaudeAcpAgent { explanation !== undefined && fallbackSummary.length + 2 + explanation.length > MAX_NOTICE_TITLE_LENGTH; await publishSessionFailure("advisory", { - title: useDetails ? fallbackSummary : fallbackNotice, + title: useDetails + ? fallbackSummary + : explanation + ? `${fallbackSummary}\n\n${explanation}` + : fallbackSummary, ...(useDetails ? { details: explanation } : {}), }); } else { - // A title must stand alone: the one-line summary when it is - // short enough, else a generic title with everything in the - // description (the same cap the AIR lane applies above). - const notice = - fallbackSummary.length > MAX_NOTICE_TITLE_LENGTH - ? { title: "Model fallback", description: fallbackNotice } - : { - title: fallbackSummary, - ...(explanation ? { description: explanation } : {}), - }; await sendUpdate({ sessionId: params.sessionId, update: noticeOrTranscriptUpdate( - { severity: "warning", ...notice }, + fallback.notice, supportsNotices, - `**Model fallback:** ${fallbackNotice}`, + fallback.transcript, ), }); } - if (persistent) { + if (fallback.persistent) { await this.syncModelAfterExternalSwitch( params.sessionId, session, @@ -7116,7 +6154,6 @@ export class ClaudeAcpAgent { break; case "background_tasks_changed": { await asyncTasks.backgroundTasksChanged(message.tasks); - await session.background.levelChanged(message.tasks, sendUpdate); // A level signal: the full live background-task set on every // membership change, with REPLACE semantics. Used only to // reconcile `liveBackgroundTasks` — dropping (or, for @@ -7170,7 +6207,7 @@ export class ClaudeAcpAgent { session.modelRequestSinceResult = false; // The CLI predicts the next prompt right after this result, so a // `prompt_suggestion` from here on belongs to the turn just ended. - session.promptSuggestionExpected = true; + session.promptSuggestions?.resultArrived(); // The result ends the model turn. A background task that still // waits for its tool call id gets its spawn now, without the id. await asyncTasks.releaseHeld(); @@ -7372,29 +6409,14 @@ export class ClaudeAcpAgent { ) { session.contextWindowSize = matchingModelUsage.usage.contextWindow; session.contextWindowAuthoritative = true; - // Authoritative: fold it into the cross-session cache keyed on - // (this session's provider, the resolved model id — - // matchingModelUsage.key, e.g. "claude-sonnet-5[1m]") so a later - // session/new or switch on the same provider that resolves to - // this model seeds the correct window synchronously, with no - // getContextUsage IPC. - cacheContextWindow( - contextWindowCacheKey(session.providerCacheKey, matchingModelUsage.key), + // Authoritative: fold it into the cross-session cache (see + // `cacheResultContextWindow`). + cacheResultContextWindow( + session.providerCacheKey, + matchingModelUsage.key, + lastAssistantModel, matchingModelUsage.usage.contextWindow, ); - // Also cache under the assistant message's own (bare) spelling. - // Seed-time reads fall back to a picker value / verbatim live id - // when a row carries no resolvedModel (the synthesized - // out-of-allowlist resume row sets it undefined on purpose), and - // those spellings match `.model` from the assistant message, not - // the decorated modelUsage key — without this entry such rows - // could never hit the cache. - if (lastAssistantModel && lastAssistantModel !== matchingModelUsage.key) { - cacheContextWindow( - contextWindowCacheKey(session.providerCacheKey, lastAssistantModel), - matchingModelUsage.usage.contextWindow, - ); - } } session.lastTotalCostUsd = message.total_cost_usd; @@ -7439,14 +6461,7 @@ export class ClaudeAcpAgent { pendingExitPlanModeInterruption?.toolUseId; if (!continuesPlan && !(isAutonomousResult && message.num_turns === 0)) { await closeCycleToolCalls(); - this.endAutonomousCycle( - params.sessionId, - autonomousCycleEnd( - message, - resultModelUsage, - session.cancelled || exitPlanInterruption || isAbortedResult(message), - ), - ); + this.endAutonomousCycle(params.sessionId); } if (session.cancelled) { @@ -7739,28 +6754,31 @@ export class ClaudeAcpAgent { ); } + // The stop reason or the error, by the rules both cores share. + const ended = resultEnd(message); switch (message.subtype) { case "success": { - if (message.is_error && isLoginRequiredText(message.result)) { - await failActiveWithSessionFailure( - "auth_required", - RequestError.authRequired(), - message.result, - ); - break; - } - if (message.stop_reason === "max_tokens") { - stopReason = "max_tokens"; - break; - } - if (message.is_error) { + if ("error" in ended) { + if (ended.loginRequired) { + await failActiveWithSessionFailure( + "auth_required", + RequestError.authRequired(), + ended.error, + ); + break; + } await failActiveWithSessionFailure( providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), - internalErrorForClient(errorKindData(lastAssistantError), message.result), - lastAssistantFailureTitle ?? message.result, + internalErrorForClient(errorKindData(lastAssistantError), ended.error), + lastAssistantFailureTitle ?? ended.error, ); break; } + // `end_turn` keeps the stop reason as it was. + if (ended.stopReason !== "end_turn") { + stopReason = ended.stopReason; + break; + } // The result text is forwarded in two cases. Local-only // commands (no model invocation): the result IS the command // output. Otherwise the result is normally a trailing copy of @@ -7807,66 +6825,29 @@ export class ClaudeAcpAgent { } break; } - case "error_during_execution": { - if (message.stop_reason === "max_tokens") { - stopReason = "max_tokens"; - break; - } - if (message.is_error) { + case "error_during_execution": + if ("error" in ended) { await failActiveWithSessionFailure( providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit), - internalErrorForClient( - errorKindData(lastAssistantError), - message.errors.join(", ") || message.subtype, - ), - lastAssistantFailureTitle ?? (message.errors.join(", ") || message.subtype), + internalErrorForClient(errorKindData(lastAssistantError), ended.error), + lastAssistantFailureTitle ?? ended.error, ); break; } - stopReason = "end_turn"; + stopReason = ended.stopReason; break; - } case "error_max_budget_usd": - if (message.is_error) { - await failActiveWithSessionFailure( - "budget_exhausted", - internalErrorForClient( - errorKindData(lastAssistantError), - message.errors.join(", ") || message.subtype, - ), - message.errors.join(", ") || message.subtype, - ); - break; - } - stopReason = "max_turn_requests"; - break; case "error_max_turns": - if (message.is_error) { - await failActiveWithSessionFailure( - "context_exhausted", - internalErrorForClient( - errorKindData(lastAssistantError), - message.errors.join(", ") || message.subtype, - ), - message.errors.join(", ") || message.subtype, - ); - break; - } - stopReason = "max_turn_requests"; - break; case "error_max_structured_output_retries": - if (message.is_error) { + if ("error" in ended) { await failActiveWithSessionFailure( - "provider_error", - internalErrorForClient( - errorKindData(lastAssistantError), - message.errors.join(", ") || message.subtype, - ), - message.errors.join(", ") || message.subtype, + LIMIT_FAILURE_CATEGORY[message.subtype], + internalErrorForClient(errorKindData(lastAssistantError), ended.error), + ended.error, ); break; } - stopReason = "max_turn_requests"; + stopReason = ended.stopReason; break; default: unreachable(message, this.logger); @@ -8079,32 +7060,7 @@ export class ClaudeAcpAgent { // already reached the client as chunks and forward only the // remainder. Each stream (top level or one subagent) keeps its own // record, so a subagent cannot attribute content to another stream. - // Contiguous deltas of the same block (same index and type) extend - // the current entry; anything else opens a new one. - if (message.event.type === "content_block_delta") { - const delta = message.event.delta; - const chunk = - delta.type === "text_delta" - ? { type: "text" as const, text: delta.text } - : delta.type === "thinking_delta" - ? { type: "thinking" as const, text: delta.thinking } - : undefined; - // Skip empty deltas (some gateways emit empty thinking chunks — - // #793): appending "" is a no-op, but pushing a "" entry would - // create a block the consolidated handler's `text.length > 0` - // guard can never consume, stalling the diff cursor and - // re-emitting the next block as a duplicate. - if (chunk?.text) { - const index = message.event.index; - const streamedBlocks = streamedBlocksOf(message.parent_tool_use_id); - const last = streamedBlocks[streamedBlocks.length - 1]; - if (last && last.index === index && last.type === chunk.type) { - last.text += chunk.text; - } else { - streamedBlocks.push({ index, type: chunk.type, text: chunk.text }); - } - } - } + recordStreamedDelta(streamedBlocksOf(message.parent_tool_use_id), message.event); // The stream sees a tool_use first, so the tool's PostToolUse hook // is registered here, and publishes through this client: a // subagent's hook update must reach the child session too. @@ -8534,8 +7490,6 @@ export class ClaudeAcpAgent { const acceptedPlanToolUseId = observeExitPlanToolResults(message, content, session); let backgroundedToolCalls: ReadonlySet = new Set(); let runningTerminal: string | undefined; - let runningTerminalTask: string | undefined; - let runningToolCalls: ReadonlySet = new Set(); if (message.type === "user") { rememberResolvedToolNames(session, content); recordSubagentFileEdits( @@ -8578,19 +7532,8 @@ export class ClaudeAcpAgent { ...(message.parent_tool_use_id ? { parentToolUseId: message.parent_tool_use_id } : {}), - ...(typeof backgroundBashTask.outputFilePath === "string" - ? { outputFilePath: backgroundBashTask.outputFilePath } - : {}), }); - runningTerminalTask = backgroundBashTask.taskId; } - runningToolCalls = session.background.toolResults( - session, - content, - toolUseResult, - session.toolUseCache, - message.parent_tool_use_id, - ); await asyncTasks.toolResults(content); backgroundedToolCalls = backgroundedBashToolCallIds( content, @@ -8654,34 +7597,16 @@ export class ClaudeAcpAgent { // (e.g. a subagent image) carry the stamped parentToolUseId // meta and are excluded there. await sendUpdate( - withRunningToolCall( - withRunningTerminal( - backgroundedBashToolCall( - acceptedPlanToolResult(notification, acceptedPlanToolUseId), - backgroundedToolCalls, - asyncTasks.enabled, - ), - runningTerminal, + withRunningTerminal( + backgroundedBashToolCall( + acceptedPlanToolResult(notification, acceptedPlanToolUseId), + backgroundedToolCalls, + asyncTasks.enabled, ), - runningToolCalls, + runningTerminal, ), ); } - for (const progress of session.background.toolCallProgress(runningToolCalls)) { - await sendUpdate(progress); - } - // After the result's own updates: the command's output replaces - // the placeholder that they gave its terminal. - if (runningTerminalTask) { - session.background.followOutput(runningTerminalTask, (notification) => - sendUpdate(notification).catch((error) => { - this.logger.error( - `Session ${params.sessionId}: failed to send background command output:`, - error, - ); - }), - ); - } break; } case "tool_progress": { @@ -8782,7 +7707,8 @@ export class ClaudeAcpAgent { session.titles.reset(); // The follow-up prompt was predicted from the transcript the reset // dropped, so it no longer follows from anything. - await this.publishPromptSuggestion(params.sessionId, null); + const cleared = session.promptSuggestions?.clear(); + if (cleared) await this.publishPromptSuggestion(params.sessionId, cleared); // The recorded request and TTL describe the dropped conversation. session.idleCompaction?.reset(); break; @@ -8793,12 +7719,8 @@ export class ClaudeAcpAgent { // The CLI predicts the next prompt after the turn's `result`. AIR // shows it as gray text in an empty composer; Tab fills the // composer, and nothing is sent until the user sends it. - if (!session.promptSuggestionExpected) break; - session.promptSuggestionExpected = false; - await this.publishPromptSuggestion( - params.sessionId, - promptSuggestionText(message.suggestion), - ); + const suggested = session.promptSuggestions?.suggested(message.suggestion); + if (suggested) await this.publishPromptSuggestion(params.sessionId, suggested); break; } case "auth_status": @@ -9028,9 +7950,6 @@ export class ClaudeAcpAgent { // cancelled by it). const cancelledThrough = this.promptsReceived; this.cancelledThrough.set(params.sessionId, cancelledThrough); - // The interrupt below makes Claude Code withdraw the requests that are open - // now (see `withPendingUserInput`). - for (const input of session.openUserInputs ?? []) input.cancelled = true; // Before anything awaits, so a model request that the consumer reads in // the meantime is already judged against these prompts. session.cancelledPrompts ??= new Set(); @@ -9412,13 +8331,6 @@ export class ClaudeAcpAgent { if (candidate !== session) continue; // Its list row now shows what the registry and the transcript tell. this.sessionIndex.onOwnSessionChanged(sessionId); - // Its background tasks end with the query's process. - for (const exit of session.background.queryClosed()) { - this.client.sessionUpdate(exit).catch((error) => { - this.logger.error(`Session ${sessionId}: failed to end a background terminal:`, error); - }); - } - this.backgroundWork.sync(sessionId, session, this.foregroundState(sessionId)); } session.consumer = undefined; session.contextCompaction = undefined; @@ -9430,10 +8342,8 @@ export class ClaudeAcpAgent { /** Cleanly tear down a session: cancel in-flight work, release stream * resources, and remove it from the session map. Returns once the turns it - * cancels have ended, so their end (a v1 `session/prompt` response, a v2 - * `idle` state) goes out before the caller answers: ACP v2's - * `session/close` cancels "as if `session/cancel` had been called", then - * frees the session. A wedged consumer holds it at most + * cancels have ended, so their `session/prompt` responses go out before + * the caller answers. A wedged consumer holds it at most * {@link TEARDOWN_TURN_END_TIMEOUT_MS}. */ private async teardownSession(sessionId: string): Promise { const session = this.sessions[sessionId]; @@ -9452,7 +8362,7 @@ export class ClaudeAcpAgent { } // The turns end before this returns; so does a model cycle that Claude // Code started on its own, which no turn waits for. - this.endAutonomousCycle(sessionId, { outcome: { stopReason: "cancelled" } }); + this.endAutonomousCycle(sessionId); // cancel() arms the force-cancel floor and interrupts gracefully, but a // wedged consumer only wakes when `cancelController` aborts — closeQueryStream // below doesn't touch it. Since we're tearing the session down anyway, wake @@ -9475,7 +8385,6 @@ export class ClaudeAcpAgent { session.asyncTaskRuntime?.clear(); this.replayedChildModels.delete(sessionId); this.cancelledThrough.delete(sessionId); - this.backgroundWork.forget(sessionId); delete this.sessions[sessionId]; this.sessionIndex.onTeardown(sessionId); const ended = await raceTimeoutAndAbort( @@ -9487,9 +8396,8 @@ export class ClaudeAcpAgent { this.logger.error( `Session ${sessionId}: its cancelled turns did not end within ${TEARDOWN_TURN_END_TIMEOUT_MS}ms of the teardown`, ); - // Settle them here, so the close still answers after each turn's end: a - // v1 prompt gets `cancelled`, a v2 prompt not yet inserted gets -32800, - // and an inserted one ends in an `idle` with `cancelled`. + // Settle them here, so the close still answers after each turn's end: + // its prompt gets `cancelled`. for (const turn of turns) { if (turn.settled) continue; session.fileChangeReporter?.finish(turn.fileChangeReport, "cancelled"); @@ -9500,10 +8408,8 @@ export class ClaudeAcpAgent { session.activeTurn = null; // Foreground work that no turn owns, such as a cycle that Claude Code // started after the endAutonomousCycle call above. - const work = this.foregroundWork.get(sessionId); - if (work) { - this.endForeground(sessionId, work.owner, { outcome: { stopReason: "cancelled" } }); - } + const owner = this.foregroundWork.get(sessionId); + if (owner) this.endForeground(sessionId, owner); } } @@ -9522,57 +8428,6 @@ export class ClaudeAcpAgent { return {}; } - /** Stops a command running in the background (v2 `terminal/cancel`, see - * {@link cancelBackgroundWork}). */ - cancelBackgroundTerminal(params: { sessionId: string; terminalId: string }): Promise { - return cancelBackgroundWork(Object.values(this.sessions), params.sessionId, { - terminalId: params.terminalId, - }); - } - - /** Stops the task of a tool call running in the background (v2 - * `tool_call/cancel`, see {@link cancelBackgroundWork}). */ - cancelBackgroundToolCall(params: { sessionId: string; toolCallId: string }): Promise { - return cancelBackgroundWork(Object.values(this.sessions), params.sessionId, { - toolCallId: params.toolCallId, - }); - } - - /** Sends the current state of each tool call in `toolCallIds` that the root - * session `sessionId` runs in the background: the call as its tool use - * shows it, with its status now. v2 requires it for each tool call that a - * `session/resume` snapshot lists. A failure is logged, as for - * {@link publishLiveSubagents}. */ - async publishBackgroundToolCalls(sessionId: string, toolCallIds: string[]): Promise { - const session = this.sessions[sessionId]; - if (!session) return; - const renderer = new AcpToolCallRenderer(this.toolCallCapabilities); - try { - for (const update of session.background.toolCallStates(toolCallIds, renderer, session.cwd)) { - await this.client.sessionUpdate(update); - } - } catch (error) { - this.logger.error(`Session ${sessionId}: failed to send background tool calls:`, error); - } - } - - /** Sends the current `subagent_update` of each live subagent session in - * `children`, of the session `sessionId` (see - * {@link NativeSubagentRuntime.currentUpdate}). v2 requires it for each - * child that a `session/resume` snapshot lists. A failure is logged, as - * for {@link publishCurrentSubagentStates}. */ - async publishLiveSubagents(sessionId: string, children: string[]): Promise { - const runtime = this.sessions[sessionId]?.nativeSubagentRuntime; - try { - for (const child of children) { - const update = runtime?.currentUpdate(child); - if (update) await this.client.sessionUpdate(asSdkSessionNotification(update)); - } - } catch (error) { - this.logger.error(`Session ${sessionId}: failed to send current subagent states:`, error); - } - } - /** Per client: see {@link sessionIndex.SessionIndexConnection.deleteSession}. */ async deleteSession(params: DeleteSessionRequest): Promise { return this.sessionIndex.deleteSession(params); @@ -9611,77 +8466,16 @@ export class ClaudeAcpAgent { if (!option) { throw RequestError.invalidParams(undefined, `Unknown config option: ${params.configId}`); } - // The value must match the type the option was advertised with: a - // `type: "boolean"` option takes `true`/`false`, not "on"/"off", and a - // `type: "select"` option takes a string. - const expectedType = option.type === "boolean" ? "boolean" : "string"; - if (typeof params.value !== expectedType) { - throw RequestError.invalidParams( - { configId: params.configId, value: params.value }, - `Config option ${params.configId} takes a ${expectedType} value, but received: ${JSON.stringify(params.value)}`, - ); - } - - // Fast mode and ultracode carry a boolean value (for Clients that opted - // into boolean config options) or the "on"/"off" select fallback, so they - // bypass the - // string-only validation the select-style options below rely on. - if (params.configId === FAST_MODE_CONFIG_ID) { - await this.applyFastMode(session, resolveFastModeEnabled(params)); - return { configOptions: session.configOptions }; - } - if (params.configId === ULTRACODE_CONFIG_ID) { - await this.applyUltracode(session, resolveUltracodeEnabled(params)); - return { configOptions: session.configOptions }; - } - - if (typeof params.value !== "string") { - throw RequestError.invalidParams( - undefined, - `Invalid value for config option ${params.configId}: ${params.value}`, - ); - } - - const allValues = - "options" in option && Array.isArray(option.options) - ? option.options.flatMap((o) => ("options" in o ? o.options : [o])) - : []; - let validValue = allValues.find((o) => o.value === params.value); - - // For model options, fall back to resolveModelPreference when the exact - // value doesn't match. This lets callers use human-friendly aliases like - // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6". - // Resolve against session.modelInfos first: those entries carry - // `resolvedModel`, so a full model id (in either hint spelling) lands on - // the right row via the exact tier instead of a fuzzier one picking a - // same-family sibling from a different context lane. The options-derived - // list (which never carries `resolvedModel`) remains as a fallback for - // resolutions that don't map back onto a selectable option (e.g. a fuzzy - // hit on an out-of-picker verbatim entry). - if (!validValue && params.configId === MODEL_CONFIG_ID) { - const toOptionValue = (resolved: ModelInfo | null) => - resolved ? allValues.find((o) => o.value === resolved.value) : undefined; - validValue = toOptionValue(resolveModelPreference(session.modelInfos, params.value)); - if (!validValue) { - const optionInfos: ModelInfo[] = allValues.map((o) => ({ - value: o.value, - displayName: o.name, - description: o.description ?? "", - })); - validValue = toOptionValue(resolveModelPreference(optionInfos, params.value)); + const selected = configOptionValue(option, params, session.modelInfos); + if ("enabled" in selected) { + if (params.configId === FAST_MODE_CONFIG_ID) { + await this.applyFastMode(session, selected.enabled); + } else { + await this.applyUltracode(session, selected.enabled); } + return { configOptions: session.configOptions }; } - - if (!validValue) { - throw RequestError.invalidParams( - undefined, - `Invalid value for config option ${params.configId}: ${params.value}`, - ); - } - - // Use the canonical option value so downstream code always receives the - // model ID rather than the caller-supplied alias. - const resolvedValue = validValue.value; + const resolvedValue = selected.value; let effectiveValue = resolvedValue; if (params.configId === MODE_CONFIG_ID) { @@ -10092,33 +8886,17 @@ export class ClaudeAcpAgent { // Claude persists the retained summary as a user message framed with // continuation instructions for the model. Clients on the compaction // contract get it materialized as the completed compaction entity at - // this transcript position (the boundary's own system record comes back - // from getSessionMessages without its metadata, so the summary message - // is the one durable marker); other clients keep the transcript text. - // The entity is keyed by the summary message's uuid, which differs from - // the live id (the `compacting` status uuid) — replay is terminal-first - // by the RFD, and no client merges a replay into a live entity store. - // A subagent's own compaction summary is not the root session's. A summary - // whose framing is unrecognized is still this compaction, without summary - // text: isCompactSummary says what it is, and the framing is Claude Code's - // to change. + // this transcript position; other clients keep the transcript text. + // A subagent's own compaction summary is not the root session's. if ( replayCompactionUpdates && - isCompactSummaryMessage(message) && parentToolUseId === null && - typeof message.uuid === "string" && - message.uuid.length > 0 - ) { - const replayCompaction = new ContextCompactionLifecycle( + (await replayCompactSummary( + message, (notification) => this.client.sessionUpdate(notification), - { - sessionId, - presentation: "compaction_update", - airClient: this.toolCallCapabilities.air.client, - }, - ); - replayCompaction.recordSummary(assistantMessageText(message.message)); - await replayCompaction.finish(message.uuid, "completed"); + { sessionId, airClient: this.toolCallCapabilities.air.client }, + )) + ) { return; } @@ -10286,19 +9064,9 @@ export class ClaudeAcpAgent { * conversation) as awaiting user input for exactly the lifetime of its * promise (see `Session.openUserInputs`). Steering consults the open * requests synchronously, so a message arriving while any permission or - * elicitation card is open uses non-interrupting SDK delivery. - * - * `signal` aborts when Claude Code withdraws the request, which is not an - * answer. Example: the user cancels while a permission request is open. - * Claude Code's interrupt withdraws it as it stops the work, and the work - * ends `idle cancelled` right after. The end of the request then reports - * no `running`: the client would see `requires_action`, `running`, `idle - * cancelled`. A withdrawal without a cancel means that something else - * answered the request in the user's place, such as a `PermissionRequest` - * hook, and the work goes on, so the end reports `running`. */ + * elicitation card is open uses non-interrupting SDK delivery. */ private async withPendingUserInput( sessionId: string, - signal: AbortSignal, request: () => Promise, agentID?: string, ): Promise { @@ -10306,40 +9074,13 @@ export class ClaudeAcpAgent { if (!session) return request(); const input: OpenUserInput = agentID !== undefined ? { agentID } : {}; (session.openUserInputs ??= new Set()).add(input); - this.syncAwaitingUser(sessionId, session); try { return await request(); } finally { session.openUserInputs.delete(input); - const withdrawnByCancel = signal.aborted && input.cancelled === true; - this.syncAwaitingUser(sessionId, session, withdrawnByCancel); } } - /** Report whether the session's foreground work awaits the user: exactly - * while a permission request or question that blocks it is open (see - * {@link blocksForeground}). A request does not say which prompt it is for, - * and Claude Code can ask for a queued prompt before the consumer activates - * it, so a request is not tied to the work that was running when it - * opened. Called when a request opens or closes, when a turn activates, and - * when a task registers or moves to the background. - * - * When the last request ends, the work reports `running` again, unless a - * cancel withdrew that request (`withdrawnByCancel`, see - * {@link withPendingUserInput}): the work is being stopped, and reports its - * end next. */ - private syncAwaitingUser(sessionId: string, session: Session, withdrawnByCancel = false): void { - const work = this.foregroundWork.get(sessionId); - if (!work) return; - const waiting = [...(session.openUserInputs ?? [])].some((input) => - blocksForeground(session, input.agentID), - ); - if (waiting === work.awaitingUser) return; - work.awaitingUser = waiting; - if (waiting) work.events.awaitingUser(); - else if (!withdrawnByCancel) work.events.running(); - } - /** Whether Claude is working in the session `sessionId`: an unsettled turn in * its queue, or foreground work that no turn owns, such as a cycle that * Claude Code started on its own. */ @@ -10349,63 +9090,22 @@ export class ClaudeAcpAgent { ); } - /** Sends the current title and `updatedAt` of the session `sessionId` as a - * `session_info_update` (see {@link SessionTitles.publishCurrent}). A - * failure is logged: the info is optional, so it does not fail a resume. */ - async publishCurrentSessionInfo(sessionId: string): Promise { - const session = this.sessions[sessionId]; - if (!session) return; - try { - await session.titles.publishCurrent(session); - } catch (error) { - this.logger.error(`Session ${sessionId}: failed to send the session info:`, error); - } - } - - /** The foreground state of the session `sessionId` now, as v2's - * `state_update` names it: `idle` while no work runs, `requires_action` - * while the work awaits the user (see {@link syncAwaitingUser}), and - * `running` otherwise. */ - foregroundState(sessionId: string): "idle" | "running" | "requires_action" { - const work = this.foregroundWork.get(sessionId); - if (!work) return "idle"; - return work.awaitingUser ? "requires_action" : "running"; - } - /** Make `owner` the foreground work of the session `sessionId` (see - * {@link ForegroundEvents}): it reports `running`, then waits at once if a - * request is open. If work already runs, it goes on under `owner` and - * reports nothing new: a turn takes over a cycle that Claude Code started on - * its own, and only the owner's end ends the work. */ + * {@link ForegroundOwner}). If work already runs, it goes on under + * `owner`: a turn takes over a cycle that Claude Code started on its own, + * and only the owner's end ends the work. */ private startForeground(sessionId: string, owner: ForegroundOwner): void { - const current = this.foregroundWork.get(sessionId); - if (current) { - current.owner = owner; - return; - } - const events = this.foregroundEvents?.(sessionId) ?? UNREPORTED_FOREGROUND; - this.foregroundWork.set(sessionId, { owner, events, awaitingUser: false }); - events.running(); - const session = this.sessions[sessionId]; - if (session) this.syncAwaitingUser(sessionId, session); + this.foregroundWork.set(sessionId, owner); } - /** End the foreground work of the session `sessionId` with `outcome`, or as - * failed with `error`, if `owner` is that work. */ - private endForeground( - sessionId: string, - owner: ForegroundOwner, - end: { outcome: TurnOutcome } | { error: unknown }, - ): void { - const work = this.foregroundWork.get(sessionId); - if (work?.owner !== owner) return; - this.foregroundWork.delete(sessionId); - if ("outcome" in end) work.events.ended(end.outcome); - else work.events.failed(end.error); + /** End the foreground work of the session `sessionId`, if `owner` is that + * work. */ + private endForeground(sessionId: string, owner: ForegroundOwner): void { + if (this.foregroundWork.get(sessionId) === owner) this.foregroundWork.delete(sessionId); } /** Start a model cycle that Claude Code started on its own as the foreground - * work (see {@link ForegroundEvents}) when `message` is its first output: + * work (see {@link ForegroundOwner}) when `message` is its first output: * model output of the main conversation (not a subagent's) while no work * runs. That includes a turn that v1 holds for its background subagents, * whose work ended at its result, so its follow-up is a cycle. Output of @@ -10425,16 +9125,13 @@ export class ClaudeAcpAgent { this.startForeground(sessionId, AUTONOMOUS_CYCLE); } - /** Whether the client has been told that the session's work ended: on v2 by - * an idle state, so no foreground work is open, including a cycle that - * Claude Code started on its own; on v1 by the response to the turn's - * prompt, so no turn is active. v1 reports nothing for a cycle, so its end - * is its result or idle: until then, Claude Code's results for the cycle's - * tool calls still reach the client after a cancel. */ + /** Whether the client has been told that the session's work ended: by the + * response to the turn's prompt, so no turn is active. The client is told + * nothing of a cycle, so its end is its result or idle: until then, Claude + * Code's results for the cycle's tool calls still reach the client after a + * cancel. */ private workEndReported(sessionId: string, session: Session): boolean { - return this.foregroundEvents - ? !this.foregroundWork.has(sessionId) - : !session.activeTurn && !this.runsAutonomousCycle(sessionId); + return !session.activeTurn && !this.runsAutonomousCycle(sessionId); } /** Whether the consumer drops what it reaches now as output of the work a @@ -10501,17 +9198,14 @@ export class ClaudeAcpAgent { /** End the session's model cycle that Claude Code started on its own, if one * runs. */ - private endAutonomousCycle( - sessionId: string, - end: { outcome: TurnOutcome } | { error: unknown }, - ): void { - this.endForeground(sessionId, AUTONOMOUS_CYCLE, end); + private endAutonomousCycle(sessionId: string): void { + this.endForeground(sessionId, AUTONOMOUS_CYCLE); } /** Whether a model cycle that Claude Code started on its own is the session's * foreground work. A turn that took a cycle over owns the work from then on. */ private runsAutonomousCycle(sessionId: string): boolean { - return this.foregroundWork.get(sessionId)?.owner === AUTONOMOUS_CYCLE; + return this.foregroundWork.get(sessionId) === AUTONOMOUS_CYCLE; } /** The ACP turn that owns a root request of the session now, if any. A @@ -10570,7 +9264,6 @@ export class ClaudeAcpAgent { return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, signal, () => this.withPendingUserInput( ownerSessionId, - signal, () => this.client.requestPermission(params, signal), agentID, ), @@ -10599,9 +9292,9 @@ export class ClaudeAcpAgent { /** `signal`, also aborted when `teardownSession` aborts the * `abortController` of session `sessionId`. Claude Code aborts a callback's * own signal when its query closes, but not necessarily before - * `session/close` responds, and v2 requires every pending permission - * request and elicitation of the session to be cancelled with - * `$/cancel_request` before that response. */ + * `session/close` responds, and every pending permission request and + * elicitation of the session is cancelled with `$/cancel_request` before + * that response. */ private untilSessionClosed(sessionId: string, signal: AbortSignal): AbortSignal { const sessionSignal = this.sessions[sessionId]?.abortController?.signal; return sessionSignal ? AbortSignal.any([signal, sessionSignal]) : signal; @@ -10768,50 +9461,11 @@ export class ClaudeAcpAgent { .catch((error) => this.logger.error(`Session ${sessionId}: failed to report subagent cwd: ${error}`), ); - const options: SessionConfigOption[] = []; - if (input.permission_mode) { - const mode = session.modes.availableModes.find((mode) => mode.id === input.permission_mode); - options.push( - SessionModeManager.configOption({ - currentModeId: input.permission_mode, - availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }], - }), - ); - } - const switchedModel = - input.hook_event_name === "PostModelSwitch" - ? responseModelEntry(input.to_model, this.subagentModels(sessionId)) - : undefined; - // The entry of the root session's `model` option, as for a child response. - if (switchedModel) options.push(childModelOption(switchedModel)); - if (input.effort?.level) { - options.push({ - id: EFFORT_CONFIG_ID, - name: "Effort", - description: "Current reasoning effort", - category: "thought_level", - type: "select", - currentValue: input.effort.level, - options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }], - }); - } - if (input.agent_type) { - const agent = session.agents.find((agent) => agent.name === input.agent_type); - options.push({ - id: AGENT_CONFIG_ID, - name: "Agent", - description: "Subagent type", - type: "select", - currentValue: input.agent_type, - options: [ - { - value: input.agent_type, - name: input.agent_type, - ...(agent?.description ? { description: agent.description } : {}), - }, - ], - }); - } + const options = subagentHookOptions(input, { + availableModes: session.modes.availableModes, + models: this.subagentModels(sessionId), + agents: session.agents, + }); await session.nativeSubagentRuntime ?.reportConfiguration(input.agent_id, options) .catch((error) => @@ -10823,70 +9477,6 @@ export class ClaudeAcpAgent { }; } - /** - * Captures pre-execution patches for v2 Edit and Write tool calls. - * - * A v2 diff cannot hold the input's snippet, so the patch needs the file as - * it was before the tool runs. Claude Code waits for the hook before running - * the tool. If its streamed tool use has not arrived, the patch waits for - * the tool call's first publication. The hook makes no permission decision, - * and a failure only leaves the preview out. - */ - private fileChangePreviewHook(sessionId: string): HookCallback { - return async (input, toolUseID, { signal }) => { - if (input.hook_event_name === "PreToolUse" && toolUseID) { - await this.previewFileChange(sessionId, input, toolUseID, signal).catch((error) => - this.logger.error(`Session ${sessionId}: failed to preview ${toolUseID}: ${error}`), - ); - } - return { continue: true }; - }; - } - - private async previewFileChange( - sessionId: string, - input: PreToolUseHookInput, - toolUseID: string, - signal: AbortSignal, - ): Promise { - const session = this.sessions[sessionId]; - if (!session) return; - // As in `canUseTool`: the work a cancel stopped shows nothing. - const stopped = this.isStoppedWorkToolUse(session, toolUseID, signal); - if (stopped instanceof Promise ? await stopped : stopped) return; - const toolInput = (input.tool_input ?? {}) as Record; - const content = await previewPatchContent(input.tool_name, toolInput, session.cwd, "v2"); - if (!content) return; - const fields = toolCallFieldsOf(session); - // Workflow tools can run hooks without streaming their use or result. - // Keep the patch until the stream or a permission request publishes the - // tool call, instead of creating a pending call with no completion path. - if (!session.emittedToolCalls.has(toolUseID)) { - fields.stagePreview(toolUseID, content); - return; - } - const { parentToolUseId, toolCallSessionId } = this.toolUseOwner( - session, - sessionId, - input.agent_id, - ); - const update: SessionNotification["update"] = { - sessionUpdate: "tool_call_update", - toolCallId: toolUseID, - content, - }; - if (parentToolUseId) stampParentToolUseId(update, parentToolUseId); - if (!fields.apply(update)) return; - // The streamed input must not replace the patch, as for an approval patch. - fields.pinContent(toolUseID, content); - // The stream's router sends it to the session that holds the tool call. - if (session.nativeSubagentDeliver) { - await session.nativeSubagentDeliver({ sessionId, update }); - } else { - await this.client.sessionUpdate({ sessionId: toolCallSessionId, update }); - } - } - canUseTool(sessionId: string): CanUseTool { return async ( toolName, @@ -10984,15 +9574,9 @@ export class ClaudeAcpAgent { const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true; const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, noPersistentRule); const capabilities = this.toolCallCapabilities; - const previewContent = - capabilities.diffPatch || capabilities.v2 - ? await previewPatchContent( - toolName, - toolInput, - session.cwd, - capabilities.v2 ? "v2" : "air", - ) - : undefined; + const previewContent = capabilities.diffPatch + ? await previewPatchContent(toolName, toolInput, session.cwd, "air") + : undefined; const presentation = buildClaudePermissionPresentation({ toolName, input: toolInput, @@ -11008,24 +9592,15 @@ export class ClaudeAcpAgent { defaultToNo, }); - // `mcpServer` (SDK 0.3.274+): which MCP server serves an `mcp__*` tool - // and where its definition came from. Forwarded verbatim so a client - // can key trust on `source` (`sdk` = a host-registered in-process - // server; anything else is configuration) instead of parsing the - // tool-name prefix. The name is the config key as authored — untrusted - // text, so it rides `_meta` rather than the title. // AIR already holds the tool name and the parent tool call. Every // other client gets them again, like upstream. - const airClient = capabilities.air.client; - if (mcpServer || (parentToolUseId && !airClient)) { - presentation.toolCall._meta = { - claudeCode: { - ...(airClient ? {} : { toolName }), - ...(parentToolUseId && !airClient ? { parentToolUseId } : {}), - ...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}), - }, - }; - } + const toolCallMeta = permissionToolCallMeta({ + toolName, + parentToolUseId, + mcpServer, + airClient: capabilities.air.client, + }); + if (toolCallMeta) presentation.toolCall._meta = toolCallMeta; const permissionOptions = buildClaudePermissionOptions({ toolName, @@ -11075,14 +9650,10 @@ export class ClaudeAcpAgent { // cancel does not stop. Answer such a request with a denial so the // subagent reads "the user refused" and goes on. Throwing instead makes // Claude Code deny it as "Tool permission request failed". - // On ACP v2, `cancelled` also answers a prompt that the user dismissed - // or that the client declined by policy. Without a `session/cancel` - // (`session.cancelled` unset), that is the user refusing the tool, so it - // gets the same denial and the turn goes on. if ( response.outcome?.outcome === "cancelled" && this.sessions[sessionId] === session && - (!blocksForeground(session, agentID) || (this.v2 && !session.cancelled)) + !blocksForeground(session, agentID) ) { return { behavior: "deny", @@ -11156,53 +9727,32 @@ export class ClaudeAcpAgent { /** * Handle elicitation requests that originate from MCP servers by forwarding - * them to the client over ACP. Only the user's own decline reaches the MCP - * server as `decline`. A request the user never saw, because the client did - * not advertise its mode, we can't represent it, or the client answered with - * an error, is a `cancel`. + * them to the client over ACP (see {@link answerMcpElicitation}). */ private handleMcpElicitation(sessionId: string, support: ElicitationSupport): OnElicitation { return async (request, { signal: callbackSignal }) => { const signal = this.untilSessionClosed(sessionId, callbackSignal); - const isUrl = request.mode === "url"; - if ((isUrl && !support.url) || (!isUrl && !support.form)) { - return { action: "cancel" }; - } - - const createRequest = mcpElicitationToCreateRequest(request, sessionId); - if (!createRequest) { - return { action: "cancel" }; - } - - try { - const response = await this.withPendingUserInput(sessionId, signal, () => - this.elicitFromClient(createRequest, signal), - ); - if (signal.aborted) { - return { action: "cancel" }; - } - return createElicitationResponseToElicitResult(response); - } catch (error) { - // A cancellation we requested (signal aborted) settles as a cancel, not - // a hard decline — the elicitation was abandoned, not refused. - if (signal.aborted) { - return { action: "cancel" }; - } - // The client answered with an error, e.g. -32602 for a form it cannot - // render, so the user never saw the request. MCP's `decline` means the - // user refused; `cancel` means they made no explicit choice. - this.logger.error(`Failed to forward MCP elicitation: ${error}`); - return { action: "cancel" }; - } + return answerMcpElicitation(request, { + sessionId, + support, + logger: this.logger, + elicit: (createRequest) => + elicitUnlessWithdrawn(signal, (signal) => + this.withPendingUserInput(sessionId, () => + this.elicitFromClient(createRequest, signal), + ), + ), + }); }; } /** * Present the built-in AskUserQuestion tool's questions as an ACP form - * elicitation and return the answers as the tool's `updatedInput`. Called from - * `canUseTool` since that is where the SDK routes the tool's permission check. - * The elicitation goes to `requestSessionId`, a subagent's child session for - * a question it asks, and blocks the turn of `sessionId`. + * elicitation and return the answers as the tool's `updatedInput` (see + * {@link answerAskUserQuestion}). Called from `canUseTool` since that is + * where the SDK routes the tool's permission check. The elicitation goes to + * `requestSessionId`, a subagent's child session for a question it asks, + * and blocks the turn of `sessionId`. */ private async handleAskUserQuestion( sessionId: string, @@ -11213,89 +9763,42 @@ export class ClaudeAcpAgent { agentID: string | undefined, ): Promise { const signal = this.untilSessionClosed(sessionId, toolSignal); - const questions = extractAskUserQuestions(toolInput); - if (!questions) { - return { behavior: "deny", message: "AskUserQuestion called with no valid questions." }; - } - - const createRequest = askUserQuestionsToCreateRequest( - questions, - requestSessionId, + return answerAskUserQuestion(toolInput, { + sessionId: requestSessionId, toolUseID, - this.toolCallCapabilities.air.client, - ); - let response; - try { - response = await this.withChildAwaitingUser(sessionId, requestSessionId, signal, () => - this.withPendingUserInput( - sessionId, - signal, - () => this.elicitFromClient(createRequest, signal), - agentID, + airClient: this.toolCallCapabilities.air.client, + reportUnanswered: () => this.sessions[sessionId]?.askUserQuestionUnansweredV1 === true, + logger: this.logger, + elicit: (createRequest) => + elicitUnlessWithdrawn(signal, (signal) => + this.withChildAwaitingUser(sessionId, requestSessionId, signal, () => + this.withPendingUserInput( + sessionId, + () => this.elicitFromClient(createRequest, signal), + agentID, + ), + ), ), - ); - } catch (error) { - // A cancellation we requested (signal aborted) settles as an aborted tool - // use, matching the post-response check below. - if (signal.aborted) { - throw new Error("Tool use aborted", { cause: error }); - } - this.logger.error(`Failed to present AskUserQuestion elicitation: ${error}`); - return { behavior: "deny", message: "Could not present the question to the user." }; - } - if (signal.aborted) { - throw new Error("Tool use aborted"); - } - - const outcome = applyAskElicitationResponse(response, toolInput, questions, { - reportUnanswered: this.sessions[sessionId]?.askUserQuestionUnansweredV1 === true, }); - if (outcome.action === "cancel") { - throw new Error("Tool use aborted"); - } - return { behavior: "allow", updatedInput: outcome.updatedInput }; } /** * Handle `request_user_dialog` control requests — blocking dialogs the CLI - * asks the host to render. Only kinds declared in `supportedDialogKinds` - * are ever emitted; everything unexpected is answered `cancelled` (the - * required answer for unrecognized kinds), which applies the dialog's - * default behavior CLI-side. Today the only declared kind is the - * refusal-fallback consent prompt, rendered as an ACP form elicitation. + * asks the host to render (see {@link answerUserDialog}). */ private handleUserDialog(sessionId: string): OnUserDialog { return async (request, { signal: callbackSignal }) => { const signal = this.untilSessionClosed(sessionId, callbackSignal); - if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) { - return { behavior: "cancelled" }; - } - const prompt = extractRefusalFallbackPrompt(request.payload); - if (!prompt) { - this.logger.error( - `refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`, - ); - return { behavior: "cancelled" }; - } - let response: CreateElicitationResponse; - try { - response = await this.withPendingUserInput(sessionId, signal, () => - this.elicitFromClient(refusalFallbackToCreateRequest(prompt, sessionId), signal), - ); - } catch (error) { - // A cancellation we requested (signal aborted) is expected teardown; - // anything else is a client failure. Either way the safe answer is - // `cancelled` — the CLI applies the dialog's default (keep the - // refusal) rather than switching models without consent. - if (!signal.aborted) { - this.logger.error(`Failed to present refusal fallback elicitation: ${error}`); - } - return { behavior: "cancelled" }; - } - if (signal.aborted) { - return { behavior: "cancelled" }; - } - return { behavior: "completed", result: refusalFallbackResultFromResponse(response) }; + return answerUserDialog(request, { + sessionId, + logger: this.logger, + elicit: (createRequest) => + elicitUnlessWithdrawn(signal, (signal) => + this.withPendingUserInput(sessionId, () => + this.elicitFromClient(createRequest, signal), + ), + ), + }); }; } @@ -11376,11 +9879,9 @@ export class ClaudeAcpAgent { /** * The work that follows a session setup response: the session's commands, * the current states of the subagents a load replayed, and MCP server - * authentication. On v2 the setup handlers of `src/v2/agent.ts` do this - * work once they have sent their response, so it does nothing here. + * authentication. */ private afterSetupResponse(sessionId: string, mcpServers: McpServer[]): void { - if (this.v2) return; setTimeout(() => { this.sendAvailableCommandsUpdate(sessionId); void this.publishCurrentSubagentStates(sessionId); @@ -11388,16 +9889,6 @@ export class ClaudeAcpAgent { }, 0); } - /** - * Starts OAuth in the background for the servers of `mcpServers` that - * Claude Code reports as needing it, which sends the client a URL - * elicitation. The v2 surface calls it after the setup response, since v2 - * asks for no elicitation before the `session/new` response. - */ - authenticateMcpServers(sessionId: string, mcpServers: McpServer[]): void { - startMcpAuthentication(this, sessionId, mcpServers); - } - /** * Sends the current state of each subagent that the last load of * `sessionId` replayed (see {@link RfdSubagentReplay.currentStates}). Run it @@ -11472,21 +9963,12 @@ export class ClaudeAcpAgent { } /** - * Refine a heuristic context window with `getContextUsage().rawMaxTokens`, - * without blocking the caller. The text heuristic misses natively-1M models - * whose picker rows carry no "1m" token (e.g. `sonnet`, and since CLI - * 2.1.283 `opus`/`default`), so those would otherwise show 200k until the - * first result's modelUsage arrives. - * - * Never awaited: SDK control requests are serialized, so awaiting here - * would delay session/new or a model switch (~15s on older CLIs, issues - * #886/#880; ~0.5s on 2.1.283). Requests the `summary` detail instead of - * the default `full` breakdown, which issues one `messages/count_tokens` - * call per category (~18 for a bare session) and hit rate limits on every - * model switch; `summary` answers locally with the same `rawMaxTokens`. - * - * Does not write `contextWindowCache` (that stays keyed to the - * `result.modelUsage` spellings) — a later result still overwrites this. + * Refine a heuristic context window with `getContextUsage().rawMaxTokens` + * (`readContextWindow`), without blocking the caller. The text heuristic + * misses natively-1M models whose picker rows carry no "1m" token (e.g. + * `sonnet`, and since CLI 2.1.283 `opus`/`default`), so those would + * otherwise show 200k until the first result's modelUsage arrives. A later + * result still overwrites this. */ private refreshContextWindowInBackground(sessionId: string, session: Session): void { if (session.contextWindowAuthoritative) return; @@ -11498,12 +9980,12 @@ export class ClaudeAcpAgent { session.models.currentModelId === modelId; // A synchronous throw must not fail the caller either. Promise.resolve() - .then(() => query.getContextUsage({ detail: "summary" })) + .then(() => readContextWindow(query)) .then( - (usage) => { + (window) => { if (!stillCurrent() || session.contextWindowAuthoritative) return; - if (!(usage.rawMaxTokens > 0)) return; - session.contextWindowSize = usage.rawMaxTokens; + if (window === undefined) return; + session.contextWindowSize = window; session.contextWindowAuthoritative = true; }, (error) => { @@ -11554,44 +10036,25 @@ export class ClaudeAcpAgent { } // Rebuild config options since effort levels depend on the selected - // model. The effort seed depends on who owns the choice: a user pin - // made through the ACP picker this session lives at the SDK's flag - // layer and follows the session across switches, so carry it forward. - // Otherwise the CLI resolves effort itself from the persisted settings - // — the NEW model's `modelSettings` entry first (the CLI persists - // /effort per model), then the legacy top-level value — so display - // what it will actually run instead of dragging the old model's value - // along. + // model (see `effortForModelSwitch`). const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID); - const currentEffort = - typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined; const useRecommendedValue = clientSupportsRecommendedConfigValue(this.clientCapabilities); - const pinnedEffort = session.effortPinnedLevel; - const effortWasPinned = pinnedEffort !== undefined; - const appliedEffortBeforeSwitch = - session.appliedEffortLevel ?? - (useRecommendedValue && typeof currentEffort === "string" && currentEffort !== "default" - ? currentEffort - : pinnedEffort); - const effortPinnedForNewModel = - effortWasPinned && - newModelInfo?.supportsEffort === true && - newModelInfo.supportedEffortLevels?.some((level) => level === pinnedEffort) === true; - const seedEffort = effortPinnedForNewModel - ? pinnedEffort - : settingsEffortForModel( - mergeEffortSettings( - session.settingsManager.getSettings(), - session.effortSettingsOverride, - ), - newModelInfo, - value, - ); + const effortSwitch = effortForModelSwitch({ + state: { pinned: session.effortPinnedLevel, applied: session.appliedEffortLevel }, + displayed: typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined, + newModelInfo, + modelId: value, + settings: mergeEffortSettings( + session.settingsManager.getSettings(), + session.effortSettingsOverride, + ), + useRecommendedValue, + }); session.configOptions = buildConfigOptions( session.modes, session.models, session.modelInfos, - seedEffort, + effortSwitch.seed, session.agents, session.currentAgent, { @@ -11615,70 +10078,27 @@ export class ClaudeAcpAgent { }, ); - // Opted-in clients apply the concrete displayed effort on every switch, - // including settings-derived values, so a previous automatic flag cannot - // shadow the new model's settings. This does not create a user pin. - // For legacy clients, sync only when a user pin changed across the - // switch — i.e. the new model clamped it away (buildConfigOptions - // validated the seed against the new model's levels), where the flag - // must be cleared too or the SDK would keep running the old pin - // invisibly. Settings-derived seeds are display-only: the CLI resolves - // persisted effort itself, and pinning it at the flag layer would - // shadow the per-model values on every later switch. - const shouldSyncEffort = useRecommendedValue || (effortWasPinned && !effortPinnedForNewModel); - if (shouldSyncEffort) { - const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID); - const newEffort = - typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined; - try { - await session.query.applyFlagSettings( - // A legacy client's unpinned effort is display-only: the CLI - // resolves the persisted value for the new model. When an old - // user pin is no longer supported, clear the flag layer instead - // of replacing it with that displayed value. Opted-in clients - // deliberately apply their concrete displayed effort. - effortFlagSettings( - useRecommendedValue ? newEffort : undefined, - session.ultracodeEnabled, - ), - ); - session.effortPinnedLevel = effortPinnedForNewModel ? pinnedEffort : undefined; - session.appliedEffortLevel = - useRecommendedValue && newEffort !== "default" ? newEffort : undefined; - } catch (error) { - // setModel has already succeeded. Effort synchronization is a - // secondary, best-effort operation: propagating this error would - // make the RPC report failure (or suppress an external-switch - // notification) even though the SDK is already on the new model. - // Preserve the old SDK/pin bookkeeping and never advertise the - // unapplied value: restore the last applied value when the new model - // can select it, otherwise omit the effort option until a later - // successful switch rebuilds it. - session.effortPinnedLevel = pinnedEffort; - session.appliedEffortLevel = appliedEffortBeforeSwitch; - const appliedValueStillSelectable = - appliedEffortBeforeSwitch !== undefined && - newEffortOpt?.type === "select" && - newEffortOpt.options.some((option) => - "value" in option - ? option.value === appliedEffortBeforeSwitch - : option.options.some((nested) => nested.value === appliedEffortBeforeSwitch), - ); - if (newEffortOpt?.type === "select" && appliedValueStillSelectable) { - newEffortOpt.currentValue = appliedEffortBeforeSwitch; - } else { - session.configOptions = session.configOptions.filter( - (option) => option.id !== EFFORT_CONFIG_ID, - ); - } - this.logger.error( - `Failed to synchronize effort after model switch to "${value}":`, - error, - ); - } - } else if (effortPinnedForNewModel) { - session.effortPinnedLevel = pinnedEffort; - session.appliedEffortLevel = pinnedEffort; + // setModel has already succeeded, so a failed effort sync is logged: + // propagating it would make the RPC report failure (or suppress an + // external-switch notification) even though the SDK is already on the + // new model. + const effort = await settleEffortAfterModelSwitch( + effortSwitch, + session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID), + session.ultracodeEnabled, + useRecommendedValue, + (settings) => session.query.applyFlagSettings(settings), + ); + session.effortPinnedLevel = effort.state.pinned; + session.appliedEffortLevel = effort.state.applied; + session.configOptions = session.configOptions.flatMap((option) => + option.id !== EFFORT_CONFIG_ID ? [option] : effort.option ? [effort.option] : [], + ); + if ("error" in effort) { + this.logger.error( + `Failed to synchronize effort after model switch to "${value}":`, + effort.error, + ); } // Emit current_mode_update only after session.modes AND @@ -11811,67 +10231,39 @@ export class ClaudeAcpAgent { * `fast_mode_state` (delivered on `system`/init and on user-turn `result`s). * The SDK can flip fast mode independently of the user — e.g. back to `on` * once a rate-limit `cooldown` clears — so we mirror definitive on/off - * changes into the config option and notify the client. + * changes into the config option and notify the client (see + * {@link fastModeStateChange}). * - * Guards, in order: - * - absent state: nothing to reconcile. - * - no Fast mode option: the current model doesn't support fast mode, so the - * reported state reflects capability, not the user's intent. Leave the - * retained setting untouched so it's correct when a supporting model is - * reselected (the source of the earlier intent-clobber bug was mutating it - * here). - * - `cooldown`: a transient suspension of an already-enabled fast mode. - * Leave the toggle as-is rather than flapping it — and never let a stray - * cooldown spuriously enable a toggle the user has off. - * - * `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the - * state. Only explainable reasons are retained (see - * {@link normalizeFastModeDisabledReason}), so the comparison below tracks - * exactly what the user can see: a routine `sdk_opt_in_required` report on - * every turn's result can't churn the option, while a real blocker updates - * the description even when the toggle's own value is unchanged. */ + * Without a Fast mode option the current model doesn't support fast mode, + * so the reported state reflects capability, not the user's intent. The + * retained setting stays untouched so it's correct when a supporting model + * is reselected (the source of the earlier intent-clobber bug was mutating + * it here). */ private async syncFastModeState( sessionId: string, session: Session, state: FastModeState | undefined, reason?: FastModeDisabledReason, ): Promise { - if (state === undefined) { - return; - } if (!session.configOptions.some((o) => o.id === FAST_MODE_CONFIG_ID)) { return; } - if (state === "cooldown") { - return; - } - const enabled = state === "on"; - // A reason only describes an off state; drop any that rides an `on` report - // so it can't decorate the option the next time fast mode goes off. - const nextReason = enabled ? undefined : normalizeFastModeDisabledReason(reason); - if (enabled === session.fastModeEnabled && nextReason === session.fastModeDisabledReason) { - return; - } - // The user asked for Fast mode and the SDK is telling us it can't serve it. - // The description carries the same explanation, but a toggle silently - // snapping back is the case worth saying out loud once, at the flip. - const explanation = - nextReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[nextReason] : undefined; - const explain = session.fastModeEnabled && !enabled && explanation !== undefined; - session.fastModeEnabled = enabled; - session.fastModeDisabledReason = nextReason; - this.refreshFastModeOption(session, enabled); - if (explain) { + const change = fastModeStateChange( + { enabled: session.fastModeEnabled, disabledReason: session.fastModeDisabledReason }, + state, + reason, + ); + if (!change) return; + session.fastModeEnabled = change.enabled; + session.fastModeDisabledReason = change.disabledReason; + this.refreshFastModeOption(session, change.enabled); + if (change.notice) { await this.client.sessionUpdate({ sessionId, update: noticeOrTranscriptUpdate( - { - severity: "warning", - title: "Fast mode turned off", - description: `${sentenceCase(explanation)}.`, - }, + change.notice.notice, clientSupportsNotices(this.clientCapabilities), - `**Fast mode turned off:** ${explanation}.`, + change.notice.transcript, ), }); } @@ -12071,58 +10463,14 @@ export class ClaudeAcpAgent { await settingsManager.initialize(); timing.phase("settings"); - const mcpServers: Record = {}; - if (Array.isArray(params.mcpServers)) { - for (const server of params.mcpServers) { - if ("type" in server && (server.type === "http" || server.type === "sse")) { - // HTTP or SSE type MCP server - mcpServers[server.name] = { - type: server.type, - url: server.url, - headers: server.headers - ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) - : undefined, - }; - } else if (!("type" in server)) { - // Stdio type MCP server (with or without explicit type field) - mcpServers[server.name] = { - type: "stdio", - command: server.command, - args: server.args, - env: server.env - ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) - : undefined, - }; - } - } - } + const mcpServers = sdkMcpServers(params.mcpServers); const customInstructions = airCustomInstructions(params._meta); - let systemPrompt: Options["systemPrompt"] = - customInstructions === undefined - ? { type: "preset", preset: "claude_code" } - : { type: "preset", preset: "claude_code", append: customInstructions }; - if (params._meta?.systemPrompt) { - const customPrompt = params._meta.systemPrompt; - if (typeof customPrompt === "string") { - systemPrompt = customPrompt; - } else if ( - typeof customPrompt === "object" && - customPrompt !== null && - !Array.isArray(customPrompt) - ) { - // Forward all preset options (append, excludeDynamicSections, and - // anything the SDK adds later) while locking type/preset. - systemPrompt = { - ...(customPrompt as object), - type: "preset", - preset: "claude_code", - } as Options["systemPrompt"]; - } - } - // The spec's `systemPrompt` takes precedence over the `_meta.systemPrompt` - // extension above. - if (clientPrompt) systemPrompt = sdkSystemPrompt(clientPrompt, customInstructions); + const systemPrompt = sessionSystemPrompt( + clientPrompt, + params._meta?.systemPrompt, + customInstructions, + ); // Extract options from _meta if provided const sessionMeta = params._meta as NewSessionMeta | undefined; @@ -12158,9 +10506,6 @@ export class ClaudeAcpAgent { this.logger, ); - // Parse model configuration from environment (e.g. Bedrock model overrides) - const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG); - // Elicitation modes the connected client advertised. We only forward // elicitations (and only re-enable AskUserQuestion) for modes the client // can actually render. @@ -12224,16 +10569,11 @@ export class ClaudeAcpAgent { // into the query. const resolvedProvider = this.resolveProviderConfig(); const providerEnv = createEnvForProvider(resolvedProvider); - const providedSettings = userProvidedOptions?.settings; - const providedSettingsObject = - typeof providedSettings === "string" - ? (JSON.parse( - await fs.readFile(path.resolve(params.cwd, providedSettings), "utf8"), - ) as Settings) - : providedSettings; - const configuredSettingsObject = modelConfig - ? { ...modelConfig, ...providedSettingsObject } - : providedSettingsObject; + // With the model configuration of the environment (e.g. Bedrock model overrides). + const configuredSettingsObject = await configuredSettings( + userProvidedOptions?.settings, + params.cwd, + ); // Claude Code applies env from settings.json after the subprocess env. Put // an active ACP route in the programmatic settings tier too so user/project // settings cannot silently restore a different ANTHROPIC_BASE_URL. @@ -12261,7 +10601,7 @@ export class ClaudeAcpAgent { // are distinguished exactly as the CLI will see them. const providerCacheKey = providerCacheKeyFor(env); this.logger.log( - `[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`, + `[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${loggableBaseUrl(resolvedProvider?.baseUrl)}`, ); // Both forms of native subagent sessions report the configuration of a child. @@ -12352,17 +10692,11 @@ export class ClaudeAcpAgent { ], } : {}), - // v2 needs the patch captured before an Edit or Write runs, also - // without a permission request (`fileChangePreviewHook`): a v2 diff - // cannot hold the input's snippet. - ...(this.v2 || subagentConfigHook + ...(subagentConfigHook ? { PreToolUse: [ ...(userProvidedOptions?.hooks?.PreToolUse || []), - ...(subagentConfigHook ? [{ hooks: [subagentConfigHook] }] : []), - ...(this.v2 - ? [{ matcher: "Edit|Write", hooks: [this.fileChangePreviewHook(sessionId)] }] - : []), + { hooks: [subagentConfigHook] }, ], } : {}), @@ -12488,8 +10822,12 @@ export class ClaudeAcpAgent { prompt: input, options, }); - this.useAuthStatusSettings(settingsManager, configuredSettingsObject, userProvidedOptions?.env); - const authSettingsEpoch = this.authEpoch; + this.authStatus.useSettings( + settingsManager, + configuredSettingsObject, + userProvidedOptions?.env, + ); + const authSettingsEpoch = this.authStatus.epoch; timing.phase("prepare-query"); // `query()` spawns the CLI at once. Any throw between here and the @@ -12515,8 +10853,8 @@ export class ClaudeAcpAgent { // Publish the identity BEFORE the guard can refuse this session. A // refusal is exactly when the client most needs to know which account it // was refused for. - if (authSettingsEpoch === this.authEpoch) { - this.publishSessionAccountIdentity( + if (authSettingsEpoch === this.authStatus.epoch) { + this.authStatus.publishSessionAccount( initializationResult.account, settingsManager.getApiKeyHelper(configuredSettingsObject), ); @@ -12591,41 +10929,13 @@ export class ClaudeAcpAgent { timing.phase("models"); // Resolve the current model's capabilities separately from the stable - // permission-mode catalog advertised to ACP clients. - // A resumed session can be running a model outside the `availableModels` - // allowlist (currentModelId is then the verbatim live id, see - // `matchResumedModel`); its capabilities are still known to the SDK's - // unfiltered list, so fall back to that before treating the model as - // unknown — otherwise auto mode would be spuriously clamped and the - // Fast-mode/Effort options hidden for a model that supports them. - const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId); - const fallbackModelInfo = allowlistedModelInfo - ? undefined - : (resolveModelPreference(sdkModels, models.currentModelId) ?? undefined); - const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo; - // Register the fallback-resolved capabilities under the verbatim live id - // so every modelInfos consumer (buildConfigOptions' effort lookup, later - // rebuilds via session.modelInfos) agrees with the gating below. - // `buildModelConfigOption` lists this model as its own entry while it is - // current and drops it once the user switches to a listed model; it is - // never added to `models.availableModels`. The spread keeps every capability flag - // (current and future); the identity fields are overridden because the - // fuzzy-matched sibling's resolvedModel/displayName/description can - // describe a different context lane and would poison later resolvedModel - // matching (syncModelAfterExternalSwitch) and context-window inference - // (applyConfigOptionValue) if they traveled under this id. - const modelInfos = fallbackModelInfo - ? [ - ...allowedModels, - { - ...fallbackModelInfo, - value: models.currentModelId, - displayName: models.currentModelId, - description: "", - resolvedModel: undefined, - }, - ] - : allowedModels; + // permission-mode catalog advertised to ACP clients (see + // `withCurrentModelInfo`). + const { allowlistedModelInfo, currentModelInfo, modelInfos } = withCurrentModelInfo( + allowedModels, + sdkModels, + models.currentModelId, + ); const { modes, autoModeFallbackWarningPending } = await this.sessionModes.initialize({ query: q, requestedMode: initialPermissionMode, @@ -12795,13 +11105,9 @@ export class ClaudeAcpAgent { emittedToolCalls: new Set(), eagerToolCallSessions: new Map(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork( - sessionId, - this.backgroundWork.enabled && options.perTaskStopAffordance === true, - ), + background: new SessionBackgroundWork(sessionId), stopsWorkflowsOnCancel: options.perTaskStopAffordance === true && - !this.backgroundWork.enabled && !clientSupportsAsyncTasks(this.clientCapabilities), nativeSubagentsByTaskId: new Map(), nativeSubagentTaskIdByToolUseId: new Map(), @@ -12817,6 +11123,9 @@ export class ClaudeAcpAgent { settingsManager.getApiKeyHelper(configuredSettingsObject), )?.kind, fileChangeReporter, + ...(this.toolCallCapabilities.air.client + ? { promptSuggestions: new PromptSuggestions() } + : {}), }; const registered = this.sessions[sessionId]; registered.idleCompaction = new IdleCompaction( @@ -12840,11 +11149,6 @@ export class ClaudeAcpAgent { { env }, ); timing.phase("register"); - this.backgroundWork.sync( - sessionId, - this.sessions[sessionId], - this.foregroundState(sessionId), - ); this.refreshContextWindowInBackground(sessionId, this.sessions[sessionId]); return { @@ -12870,20 +11174,7 @@ export class ClaudeAcpAgent { // and logout apply in the order they arrived. A failed listing fails this // `providers/set` before the provider replaces the current one. if (config) { - try { - Object.assign(config, await this.listProviderModels(config)); - } catch (error) { - if (error instanceof GatewayListingError) { - throw RequestError.internalError( - { providerId: PROVIDER_ID, apiType: config.apiType, baseUrl: config.baseUrl }, - `Model discovery for provider "${PROVIDER_ID}" failed: ${error.message}`, - ); - } - throw error; - } - this.logger.log( - `[providers/set] listedModels=${config.listedModels?.length ?? "none"} bedrockRegionPrefix=${config.bedrockRegionPrefix ?? "none"}`, - ); + await withListedModels(config, this.providerConfig, this.logger); } const sessions = Object.entries(this.sessions); const activeTurns = sessions.flatMap(([, session]) => @@ -13202,20 +11493,6 @@ function turnQuotaMeta(session: Session) { return quotaMeta(session.accumulatedUsage, session.accumulatedModelUsage); } -/** `_meta.quota` for work that spent `usage` in its main agent loop and - * `modelUsage` by model (see {@link turnQuotaMeta}). */ -function quotaMeta(usage: AccumulatedUsage, modelUsage: ModelTokenTally | undefined) { - return { - quota: { - token_count: quotaTokenCount(usage), - model_usage: Object.entries(modelUsage ?? {}).map(([model, usage]) => ({ - model, - token_count: quotaTokenCount(usage), - })), - }, - }; -} - /** Whether a request that awaits the user for the subagent `agentID` (absent * for the main conversation) blocks the session's foreground work. The main * conversation's requests do. So do those of a subagent that runs in the @@ -13243,184 +11520,12 @@ function blocksForeground(session: Session, agentID: string | undefined): boolea /** Whether Claude Code ended the work of `result` because an interrupt * stopped it, while the model streamed or while a tool ran. */ -function isAbortedResult(result: Extract): boolean { +export function isAbortedResult(result: Extract): boolean { return ( result.terminal_reason === "aborted_streaming" || result.terminal_reason === "aborted_tools" ); } -/** How a model cycle that Claude Code started on its own ended, read from its - * result as a turn's is: a refusal, the token limit, and the turn and budget - * limits are their stop reasons, an error result fails the cycle, and a - * cancel ends it `cancelled`. The usage and `_meta.quota` are the result's - * own, `modelUsage` its increment by model, since autonomous results stay out - * of the turn tally. */ -function autonomousCycleEnd( - result: Extract, - modelUsage: ModelTokenTally, - cancelled: boolean, -): { outcome: TurnOutcome } | { error: unknown } { - // Typed non-null, but third-party backends have been observed omitting - // usage token fields (see snapshotFromUsage). - const tally: AccumulatedUsage = { - inputTokens: result.usage.input_tokens ?? 0, - outputTokens: result.usage.output_tokens ?? 0, - cachedReadTokens: result.usage.cache_read_input_tokens ?? 0, - cachedWriteTokens: result.usage.cache_creation_input_tokens ?? 0, - }; - const ended = (stopReason: StopReason) => ({ - outcome: { - stopReason, - usage: { ...tally, totalTokens: tallyTotal(tally) }, - _meta: quotaMeta(tally, modelUsage), - }, - }); - if (cancelled) return ended("cancelled"); - if (result.stop_reason === "refusal") return ended("refusal"); - if (result.stop_reason === "max_tokens") return ended("max_tokens"); - if (result.is_error) { - const text = - result.subtype === "success" ? result.result : result.errors.join(", ") || result.subtype; - return { error: RequestError.internalError(undefined, text) }; - } - return ended( - result.subtype === "success" || result.subtype === "error_during_execution" - ? "end_turn" - : "max_turn_requests", - ); -} - -/** One `token_count` object. `cachedInputTokens` is cache reads, matching - * codex's field; Claude also reports cache writes, which codex has no slot - * for, so those ride along in an extra sibling (the same name the ACP `usage` - * field already uses) and are counted in `totalTokens`. `reasoningOutputTokens` - * is always 0: Claude bills thinking inside its output tokens and never breaks - * it out — the key is kept so the shape stays uniform across agents. */ -function quotaTokenCount(usage: AccumulatedUsage) { - return { - totalTokens: tallyTotal(usage), - inputTokens: usage.inputTokens, - cachedInputTokens: usage.cachedReadTokens, - cachedWriteTokens: usage.cachedWriteTokens, - outputTokens: usage.outputTokens, - reasoningOutputTokens: 0, - }; -} - -/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes - * the cache counters, so this is not double-counting (see `totalTokens`). */ -function tallyTotal(usage: AccumulatedUsage): number { - return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens; -} - -/** Project `result.modelUsage` into our tally shape. Counters are coerced the - * way `snapshotFromUsage` coerces the stream's: third-party backends have been - * observed omitting fields, and a missing or NaN counter must not reach the - * wire as NaN (which `JSON.stringify` writes as `null`). */ -function normalizeModelUsage(modelUsage: Record | undefined): ModelTokenTally { - const tally: ModelTokenTally = {}; - for (const [model, usage] of Object.entries(modelUsage ?? {})) { - tally[model] = { - inputTokens: finiteCount(usage?.inputTokens), - outputTokens: finiteCount(usage?.outputTokens), - cachedReadTokens: finiteCount(usage?.cacheReadInputTokens), - cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens), - }; - } - return tally; -} - -function finiteCount(value: number | null | undefined): number { - return typeof value === "number" && Number.isFinite(value) ? value : 0; -} - -/** `current - previous` per model, dropping models with nothing to report so a - * turn only lists the models it actually ran on. A reading that fell BELOW the - * previous one means the running total restarted under us (a mid-session - * /clear, a zeroed crash result): there is no usable reference left to - * subtract, so the reading itself is the increment. */ -function modelUsageIncrement(current: ModelTokenTally, previous: ModelTokenTally): ModelTokenTally { - const increment: ModelTokenTally = {}; - for (const [model, usage] of Object.entries(current)) { - const base = previous[model]; - const subtracted: AccumulatedUsage = base - ? { - inputTokens: usage.inputTokens - base.inputTokens, - outputTokens: usage.outputTokens - base.outputTokens, - cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens, - cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens, - } - : usage; - const rewound = Object.values(subtracted).some((count) => count < 0); - const resolved = rewound ? usage : subtracted; - if (tallyTotal(resolved) > 0) { - increment[model] = resolved; - } - } - return increment; -} - -/** The per-model rows for the first result of a resumed session. Its - * `modelUsage` reading continues from the totals the transcript saved (SDK - * 0.3.277+; older transcripts may hold none), so there is no earlier reading - * to subtract from, and taking the reading itself would charge the whole - * pre-resume history to this one turn. The result's own `usage` is per-turn - * (main agent loop only, like the response's `usage`), so it stands in under - * the turn's top-level model — `key` is that model's `modelUsage` spelling - * when the reading has one, else the assistant message's own. Any subagent - * spend on this one turn goes unlisted rather than over-listed. */ -function resumedFirstResultModelUsage( - usage: { - input_tokens?: number | null; - output_tokens?: number | null; - cache_read_input_tokens?: number | null; - cache_creation_input_tokens?: number | null; - }, - key: string | null, -): ModelTokenTally { - if (key === null) { - return {}; - } - const row: AccumulatedUsage = { - inputTokens: finiteCount(usage.input_tokens), - outputTokens: finiteCount(usage.output_tokens), - cachedReadTokens: finiteCount(usage.cache_read_input_tokens), - cachedWriteTokens: finiteCount(usage.cache_creation_input_tokens), - }; - return tallyTotal(row) > 0 ? { [key]: row } : {}; -} - -/** Fold `increment` into `base` per model — the per-model counterpart of the - * `+=` the turn's flat accumulator uses. */ -function addModelUsage(base: ModelTokenTally, increment: ModelTokenTally): ModelTokenTally { - const merged: ModelTokenTally = { ...base }; - for (const [model, usage] of Object.entries(increment)) { - const existing = merged[model]; - merged[model] = existing - ? { - inputTokens: existing.inputTokens + usage.inputTokens, - outputTokens: existing.outputTokens + usage.outputTokens, - cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens, - cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens, - } - : usage; - } - return merged; -} - -/** Sum all four fields as a proxy for post-turn context occupancy: the current - * turn's output becomes next turn's input. Per the Anthropic API, input_tokens - * excludes cache tokens — cache_read and cache_creation are reported - * separately — so summing all four is not double-counting. */ -function totalTokens(usage: UsageSnapshot): number { - return ( - usage.input_tokens + - usage.output_tokens + - usage.cache_read_input_tokens + - usage.cache_creation_input_tokens - ); -} - /** Error kinds this adapter invents itself, alongside the SDK's categorical * `SDKAssistantMessageError` kinds: `no_result` marks a turn the SDK declared * over without ever emitting its result (issue #825). */ @@ -13442,391 +11547,7 @@ function errorKindData( return errorKind ? { errorKind } : undefined; } -/** Project a nullable API usage object into our non-null snapshot shape. - * Both SDK message_start and assistant message `usage` have `number | null` - * cache fields; we coerce absent values to 0 so `totalTokens` never hits - * NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but - * synthetic or third-party-backend stream events have been observed emitting - * them as null/undefined — coerce those too so a malformed upstream event - * can't leak NaN into the wire `used` field. Delta events have different - * semantics (cumulative + prev fallback) and are handled inline. */ -function snapshotFromUsage(usage: { - input_tokens?: number | null; - output_tokens?: number | null; - cache_read_input_tokens?: number | null; - cache_creation_input_tokens?: number | null; -}): UsageSnapshot { - return { - input_tokens: usage.input_tokens ?? 0, - output_tokens: usage.output_tokens ?? 0, - cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, - cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, - }; -} - -/** - * Adapt a legacy gateway `authenticate` request into the shared - * {@link ProviderConfig} shape. Returns `null` when no gateway request is - * present. `methodId` selects the protocol: `gateway-bedrock` → bedrock, - * otherwise anthropic. - */ -function gatewayRequestToProviderConfig(request?: GatewayAuthRequest): ProviderConfig | null { - // `authenticate` validates the payload before it stores one, so a stored - // request always carries a usable gateway. Re-check the shape here anyway: - // this function decides whether a provider override is active, and the - // `--hide-claude-auth` guard is off while one is. - const gateway = request?._meta?.gateway; - if (!gateway || !isValidBaseUrl(gateway.baseUrl)) { - return null; - } - return { - apiType: request?.methodId === "gateway-bedrock" ? "bedrock" : "anthropic", - baseUrl: gateway.baseUrl, - headers: gateway.headers ?? {}, - }; -} - -/** - * Map a resolved provider config into the Claude Code env vars that redirect API - * traffic and inject headers. Returns an empty object when routing is - * unconfigured. The token/bypass placeholders (`" "`) are required so the CLI - * skips its normal login/credential checks when a gateway is in use. - */ -function createEnvForProvider(config: ProviderConfig | null): Record { - if (!config) { - return {}; - } - const resetRouting = { - ANTHROPIC_BASE_URL: "", - ANTHROPIC_BEDROCK_BASE_URL: "", - ANTHROPIC_VERTEX_BASE_URL: "", - CLAUDE_CODE_USE_BEDROCK: "0", - CLAUDE_CODE_USE_VERTEX: "0", - ANTHROPIC_VERTEX_PROJECT_ID: "", - CLOUD_ML_REGION: "", - AWS_REGION: "", - ANTHROPIC_API_KEY: "", - ANTHROPIC_AUTH_TOKEN: "", - CLAUDE_CODE_OAUTH_TOKEN: "", - }; - const customHeaders = Object.entries(config.headers) - .map(([key, value]) => `${key}: ${value}`) - .join("\n"); - - if (config.apiType === "bedrock") { - return { - ...resetRouting, - CLAUDE_CODE_USE_BEDROCK: "1", - AWS_BEARER_TOKEN_BEDROCK: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local AWS credential checks - ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl, - ANTHROPIC_CUSTOM_HEADERS: customHeaders, - ...(config.bedrockRegionPrefix - ? { ANTHROPIC_BEDROCK_REGION_PREFIX: config.bedrockRegionPrefix } - : {}), - }; - } - - if (config.apiType === "vertex") { - // `config.vertex` is guaranteed present for vertex by `unstable_setProvider` - // validation; fall back to empty strings defensively. - // Leaving ANTHROPIC_VERTEX_BASE_URL empty for the default endpoint lets - // Claude Code use its native Vertex endpoint/model resolution. Supplying - // even the default host marks the session as a custom Vertex deployment and - // can hide otherwise-valid models. - return { - ...resetRouting, - CLAUDE_CODE_USE_VERTEX: "1", - ...(config.baseUrl !== DEFAULT_VERTEX_BASE_URL - ? { ANTHROPIC_VERTEX_BASE_URL: config.baseUrl } - : {}), - ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "", - CLOUD_ML_REGION: config.vertex?.region ?? "", - ANTHROPIC_CUSTOM_HEADERS: customHeaders, - }; - } - - return { - ...resetRouting, - ANTHROPIC_BASE_URL: config.baseUrl, - ANTHROPIC_CUSTOM_HEADERS: customHeaders, - ANTHROPIC_AUTH_TOKEN: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local Claude login checks - }; -} - -function sameHeaders(a: Record, b: Record): boolean { - const aKeys = Object.keys(a); - return aKeys.length === Object.keys(b).length && aKeys.every((key) => b[key] === a[key]); -} - -/** - * Validate a provider base URL: must be a non-empty absolute http(s) URL. - */ -function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string { - if (typeof baseUrl !== "string" || baseUrl.trim() === "") { - return false; - } - let parsed: URL; - try { - parsed = new URL(baseUrl); - } catch { - return false; - } - return parsed.protocol === "http:" || parsed.protocol === "https:"; -} - -// `supportedAgents()` always returns Claude Code's built-in subagents — the -// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user -// has configured none of their own. Those aren't meaningful *main-thread* -// personas, so we filter them out and only surface the Agent picker when the -// user (or a plugin/project) has configured custom agents. Update this set if -// the SDK's built-in roster changes. -export const BUILTIN_AGENT_NAMES = new Set([ - "claude", - "general-purpose", - "Explore", - "Plan", - "statusline-setup", -]); - -// Value of the synthetic "Default" entry in the agent picker, which maps to the -// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a -// 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. -/** 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 - * creation. */ -export async function discoverCustomAgents(q: Query): Promise { - try { - const agents = await q.supportedAgents(); - return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID); - } catch { - return []; - } -} - -/** Stable ids for the session config options surfaced via `configOptions`. - * Centralized so the option declarations in `buildConfigOptions` and the - * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the - * same identifiers and can't drift apart. */ -export { MODE_CONFIG_ID }; -export const AGENT_CONFIG_ID = "agent"; -export const FAST_MODE_CONFIG_ID = "fast"; - -/** Select-fallback values used when the client has not opted into boolean - * config options (see {@link createFastModeConfigOption}). */ -export const FAST_MODE_ON = "on"; -export const FAST_MODE_OFF = "off"; -const FAST_MODE_DESCRIPTION = "Faster responses on supported models"; - -/** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle. - * `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK - * docs) keeps the toggle on so it reflects the user's intent — only an - * explicit `off` clears it. */ -export function fastModeStateEnabled(state: FastModeState): boolean { - return state !== "off"; -} - -/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values - * that a user can act on (or at least wants to know about). Deliberately - * partial — the omitted reasons are not worth surfacing: - * - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the - * opt-in), so it describes the default, not a problem. - * - `preference`: the user turned Fast mode off themselves. - * - `pending`: eligibility is still resolving; the next report supersedes it. - * - `unknown`: nothing meaningful to say. - * Unknown future reasons fall through the same way (open set — the SDK's docs - * say to ignore values you don't handle). */ -const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial> = { - free: "not available on the free plan", - extra_usage_disabled: "requires extra usage to be enabled for this account", - model_not_allowed: "not available for the selected model", - not_first_party: "not available on this API provider", - disabled_by_env: "disabled by environment configuration", - network_error: "eligibility could not be verified (network error)", -}; - -/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain: - * a reason we have an explanation for, else `undefined`. Keeping only - * explainable reasons means state comparisons (see `syncFastModeState`) track - * exactly what the user can see, so routine reports like - * `sdk_opt_in_required` never churn the config option. */ -export function normalizeFastModeDisabledReason( - reason: FastModeDisabledReason | undefined, -): FastModeDisabledReason | undefined { - return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined; -} - -/** Whether the Client advertised support for boolean session config options - * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"` - * config options to Clients that opt in; otherwise we fall back to a `select`. - * See https://agentclientprotocol.com/rfds/boolean-config-option. */ -export function clientSupportsBooleanConfigOptions( - clientCapabilities?: ClientCapabilities | null, -): boolean { - return clientCapabilities?.session?.configOptions?.boolean != null; -} - -/** Whether the Client advertised the AIR `recommendedValue` capability for - * select-style model and effort options. Missing or malformed AIR metadata - * retains the legacy `default` entries. */ -export function clientSupportsRecommendedConfigValue( - clientCapabilities?: ClientCapabilities | null, -): boolean { - return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY); -} - -/** Build the Fast mode config option. When the Client supports boolean config - * options we expose a native `type: "boolean"` toggle; otherwise we degrade to - * a two-value `select` ("on"/"off") so older Clients still get a usable - * control. - * - * `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the - * description while the toggle reads off, so a user whose account or provider - * can't serve Fast mode sees why instead of a switch that silently refuses to - * stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown` - * state isn't blocking anything right now. */ -export function createFastModeConfigOption( - enabled: boolean, - useBooleanOption: boolean, - disabledReason?: FastModeDisabledReason, -): SessionConfigOption { - const explanation = enabled - ? undefined - : disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason]; - const base = { - id: FAST_MODE_CONFIG_ID, - name: "Fast mode", - description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION, - category: "model_config", - } as const; - - if (useBooleanOption) { - return { ...base, type: "boolean", currentValue: enabled }; - } - - return { - ...base, - type: "select", - currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF, - options: [ - { value: FAST_MODE_ON, name: "On" }, - { value: FAST_MODE_OFF, name: "Off" }, - ], - }; -} - -/** Resolve the requested Fast mode value from a `session/set_config_option` - * request. Accepts a native boolean (boolean-capable Clients) or the - * "on"/"off" select-fallback strings. */ -export function resolveFastModeEnabled(params: SetSessionConfigOptionRequest): boolean { - const value = params.value; - if (typeof value === "boolean") { - return value; - } - if (value === FAST_MODE_ON) { - return true; - } - if (value === FAST_MODE_OFF) { - return false; - } - throw RequestError.invalidParams( - undefined, - `Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`, - ); -} - -/** Per-model Fast mode state threaded into {@link buildConfigOptions}. The - * option is only surfaced when the current model `supported`s fast mode. */ -export type FastModeOptionState = { - supported: boolean; - enabled: boolean; - /** Whether the Client opted into boolean config options. */ - useBooleanOption: boolean; - /** Latest explainable `fast_mode_disabled_reason`, folded into the option's - * description while the toggle reads off. */ - disabledReason?: FastModeDisabledReason; -}; - -export type ConfigOptionPresentation = { - /** Replace ambiguous `default` rows with concrete values and advertise the - * SDK/adapter recommendation as `_meta.jetbrains.air.recommendedValue`. */ - useRecommendedValue: boolean; -}; - -export function buildConfigOptions( - modes: SessionModeState, - models: SessionModelState, - modelInfos: ModelInfo[], - currentEffortLevel?: string, - agents: AgentInfo[] = [], - currentAgent: string = DEFAULT_AGENT_ID, - fastMode?: FastModeOptionState, - presentation?: ConfigOptionPresentation, - ultracode?: UltracodeOptionState, -): SessionConfigOption[] { - const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)]; - // A gateway that lists none of the SDK's models leaves no model to offer, so - // there is no model option at all. - if (models.availableModels.length > 0) { - options.push( - buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true), - ); - } - const effort = buildEffortConfigOption( - modelInfos, - models.currentModelId, - currentEffortLevel, - presentation?.useRecommendedValue === true, - ); - if (effort) options.push(effort); - - // Surface the Fast mode toggle only when the current model supports it. The - // option renders as a native boolean toggle for Clients that opted in, and a - // two-value select otherwise. - if (fastMode?.supported) { - options.push( - createFastModeConfigOption( - fastMode.enabled, - fastMode.useBooleanOption, - fastMode.disabledReason, - ), - ); - } - - // Surface the ultracode toggle only while the CLI can run it: dynamic - // workflows are enabled and the current model supports it. - if (ultracode?.supported) { - options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption)); - } - - // Only surface the Agent picker when there's a real choice — i.e. the user - // has configured at least one custom agent (built-ins are filtered out in - // discoverCustomAgents). With none configured, "Default" would be the only - // entry, so we omit the option entirely. - if (agents.length > 0) { - options.push({ - id: AGENT_CONFIG_ID, - name: "Agent", - description: "Main-thread agent persona", - type: "select", - currentValue: currentAgent, - options: [ - { value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" }, - ...agents.map((a) => ({ - value: a.name, - name: a.name, - description: a.description || undefined, - })), - ], - }); - } - - return options; -} - -function getAvailableSlashCommands( +export function getAvailableSlashCommands( commands: SlashCommand[], // Names the CLI tagged terminal-bound on `system`/init (their UX lives in // the CLI's own terminal, which ACP clients aren't) — filtered alongside @@ -14970,215 +12691,6 @@ export function runAcp(logger?: Logger) { return { connection, agent }; } -function commonPrefixLength(a: string, b: string) { - let i = 0; - while (i < a.length && i < b.length && a[i] === b[i]) { - i++; - } - return i; -} - -/** Best-effort first guess of a model's context window, used to seed the - * window synchronously (via `immediateContextWindow`) until a `result` message - * arrives with the authoritative `modelUsage` value. - * - * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK - * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also - * matching things like "10m" or embedded substrings. Semantic aliases like - * `default` carry no such token in the ID, but their `resolvedModel` and the - * SDK's human-facing `displayName`/`description` can (e.g. - * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too. - * This text scan can't catch every model — some resolve to extended-context - * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively - * ~1M). Such a miss falls back to the default window, is refined by the - * background `getContextUsage` (`refreshContextWindowInBackground`), and is - * corrected by `result.modelUsage` (and cached) within one turn. */ -function inferContextWindowFromModel(...texts: Array): number | null { - if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000; - return null; -} - -/** Cross-session cache of authoritative context windows, keyed by - * `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}). - * The window is a property of (model id, backend): the same resolved model id - * (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys) - * can name different context lanes behind different base URLs, routing - * headers, or credentials, so the key carries both. Caching it module-level - * lets a later session/new or switch that resolves to the same (backend, - * model) — in this session or any other, within the adapter's lifetime — seed - * the correct window synchronously with no IPC. Keying on the resolved id - * (rather than the picker value) means aliases that resolve to the same - * concrete model share one entry; the result handler additionally writes the - * bare assistant-message spelling so seed-time reads that fall back to a - * verbatim live id (rows without `resolvedModel`) can hit too. - * - * Populated authoritatively by each `result.modelUsage` a turn confirms (see - * the consumer's result handler). We deliberately never populate it from - * `getContextUsage`: its `model` spelling and `rawMaxTokens` can differ from - * the `result.modelUsage` key and window (e.g. 967000 vs 1000000 for sonnet), - * so it only refines the live session (`refreshContextWindowInBackground`). - * Cleared on `logout`: 1M-context entitlement can differ per account/tier, so - * windows learned under one login must not seed sessions under the next. */ -const contextWindowCache = new Map(); - -/** The env vars that determine which LLM backend — and which context lane on - * it — a query's API traffic reaches: endpoint selection (base URLs and the - * Bedrock/Vertex switches with their project/region), routing/beta headers - * (an `anthropic-beta: context-1m-…` header flips the same model id at the - * same endpoint between context lanes), and credential identity (extended - * context is entitlement-gated per account). Used to derive the - * provider-cache key from the exact env a query is created with, so - * `providers/set` config, per-session `_meta` env overrides, and ambient - * process env are all distinguished exactly as the CLI will see them. */ -const PROVIDER_ROUTING_ENV_VARS = [ - "ANTHROPIC_BASE_URL", - "ANTHROPIC_BEDROCK_BASE_URL", - "ANTHROPIC_VERTEX_BASE_URL", - "CLAUDE_CODE_USE_BEDROCK", - "CLAUDE_CODE_USE_VERTEX", - "ANTHROPIC_VERTEX_PROJECT_ID", - "CLOUD_ML_REGION", - "AWS_REGION", - "ANTHROPIC_CUSTOM_HEADERS", - "ANTHROPIC_API_KEY", - "ANTHROPIC_AUTH_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN", -] as const; - -/** Stable identifier for the LLM backend a session's query is created against, - * used to scope {@link contextWindowCache} per backend. Positional `\0`-join - * of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade - * as another and unset vars everywhere yield one stable "default" bucket. - * Header/credential values can be secrets; the key only ever lives as an - * in-memory Map key and is never logged or surfaced. Over-keying is the safe - * side: a var change that didn't really change the backend costs one cache - * miss (heuristic seed until the next result), while under-keying would serve - * one backend's window for another's. */ -function providerCacheKeyFor(env: Record): string { - return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0"); -} - -/** Compose the `contextWindowCache` key from a session's provider key and a - * model id. `\0`-joined so the model segment can't collide with a provider - * segment. */ -function contextWindowCacheKey(providerCacheKey: string, modelId: string): string { - return `${providerCacheKey}\0${modelId}`; -} - -function cacheContextWindow(modelKey: string, window: number): void { - if (window > 0) { - contextWindowCache.set(modelKey, window); - } -} - -/** The context window to report *right now* for a model, with NO IPC on the - * critical path: the cached authoritative value if we've learned it (from a - * prior turn's `result.modelUsage`, this or any session on the same backend), - * else the text heuristic over the model row's identity strings, else the - * default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`, - * the same rule at every seed site — so read keys can't drift from the write - * site's spelling. `authoritative` reports whether the value came from the - * cache: an authoritative window can legitimately equal - * DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */ -function immediateContextWindow( - providerCacheKey: string, - modelId: string, - modelInfo?: Pick, -): { size: number; authoritative: boolean } { - const cached = contextWindowCache.get( - contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId), - ); - if (cached !== undefined) return { size: cached, authoritative: true }; - return { - size: - inferContextWindowFromModel( - modelId, - modelInfo?.resolvedModel, - modelInfo?.displayName, - modelInfo?.description, - ) ?? DEFAULT_CONTEXT_WINDOW, - authoritative: false, - }; -} - -/** Translate the legacy `MAX_THINKING_TOKENS` env var, plus the optional - * `CLAUDE_CODE_THINKING_DISPLAY` env var, into the SDK's `thinking` option. - * The `maxThinkingTokens` option it used to feed is deprecated and reduced - * to on/off on current models, so map the value to explicit thinking config - * instead: unset → `undefined` (SDK default, adaptive on models that support - * it) unless a display mode is requested, in which case it becomes explicit - * `adaptive`; `0` → disabled; a positive integer → a fixed token budget. - * Anything else is ignored with a warning. `CLAUDE_CODE_THINKING_DISPLAY` - * must be `summarized` or `omitted`; it has no effect when thinking is - * disabled. */ -function resolveThinkingConfig( - rawTokens: string | undefined, - rawDisplay: string | undefined, - logger: Logger, -): ThinkingConfig | undefined { - let display: "summarized" | "omitted" | undefined; - if (rawDisplay !== undefined) { - if (rawDisplay === "summarized" || rawDisplay === "omitted") { - display = rawDisplay; - } else { - logger.error( - `Ignoring CLAUDE_CODE_THINKING_DISPLAY: expected 'summarized' or 'omitted', got '${rawDisplay}'.`, - ); - } - } - - if (rawTokens === undefined) { - return display ? { type: "adaptive", display } : undefined; - } - const parsed = Number.parseInt(rawTokens, 10); - if (Number.isNaN(parsed) || parsed < 0) { - logger.error( - `Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${rawTokens}'.`, - ); - return display ? { type: "adaptive", display } : undefined; - } - return parsed === 0 - ? { type: "disabled" } - : { type: "enabled", budgetTokens: parsed, ...(display && { display }) }; -} - -function parseModelConfig( - raw: string | undefined, -): { modelOverrides?: Record; availableModels?: string[] } | undefined { - if (!raw) return undefined; - const parsed = JSON.parse(raw); - if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { - throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object"); - } - const result: { modelOverrides?: Record; availableModels?: string[] } = {}; - if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides; - if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels; - return Object.keys(result).length > 0 ? result : undefined; -} - -function getMatchingModelUsage(modelUsage: Record, currentModel: string) { - let bestKey: string | null = null; - let bestLen = 0; - - for (const key of Object.keys(modelUsage)) { - const len = commonPrefixLength(key, currentModel); - if (len > bestLen) { - bestLen = len; - bestKey = key; - } - } - - if (bestKey) { - // `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"), - // the same spelling as ModelInfo.resolvedModel — the primary key the - // window is cached under. `currentModel` (the assistant message's - // `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result - // handler caches under that spelling too, for seed-time reads that fall - // back to a bare id (rows without `resolvedModel`). - return { key: bestKey, usage: modelUsage[bestKey] }; - } -} - /** * What a `command_lifecycle` "started" frame says started the command: a * ScheduleWakeup (dynamic `/loop`) wakeup, with the time it was set for in diff --git a/src/acp-subagents.ts b/src/acp-subagents.ts index 4beb75f5..b4a414b4 100644 --- a/src/acp-subagents.ts +++ b/src/acp-subagents.ts @@ -17,8 +17,7 @@ export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js"; * - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992, * as revised): `subagent_update` and `session_message`, which the SDK types. * A client that declares `clientCapabilities.subagents` and is not AIR gets - * it. (An ACP v2 client gets it without declaring anything, since subagents - * are part of v2's baseline.) + * it. * - `air`: the earlier draft of that RFD, which AIR implements: * `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it, * whichever capability it declares (`docs/air-extensions.md`). diff --git a/src/auth-methods.ts b/src/auth-methods.ts new file mode 100644 index 00000000..8ff81756 --- /dev/null +++ b/src/auth-methods.ts @@ -0,0 +1,88 @@ +import type { AuthMethod } from "@agentclientprotocol/sdk/unstable"; +import { agentCommand } from "./agent-command.js"; +import { shouldHideClaudeAuth } from "./hide-claude-auth.js"; + +/** + * The terminal login methods the agent advertises, in v1 form. + * + * - `terminal`: the client runs `args` against the agent's own command + * (`auth.terminal` in v1, an `auth.terminal` object in v2). + * - `metaTerminal`: the client reads `_meta["terminal-auth"]`, which names + * the command too. + * + * Without either, the client cannot run a login, and the list is empty. On a + * remote machine (SSH, `NO_BROWSER`, `CLAUDE_CODE_REMOTE`, as the SDK's own + * isRemote check) the OAuth redirect to localhost cannot reach a browser, and + * `claude auth login` falls back to a manual flow that does not work over ACP, + * so the one method runs the TUI's `claude /login` instead. The Claude + * subscription method is left out when `shouldHideClaudeAuth()` says so. + */ +export function terminalAuthMethods({ + terminal, + metaTerminal, +}: { + terminal: boolean; + metaTerminal: boolean; +}): AuthMethod[] { + if (!terminal && !metaTerminal) return []; + const withMeta = (method: AuthMethod & { args: string[] }, label: string): AuthMethod => { + if (!metaTerminal) return method; + const agent = agentCommand(); + return { + ...method, + _meta: { + "terminal-auth": { command: agent.command, args: [...agent.args, ...method.args], label }, + }, + }; + }; + const isRemote = !!( + process.env.NO_BROWSER || + process.env.SSH_CONNECTION || + process.env.SSH_CLIENT || + process.env.SSH_TTY || + process.env.CLAUDE_CODE_REMOTE + ); + if (isRemote) { + if (shouldHideClaudeAuth()) return []; + return [ + withMeta( + { + description: "Run `claude /login` in the terminal", + name: "Log in with Claude", + id: "claude-login", + type: "terminal", + args: ["--cli"], + }, + "Claude Login", + ), + ]; + } + const methods: AuthMethod[] = []; + if (!shouldHideClaudeAuth()) { + methods.push( + withMeta( + { + description: "Use Claude subscription ", + name: "Claude Subscription", + id: "claude-ai-login", + type: "terminal", + args: ["--cli", "auth", "login", "--claudeai"], + }, + "Claude Login", + ), + ); + } + methods.push( + withMeta( + { + description: "Use Anthropic Console (API usage billing)", + name: "Anthropic Console", + id: "console-login", + type: "terminal", + args: ["--cli", "auth", "login", "--console"], + }, + "Anthropic Console Login", + ), + ); + return methods; +} diff --git a/src/auth-status-reporter.ts b/src/auth-status-reporter.ts new file mode 100644 index 00000000..b5feac7d --- /dev/null +++ b/src/auth-status-reporter.ts @@ -0,0 +1,309 @@ +/** + * The connection's `authStatus` state, shared by the v1 core and the v2 + * core: the last identity it reported, the `claude auth status --json` probe, + * and the `_auth/status_update` pushes (see `auth-status.ts` for when each + * core reads the identity). + */ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { RequestError } from "@agentclientprotocol/sdk/unstable"; +import { + type AccountInfo, + filterEscalatingDefaultMode, + type Options, + resolveSettings, + type Settings, +} from "@anthropic-ai/claude-agent-sdk"; +import { + AUTH_STATUS_PROBE_TIMEOUT_MS, + type AuthStatus, + type AuthStatusKind, + fromAccountInfo, + fromCliStatus, + mergeAuthStatus, + sameAuthStatus, +} from "./auth-status.js"; +import { claudeCliPath } from "./claude-cli.js"; +import type { SettingsManager } from "./settings.js"; + +const execFileAsync = promisify(execFile); + +type ReporterLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void }; + +/** The settings a probe runs under: a session's cwd, its key helper, and its launch env. */ +export type AuthStatusSettings = Pick; + +export interface AuthStatusReporterOptions { + /** Sends `_auth/status_update`. */ + push: (status: AuthStatus) => Promise; + logger: ReporterLogger; + /** An ACP gateway login is active: the gateway, not the CLI store, is the identity. */ + gatewayLogin: () => boolean; + /** A `providers/set` override is active: a session account describes its route. */ + providerOverride: () => boolean; + /** A completed probe read `kind`. */ + onProbe?: (kind: AuthStatusKind) => void; +} + +export class AuthStatusReporter { + /** Last identity reported to the client. Undefined means "not determined yet". */ + current?: AuthStatus; + /** + * Counts auth-affecting events (a login, a logout, a new settings context). + * A probe records it at start and publishes only if it has not moved, so a + * slow read can never overwrite a newer login or logout. + */ + epoch = 0; + private settings?: { + manager: AuthStatusSettings; + override?: Settings; + env?: Options["env"]; + }; + private helper?: string; + /** In-flight `claude auth status --json` probe, shared by every caller so + * a concurrent `initialize` and start-of-prompt read never spawn two CLI + * processes. */ + private probing: Promise | null = null; + /** Keeps the "session account told us nothing" note to one line per process + * instead of one per session. */ + private loggedUninformativeAccount = false; + /** Same, for the "client provider override active" note. */ + private loggedOverriddenAccount = false; + /** Same, for the "the CLI probe timed out" warning: a wedged CLI stays wedged + * and would otherwise warn once per probe, i.e. once per user prompt. */ + private loggedProbeTimeout = false; + + constructor(private readonly options: AuthStatusReporterOptions) {} + + /** + * Stores `next` and pushes it to the client. + * + * A push goes out only when the payload changed. The identity is read on many + * occasions — each session create, each guarded turn, the start of each user + * prompt — and almost all of them see the same login. Clients replace their + * whole state on each update and tolerate duplicates, so a repeat is + * harmless, but it is also pure noise; {@link sameAuthStatus} drops it. + * + * What the agent owes is truth — a stale or uninformative source must not + * reach here at all (see the epoch check in the probe and the guards at + * session create). + */ + set(next: AuthStatus | undefined): void { + if (!next) { + return; + } + if (sameAuthStatus(this.current, next)) { + return; + } + this.current = next; + // ACP notifications get no reply, and clients that do not know the method + // drop it silently — send unconditionally and never fail a caller on it. + void Promise.resolve() + .then(() => this.options.push(next)) + .catch((error) => { + this.options.logger.error("Failed to send _auth/status_update:", error); + }); + } + + /** A login or a logout: any probe already running read the world before it. */ + changed(): void { + this.epoch += 1; + } + + /** + * Probes run under these settings from now on. A new context (cwd, key + * helper, or launch env) starts a fresh probe in it. + */ + useSettings( + manager: AuthStatusSettings, + settingsOverride?: Settings, + launchEnv?: Options["env"], + ) { + const helper = manager.getApiKeyHelper(settingsOverride); + const changed = + manager.getCwd() !== (this.settings?.manager.getCwd() ?? process.cwd()) || + helper !== this.helper || + JSON.stringify(launchEnv) !== JSON.stringify(this.settings?.env); + this.settings = { manager, override: settingsOverride, env: launchEnv }; + this.helper = helper; + if (changed) { + // The epoch bump discards any probe that read the old context, and the + // session account may carry no identity signal (an apiKeyHelper), so + // only a probe in the new context can classify it. + this.epoch += 1; + void this.probe({ fresh: true }); + } + } + + /** + * Report the identity behind a session's `AccountInfo`. + * + * `AccountInfo` is richer than the CLI probe (it is what the live query + * actually authenticates with). The v1 core calls it before the + * `--hide-claude-auth` guard may refuse the session or the turn: that flag + * blocks the *use* of a subscription, it does not make the state secret, and + * a refusal is when the client most needs to know the account it was + * refused for. + * + * Two cases skip it: + * + * - ACP gateway *authentication*: the gateway, not this account, is the + * agent-owned identity — already reported as `kind: "gateway"` by the login. + * - A client-driven provider override (`providers/set`): the session then + * routes through the client's endpoint and `AccountInfo` describes that + * route (e.g. `apiProvider: "gateway"`), which is NOT agent-owned state. + * `authStatus` reports the agent's own login only, so the CLI probe — + * which reads the credential store the override never touches — stays + * authoritative. + * + * An account with no identity signal (e.g. `{apiProvider: "firstParty"}` + * under an apiKeyHelper) means "nothing to add", not "logged out" — keep + * what the CLI probe already established instead of overwriting it. + */ + publishSessionAccount(account: AccountInfo | undefined, apiKeyHelper?: string): void { + if (this.options.providerOverride()) { + if (!this.loggedOverriddenAccount) { + this.loggedOverriddenAccount = true; + this.options.logger.log( + "[authStatus] client provider override active; keeping agent-owned probe state", + ); + } + return; + } + if (this.options.gatewayLogin()) { + return; + } + const fromSession = fromAccountInfo(account, apiKeyHelper); + if (fromSession) { + this.set(fromSession); + } else if (!this.loggedUninformativeAccount) { + this.loggedUninformativeAccount = true; + this.options.logger.log( + "[authStatus] session account carries no identity signal; keeping probe", + ); + } + } + + /** Shares one in-flight `claude auth status --json` run between callers. The + * promise is released once settled so a later call re-probes instead of + * replaying a stale verdict. `fresh` forces a new run even when one is in + * flight — `logout` needs a read that started after the credentials were + * cleared. */ + probe(options?: { fresh?: boolean }): Promise { + if (!options?.fresh && this.probing) { + return this.probing; + } + const probe = this.runProbe(); + this.probing = probe; + void probe.finally(() => { + if (this.probing === probe) { + this.probing = null; + } + }); + return probe; + } + + /** Never rejects: an unavailable CLI means "not reported", not an error. */ + private async runProbe(): Promise { + const { logger } = this.options; + // Monotonicity: a read that started before the connection's latest + // auth-affecting event describes a world that no longer exists. Remember + // which one this read belongs to and drop the answer if it moved on. + const epoch = this.epoch; + const context = this.settings; + const apiKeyHelper = context?.manager.getApiKeyHelper(context.override); + // ACP gateway auth bypasses the CLI credential store entirely, so the + // probe would report an identity that is not the one being used. A mere + // client provider override (`providers/set`) does NOT skip the probe: it + // reroutes traffic without touching the credential store, and the store is + // exactly the agent-owned login `authStatus` reports. + if (this.options.gatewayLogin()) { + return this.current; + } + let stdout: string; + try { + const cliPath = await claudeCliPath(); + const args = ["auth", "status", "--json"]; + // `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects + // it with "unknown option '--settings'", so it has to come first. + if (context?.override) args.unshift("--settings", JSON.stringify(context.override)); + ({ stdout } = await execFileAsync(cliPath, args, { + timeout: AUTH_STATUS_PROBE_TIMEOUT_MS, + cwd: context?.manager.getCwd() ?? process.cwd(), + env: context?.env ? { ...process.env, ...context.env } : process.env, + })); + } catch (error) { + const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null; + // Node killed the child on the timeout. Whatever it printed so far is a + // truncated fragment, never valid JSON: report nothing and keep the last + // known state, so nothing regresses and no push goes out. + if (failed?.killed === true && failed.signal) { + if (!this.loggedProbeTimeout) { + this.loggedProbeTimeout = true; + logger.error( + "claude auth status did not answer within 5 s; the identity is not refreshed", + ); + } + return this.current; + } + // The logged-out case exits 1 while still printing valid JSON, so the + // stdout of a failed exec is parsed just like a successful one. + if (typeof failed?.stdout !== "string" || failed.stdout.trim().length === 0) { + logger.error( + "claude auth status failed:", + error instanceof Error ? error.message : String(error), + ); + return undefined; + } + stdout = failed.stdout; + } + let status = fromCliStatus(stdout, apiKeyHelper); + if (!context && status?.kind === "api_key" && status.detail === "API key helper") { + try { + const resolved = await resolveSettings({ cwd: process.cwd() }); + status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper); + } catch { + logger.error("Could not resolve auth source settings"); + } + } + if (!status) { + logger.error("claude auth status returned unparseable output"); + return undefined; + } + if (epoch !== this.epoch) { + // A login or logout landed while this probe was running; the newer + // state wins and this answer is discarded, never published. + logger.log("[authStatus] discarding a probe that predates the latest auth change"); + return this.current; + } + // The CLI probe can be poorer than the session `AccountInfo` for the very + // same login (no organization, say). Keep those extra fields rather than + // regressing the payload; a different identity replaces it wholesale. + const merged = mergeAuthStatus(this.current, status); + this.set(merged); + this.options.onProbe?.(status.kind); + return merged; + } +} + +/** + * Runs `claude auth logout`. The Claude and Console logins keep their + * credentials in the CLI's store (keychain or config dir), which only the + * binary can clear. The command is non-interactive and idempotent. Throws an + * `internal_error` with the CLI's stderr when it fails. + */ +export async function cliLogout(): Promise { + try { + const cliPath = await claudeCliPath(); + await execFileAsync(cliPath, ["auth", "logout"]); + } catch (error) { + const stderr = + typeof error === "object" && error && "stderr" in error + ? String((error as { stderr: unknown }).stderr).trim() + : undefined; + throw RequestError.internalError( + { stderr: stderr || undefined }, + `claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} diff --git a/src/background-work.ts b/src/background-work.ts index 855cfec2..e7ad353e 100644 --- a/src/background-work.ts +++ b/src/background-work.ts @@ -1,173 +1,25 @@ /** - * The background work of a session: the work that Claude Code keeps running - * independently of the session's foreground work, which ACP v2 lists on every - * `state_update` of the session as `background` (the v2 prompt lifecycle, - * "Background work"). - * - * The lists are built from Claude Code's live background task set - * (`background_tasks_changed`). Claude Code marks `ambient` its own - * housekeeping and every live-update watcher; those are left out, except a - * task that a tool call started, such as a `Monitor` call's. The root session and each - * announced subagent session (the subagents RFD form) has its own list. Each - * task is listed once, in the list of the session that runs it: - * - * - A command that went to the background: its display terminal, from the - * Bash tool result until its task ends (`Session.backgroundTerminals`). - * - A detached subagent: its child session, in its parent's list, while it - * works, and while it is idle with background work of its own. - * - A task that a tool call started, such as a `Monitor`, a `Workflow` or a - * backgrounded MCP call: that tool call, kept `in_progress` (or `paused`) - * from its tool result until its task ends (`Session.backgroundToolCalls`). - * - Any other task, once its `task_started` came and the tool call that - * started it, if any, returned: a tool call that the adapter starts for - * it (see `SessionBackgroundWork.adoptTasks`). For example, a task that a - * hook started, or one whose tool call ended before the task joined the - * level. - * - * Only a session whose interrupt spares background tasks reports its lists - * (`Session.reportsBackground`): `session/cancel` must leave listed work - * running. + * The commands of a session that went to the background, for a client that + * renders terminals from the terminal extension and is not AIR (AIR shows a + * command through its async tasks): the display terminal of each command + * runs from its Bash tool result until its task ends. */ -import { RequestError, type ToolCallUpdate } from "@agentclientprotocol/sdk/unstable"; import type { AcpSessionNotification } from "./acp-subagents.js"; -import type { NativeSubagent } from "./native-subagents.js"; -import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js"; -import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js"; -import type { TaskProgressFields } from "./async-tasks.js"; -import { type WorkflowSnapshot, workflowSnapshotText } from "./workflow-progress.js"; -import { textContent } from "./tool-calls/content.js"; -/** An item of a `background` list. */ -export type BackgroundItem = - | { type: "terminal"; terminalId: string } - | { type: "tool_call"; toolCallId: string } - | { type: "subagent"; sessionId: string }; - -/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */ +/** The display terminal of a command that went to the background. */ export interface BackgroundTerminal { /** The Bash tool call, whose id names the terminal. */ toolCallId: string; /** The subagent tool call that ran the command, if any. */ parentToolUseId?: string; - /** The file that Claude Code writes the command's output to, from the Bash result. */ - outputFilePath?: string; - /** Reads that file into the terminal while the command runs. */ - output?: BackgroundOutputTail; } -/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */ -export interface BackgroundToolCall { - toolCallId: string; - /** - * The tool use of the call, which Claude Code no longer caches after its - * result. Undefined for a call that the adapter started for a task (see - * `SessionBackgroundWork.adoptTasks`), which has `title` instead. - */ - toolUse?: { name: string; input: unknown }; - /** The title of a call that the adapter started for a task: the task's description. */ - title?: string; - /** The subagent tool call that made the call, if any. */ - parentToolUseId?: string; - paused?: boolean; -} - -/** How a background task ended, as Claude Code reports it. Undefined when it did not say. */ -export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined; - -/** The live background task set of `background_tasks_changed`, by task id. */ -export type BackgroundLevel = Map< - string, - { - ambient: boolean; - taskType: string; - description: string; - parentTaskId?: string; - progress?: WorkflowCardProgress; - } ->; - -/** What a workflow card shows: the task's progress fields and its latest phase and agent list. */ -export type WorkflowCardProgress = TaskProgressFields & { workflow?: WorkflowSnapshot }; - -/** The work state of a session, as its `state_update` names it. */ -export type WorkStateName = "idle" | "running" | "requires_action" | "unknown"; - -/** A new list of the session `sessionId`, with what its repeated state needs. */ -export interface BackgroundReport { - sessionId: string; - items: BackgroundItem[]; - /** The session's work state now, or undefined when it has none yet. */ - state: WorkStateName | undefined; - /** The parent of a subagent session, which mirrors the child's state. */ - parentSessionId?: string; -} - -/** Where the agent reports the lists (see the agent's `background` option). */ -export interface BackgroundSink { - /** The list of a session changed, or is reported for the first time. */ - changed(report: BackgroundReport): void; - /** The session ended: its list is no longer reported. */ - forget(sessionId: string): void; -} - -/** - * The state of a session that other modules own, and that the background - * work reads: Claude Code's tasks as the consumer tracks them, the subagent - * sessions, and the query that stops a task. - */ -export interface BackgroundOwner { - liveBackgroundTasks: Map; - nativeSubagentsByTaskId?: Map; - query: { stopTask(taskId: string): Promise }; -} - -/** A task of a `background_tasks_changed` payload. */ -export type LevelTask = { - task_id: string; - task_type: string; - description: string; - parent_task_id?: string; - ambient?: boolean; -}; - -/** The most returned tool calls that {@link SessionBackgroundWork} remembers. */ -const MAX_RETURNED_CALLS = 1024; - -/** The tools whose structured result names the task that the call started. */ -const TASK_TOOLS = new Set(["Monitor", "Workflow"]); - -/** - * The background work of one root session, and of its subagent sessions: the - * terminals and tool calls of the tasks that run in the background, and what - * the client is told of them. - * - * Terminals are kept for every client that renders them, as before the - * background lists. The rest is kept only when `reported`. - */ +/** The terminals of the commands that one root session, and its subagents, run in the background. */ export class SessionBackgroundWork { - /** Claude Code's live background task set, from its last `background_tasks_changed`. */ - private level: BackgroundLevel = new Map(); /** The terminals of the commands that went to the background, by task id. */ private readonly terminals = new Map(); - /** The tool calls whose tasks went on in the background, by task id. */ - private readonly toolCalls = new Map(); - /** - * The tool calls that returned, oldest first. A task that a call started - * is part of that call until it returns, and the foreground work waits on - * it. The task's `task_started` can come after the call returned. Bounded - * by {@link MAX_RETURNED_CALLS}: a task joins the level around the time its - * call returns. - */ - private readonly returned = new Set(); - /** - * `reported`: whether the client gets the lists. Only when Claude Code's - * interrupt spares background tasks, as `perTaskStopAffordance` makes it. - */ - constructor( - private readonly sessionId: string, - readonly reported: boolean, - ) {} + constructor(private readonly sessionId: string) {} /** * Keeps the display terminal of the command `taskId` running after its Bash @@ -178,446 +30,19 @@ export class SessionBackgroundWork { } /** - * Reads what a tool result message tells of the background tasks: - * - * - The returned calls no longer hold the tasks that they started. - * - Each call whose task goes on in the background is registered: a - * `Monitor` or `Workflow` call, whose structured result names its task, - * or a call that a live task names as its tool use, such as a - * backgrounded MCP call. The task must be in the level: one that ended - * already leaves its call as it is. Claude Code marks every live-update - * watcher `ambient`, including the task of a `Monitor` call; a call - * started it, so it is work the client can see and stop, and is - * registered all the same. Ambient tasks without a call of their own, - * such as watchers Claude Code starts by itself, are never registered. - * - * Returns the ids of the registered calls, whose result then reports - * `in_progress` (see `withRunningToolCall`). + * The task `taskId` ended: exits its command's terminal, with no exit code. + * Claude Code reports no exit code for a command. */ - toolResults( - owner: BackgroundOwner, - content: unknown, - toolUseResult: unknown, - toolUses: Record, - parentToolUseId: string | null, - ): Set { - const registered = new Set(); - if (!this.reported || !Array.isArray(content)) return registered; - const results = content.filter( - (block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } => - typeof block === "object" && - block !== null && - block.type === "tool_result" && - typeof block.tool_use_id === "string", - ); - for (const { tool_use_id: toolCallId } of results) { - this.returned.add(toolCallId); - if (this.returned.size > MAX_RETURNED_CALLS) { - this.returned.delete(this.returned.values().next().value!); - } - } - const succeeded = results.filter((block) => block.is_error !== true); - const structuredTaskId = - succeeded.length === 1 && - typeof toolUseResult === "object" && - toolUseResult !== null && - typeof (toolUseResult as { taskId?: unknown }).taskId === "string" - ? (toolUseResult as { taskId: string }).taskId - : undefined; - for (const { tool_use_id: toolCallId } of succeeded) { - const toolUse = toolUses[toolCallId]; - if (!toolUse) continue; - const taskId = - (TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ?? - [...owner.liveBackgroundTasks].find( - ([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent, - )?.[0]; - const task = taskId === undefined ? undefined : this.level.get(taskId); - if (!task || task.taskType === "local_bash" || task.taskType === "local_agent") { - continue; - } - this.toolCalls.set(taskId!, { - toolCallId, - toolUse, - ...(parentToolUseId ? { parentToolUseId } : {}), - }); - registered.add(toolCallId); - } - return registered; - } - - /** - * Starts reading the output of the background command `taskId` into its - * terminal, when the client gets the lists and the Bash result named the - * command's output file. `send` sends an update of the terminal's tool - * call, routed as the call's other updates. Called once the Bash result's - * own updates are sent: the first output replaces the placeholder that they - * gave the terminal. - */ - followOutput(taskId: string, send: Send): void { + async taskEnded(taskId: string, send: Send): Promise { const terminal = this.terminals.get(taskId); - if (!this.reported || !terminal?.outputFilePath || terminal.output) return; - terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) => - send(terminalOutputUpdate(this.sessionId, terminal, output)), - ); - } - - /** - * The task `taskId` ended. Reads the rest of its command's output, then - * exits its terminal, and gives a tool call whose task it was its final - * status: `cancelled` for a task that Claude Code stopped, and `completed` - * for one that ended without saying how, such as one that the level - * dropped: it no longer runs, and reported no failure. Claude Code reports - * no exit code for a command, and `completed` does not mean 0: Claude Code - * accepts some non-zero codes as a success, such as grep's 1. - */ - async taskEnded(taskId: string, end: BackgroundTaskEnd, send: Send): Promise { - const terminal = this.terminals.get(taskId); - if (terminal) { - await terminal.output?.finish(); - this.terminals.delete(taskId); - await send(terminalExit(this.sessionId, terminal)); - } - const toolCall = this.toolCalls.get(taskId); - if (toolCall) { - this.toolCalls.delete(taskId); - await send( - toolCallUpdate(this.sessionId, toolCall, { - status: end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed", - }), - ); - } - } - - /** - * The update that reports the background tool call of the task `taskId` as - * `paused`, or as `in_progress` again, when `paused` changes it. Claude Code - * pauses a workflow this way. - */ - taskPaused(taskId: string, paused: boolean): AcpSessionNotification | undefined { - const toolCall = this.toolCalls.get(taskId); - if (!toolCall || (toolCall.paused ?? false) === paused) return undefined; - toolCall.paused = paused; - return toolCallUpdate(this.sessionId, toolCall, { status: paused ? V2_PAUSED : "in_progress" }); - } - - /** - * Updates an existing workflow card without changing its work state. A - * report without `workflow` keeps the last phase and agent list. - */ - taskProgress(taskId: string, progress: WorkflowCardProgress): AcpSessionNotification | undefined { - const task = this.level.get(taskId); - if (!task || task.taskType !== "local_workflow") return undefined; - const previous = workflowProgress(task.progress); - task.progress = { ...task.progress, ...progress }; - const current = workflowProgress(task.progress); - if (!current || JSON.stringify(previous) === JSON.stringify(current)) return undefined; - const toolCall = this.toolCalls.get(taskId); - return toolCall ? toolCallUpdate(this.sessionId, toolCall, current) : undefined; - } - - /** - * The latest progress of the workflow calls in `toolCallIds`. - * - * A task can report progress before its launch tool returns. Those fields - * wait in the live task level and are sent after the launch result. - */ - toolCallProgress(toolCallIds: ReadonlySet): AcpSessionNotification[] { - return [...this.toolCalls].flatMap(([taskId, toolCall]) => { - if (!toolCallIds.has(toolCall.toolCallId)) return []; - const progress = workflowProgress(this.level.get(taskId)?.progress); - return progress ? [toolCallUpdate(this.sessionId, toolCall, progress)] : []; - }); - } - - /** - * Takes Claude Code's new live background task set. The terminals and tool - * calls whose tasks it no longer holds end first, before a list drops them, - * as v2 requires. A terminal or tool call is registered at its tool result, - * after its task joined the level, so a later level without the task means - * that it ended. Claude Code usually sends a task's end before that level, - * which ends them first, but the SDK does not promise that order. - */ - async levelChanged(tasks: LevelTask[], send: Send): Promise { - if (!this.reported) return; - const level: BackgroundLevel = new Map( - tasks.map((task) => [ - task.task_id, - { - ambient: task.ambient === true, - taskType: task.task_type, - description: task.description, - ...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}), - progress: this.level.get(task.task_id)?.progress, - }, - ]), - ); - for (const taskId of [...this.terminals.keys(), ...this.toolCalls.keys()]) { - if (!level.has(taskId)) await this.taskEnded(taskId, undefined, send); - } - this.level = level; - } - - /** - * The query closed: Claude Code's background tasks end with its process. - * Stops reading output, and returns the updates that exit the root - * session's terminals and cancel its tool calls. The level empties, so the - * next lists are empty. A subagent's command or tool call is left as it - * was: it is on the child's stream, which the closed query's routing owned. - */ - queryClosed(): AcpSessionNotification[] { - for (const terminal of this.terminals.values()) terminal.output?.stop(); - if (!this.reported) return []; - const updates: AcpSessionNotification[] = []; - for (const [taskId, terminal] of this.terminals) { - if (terminal.parentToolUseId) continue; - this.terminals.delete(taskId); - updates.push(terminalExit(this.sessionId, terminal)); - } - for (const [taskId, toolCall] of this.toolCalls) { - if (toolCall.parentToolUseId) continue; - this.toolCalls.delete(taskId); - updates.push(toolCallUpdate(this.sessionId, toolCall, { status: V2_CANCELLED })); - } - this.level = new Map(); - this.returned.clear(); - return updates; - } - - /** - * Starts a tool call for each live task that no terminal or tool call - * stands for yet, and returns the `tool_call`s to send: v2 lists background work only by an entity - * the client knows. Such a task is one that no tool call started, such as - * one a hook started, or one whose tool call returned, with its final - * status, before the task started. The call has the task's description as - * its title, is `in_progress`, and ends with the task (see `taskEnded`). A - * task that an announced subagent session runs gets its call on that - * session's stream. Ambient tasks are housekeeping and get no call. - * - * The agent sends them before each list it publishes, so the call comes - * before the first list that names it, as v2 requires. - */ - adoptTasks(owner: BackgroundOwner): AcpSessionNotification[] { - if (!this.reported) return []; - const started: AcpSessionNotification[] = []; - const children = new Map( - [...(owner.nativeSubagentsByTaskId ?? [])].filter(([, child]) => child.announced), - ); - for (const [taskId, task] of this.level) { - if (task.ambient || this.toolCalls.has(taskId) || this.terminals.has(taskId)) continue; - // A command without a terminal yet is one that foreground work waits on. - // A subagent is listed as its session. - if (task.taskType === "local_bash" || task.taskType === "local_agent") continue; - // A task joins the level before its `task_started`, and before the tool - // call that starts it returns. Until both, the task is unknown or part - // of that tool call. - const live = owner.liveBackgroundTasks.get(taskId); - if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue; - const parent = task.parentTaskId ? children.get(task.parentTaskId) : undefined; - const toolCall: BackgroundToolCall = { - toolCallId: adoptedToolCallId(taskId), - title: task.description, - ...(parent?.parentToolUseId ? { parentToolUseId: parent.parentToolUseId } : {}), - }; - this.toolCalls.set(taskId, toolCall); - started.push(adoptedToolCall(this.sessionId, toolCall)); - } - return started; - } - - /** - * The `background` list of the root session and of each of its announced - * subagent sessions, with what their repeated states need, deeper sessions - * first and the root last: a child's own list goes out before its parent's - * list drops it. Each list is empty while the level has not been reported: - * Claude Code sends none before the first background task starts. - */ - reports(owner: BackgroundOwner, rootState: WorkStateName): BackgroundReport[] { - const children = [...(owner.nativeSubagentsByTaskId ?? [])].filter( - ([, child]) => child.announced, - ); - const childByToolUse = new Map( - children.flatMap(([, child]) => - child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [], - ), - ); - const lists = new Map([ - [this.sessionId, []], - ...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]), - ]); - // A subagent's command or tool call runs on the child's stream. - const ownerOf = (parentToolUseId: string | undefined) => - parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId; - for (const [taskId, task] of this.level) { - // A registered call's task is listed even when ambient, as a `Monitor` - // call's is (see `toolResults`). - const toolCall = this.toolCalls.get(taskId); - if (toolCall) { - const session = ownerOf(toolCall.parentToolUseId); - if (session) - lists.get(session)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId }); - continue; - } - if (task.ambient) continue; - const terminal = this.terminals.get(taskId); - if (terminal) { - const session = ownerOf(terminal.parentToolUseId); - if (session) - lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId }); - } - // Any other task is listed by the tool call that `adoptTasks` starts - // for it, or is not background work. A subagent is listed below, as - // its session. - } - // A child is listed in its parent's list while it works in the background, - // and while it is idle with background work of its own. Deeper children - // first, so a child's list is whole before its parent's reads it. - const sessions = new Map(children.map(([, child]) => [child.sessionId, child])); - const depth = (child: NativeSubagent): number => { - const parent = sessions.get(child.parentSessionId); - return parent ? depth(parent) + 1 : 0; - }; - const ordered = children.sort(([, a], [, b]) => depth(b) - depth(a)); - for (const [taskId, child] of ordered) { - const task = this.level.get(taskId); - const working = - child.workState !== undefined && - child.workState !== "idle" && - task !== undefined && - !task.ambient; - if (working || lists.get(child.sessionId)!.length > 0) { - lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId }); - } - } - return [ - ...ordered.map(([, child]) => ({ - sessionId: child.sessionId, - items: lists.get(child.sessionId)!, - state: workStateName(child.workState), - parentSessionId: child.parentSessionId, - })), - { sessionId: this.sessionId, items: lists.get(this.sessionId)!, state: rootState }, - ]; - } - - /** - * The current state of each background tool call in `toolCallIds`: the - * call as `renderer` shows its tool use, with its status now (see - * `ClaudeAcpAgent.publishBackgroundToolCalls`). - */ - toolCallStates( - toolCallIds: string[], - renderer: AcpToolCallRenderer, - cwd: string, - ): AcpSessionNotification[] { - return [...this.toolCalls] - .filter(([, toolCall]) => toolCallIds.includes(toolCall.toolCallId)) - .map(([taskId, toolCall]) => { - const update = toolCall.toolUse - ? renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd }) - : adoptedToolCall(this.sessionId, toolCall).update; - const status = toolCall.paused ? V2_PAUSED : "in_progress"; - return { - sessionId: this.sessionId, - update: { - ...update, - ...workflowProgress(this.level.get(taskId)?.progress), - status, - } as AcpSessionNotification["update"], - }; - }); - } - - /** - * The task of the terminal or tool call `target` that the session - * `sessionId` lists, or undefined when it lists no such item. - */ - listedTask( - owner: BackgroundOwner, - sessionId: string, - target: BackgroundTarget, - ): string | undefined { - if (!this.reported) return undefined; - const listed = this.reports(owner, "idle") - .find((report) => report.sessionId === sessionId) - ?.items.some((item) => - "terminalId" in target - ? item.type === "terminal" && item.terminalId === target.terminalId - : item.type === "tool_call" && item.toolCallId === target.toolCallId, - ); - if (!listed) return undefined; - const id = "terminalId" in target ? target.terminalId : target.toolCallId; - const entries = "terminalId" in target ? this.terminals : this.toolCalls; - return [...entries].find(([, entry]) => entry.toolCallId === id)?.[0]; + if (!terminal) return; + this.terminals.delete(taskId); + await send(terminalExit(this.sessionId, terminal)); } } type Send = (notification: AcpSessionNotification) => Promise; -/** A terminal or tool call that `terminal/cancel` or `tool_call/cancel` names. */ -export type BackgroundTarget = { terminalId: string } | { toolCallId: string }; - -/** - * Stops the task of a terminal or tool call that the session `sessionId` - * lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the - * root sessions: a subagent session's list is built with its root's. The - * terminal exits, or the tool call gets its final status, when Claude Code - * reports the task stopped. - */ -export async function cancelBackgroundWork( - sessions: Array, - sessionId: string, - target: BackgroundTarget, -): Promise { - const notCancellable = () => - "terminalId" in target - ? RequestError.invalidParams( - { sessionId, terminalId: target.terminalId }, - `Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`, - ) - : RequestError.invalidParams( - { sessionId, toolCallId: target.toolCallId }, - `Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`, - ); - for (const session of sessions) { - const taskId = session.background?.listedTask(session, sessionId, target); - if (taskId === undefined) continue; - try { - await session.query.stopTask(taskId); - } catch (error) { - // The task ended meanwhile: it has ended on the client too, or is about to. - if (session.background?.listedTask(session, sessionId, target) === undefined) { - throw notCancellable(); - } - throw error; - } - return; - } - throw notCancellable(); -} - -/** The update that gives the terminal of `terminal` the text `output`. */ -function terminalOutputUpdate( - sessionId: string, - terminal: BackgroundTerminal, - output: BackgroundOutput, -): AcpSessionNotification { - const data = { terminal_id: terminal.toolCallId, data: output.text }; - return { - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: terminal.toolCallId, - _meta: { - ...(terminal.parentToolUseId - ? { claudeCode: { parentToolUseId: terminal.parentToolUseId } } - : {}), - ...(output.replaces ? { terminal_output: data } : { terminal_output_delta: data }), - }, - }, - }; -} - /** The update that exits the terminal of `terminal`, with no exit code. */ function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessionNotification { return { @@ -634,122 +59,3 @@ function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessi }, }; } - -function toolCallUpdate( - sessionId: string, - toolCall: BackgroundToolCall, - fields: Omit, -): AcpSessionNotification { - return { - sessionId, - update: { - sessionUpdate: "tool_call_update", - toolCallId: toolCall.toolCallId, - ...fields, - ...(toolCall.parentToolUseId - ? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } } - : {}), - }, - }; -} - -/** - * The id of the tool call that the adapter starts for the task `taskId`. - * Claude Code's tool use ids start with `toolu_`, so it names no other call. - */ -function adoptedToolCallId(taskId: string): string { - return `task_${taskId}`; -} - -/** The `tool_call` that the adapter starts for a task (see `SessionBackgroundWork.adoptTasks`). */ -function adoptedToolCall(sessionId: string, toolCall: BackgroundToolCall): AcpSessionNotification { - return { - sessionId, - update: { - sessionUpdate: "tool_call", - toolCallId: toolCall.toolCallId, - title: toolCall.title ?? "Background task", - kind: "other", - status: toolCall.paused ? V2_PAUSED : "in_progress", - ...(toolCall.parentToolUseId - ? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } } - : {}), - } as AcpSessionNotification["update"], - }; -} - -/** The latest workflow activity, rendered as one replaceable tool-card body. */ -function workflowProgress(progress: WorkflowCardProgress | undefined) { - if (!progress || Object.keys(progress).length === 0) return undefined; - const activity = progress.description ?? progress.summary; - const usage = progress.usage; - const text = [ - progress.summary ?? progress.description, - progress.lastToolName ? `Last activity: ${progress.lastToolName}` : undefined, - usage - ? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s` - : undefined, - progress.workflow ? workflowSnapshotText(progress.workflow) : undefined, - ] - .filter((part) => part !== undefined) - .join("\n\n"); - return { - title: activity ? `Workflow: ${activity}` : "Workflow", - content: [textContent(text)], - }; -} - -/** - * Publishes the lists of each session that reports them, each time one - * changes. The sink sends them to the client synchronously, so a list follows - * the updates sent before it. - */ -export class BackgroundWorkReporter { - /** The list last published for each session, as JSON, and the root session it is built with. */ - private readonly published = new Map(); - - constructor(private readonly sink?: BackgroundSink) {} - - /** Whether the lists are reported at all. */ - get enabled(): boolean { - return this.sink !== undefined; - } - - /** - * Publishes each list of the root session `rootSessionId` that changed. The - * first call for a session publishes its list, also when it is empty. - * `rootState` is the root session's state now. - */ - sync( - rootSessionId: string, - session: BackgroundOwner & { background?: SessionBackgroundWork }, - rootState: WorkStateName, - ): void { - if (!this.sink || !session.background?.reported) return; - for (const report of session.background.reports(session, rootState)) { - const json = JSON.stringify(report.items); - if (this.published.get(report.sessionId)?.json === json) continue; - this.published.set(report.sessionId, { json, root: rootSessionId }); - this.sink.changed(report); - } - } - - /** The root session `rootSessionId` ended, and its subagent sessions with it. */ - forget(rootSessionId: string): void { - for (const [sessionId, { root }] of this.published) { - if (root !== rootSessionId) continue; - this.published.delete(sessionId); - this.sink?.forget(sessionId); - } - } -} - -/** A child's work state, when it is one that v2 names. */ -function workStateName(state: string | undefined): WorkStateName | undefined { - return state === "idle" || - state === "running" || - state === "requires_action" || - state === "unknown" - ? state - : undefined; -} diff --git a/src/client-system-prompt.ts b/src/client-system-prompt.ts index 4f53bac6..dee4b2e0 100644 --- a/src/client-system-prompt.ts +++ b/src/client-system-prompt.ts @@ -72,3 +72,30 @@ export function sdkSystemPrompt( ? { type: "preset", preset: "claude_code", append: text } : { type: "preset", preset: "claude_code" }; } + +/** + * The SDK `systemPrompt` of a session: the spec's `systemPrompt` + * (`clientPrompt`), else the `_meta.systemPrompt` extension (`metaPrompt`), a + * string that replaces the preset or an object of preset options, else the + * Claude Code preset. `customInstructions` are appended to the preset. + */ +export function sessionSystemPrompt( + clientPrompt: ClientSystemPrompt | undefined, + metaPrompt: unknown, + customInstructions?: string, +): NonNullable { + if (clientPrompt) return sdkSystemPrompt(clientPrompt, customInstructions); + if (typeof metaPrompt === "string" && metaPrompt) return metaPrompt; + if (typeof metaPrompt === "object" && metaPrompt !== null && !Array.isArray(metaPrompt)) { + // Forward all preset options (append, excludeDynamicSections, and + // anything the SDK adds later) while locking type/preset. + return { + ...(metaPrompt as object), + type: "preset", + preset: "claude_code", + } as NonNullable; + } + return customInstructions === undefined + ? { type: "preset", preset: "claude_code" } + : { type: "preset", preset: "claude_code", append: customInstructions }; +} diff --git a/src/context-compaction.ts b/src/context-compaction.ts index 8a7aa43e..f85e830a 100644 --- a/src/context-compaction.ts +++ b/src/context-compaction.ts @@ -4,6 +4,7 @@ import { ContextCompactionTrigger, createContextCompactionMeta, } from "./context-compaction-meta.js"; +import { assistantMessageText } from "./session-failure-extension.js"; import { compactionToolCall } from "./tool-calls/reporters/compaction.js"; type CompactionStatus = "completed" | "failed"; @@ -132,6 +133,36 @@ export function isCompactSummaryMessage(message: unknown): boolean { ); } +/** + * Replays the compact summary message `message` (see + * {@link isCompactSummaryMessage}) of the root conversation as one completed + * `compaction_update` at its transcript position, keyed by the message's + * uuid. Returns false, and sends nothing, for any other message. + * + * The boundary's own system record comes back from `getSessionMessages` + * without its metadata, so the summary message is the one durable marker. + * Its uuid differs from the live id (the `compacting` status uuid): replay + * is terminal-first by the RFD, and no client merges a replay into a live + * entity store. A summary whose framing is unrecognized is still this + * compaction, without summary text. + */ +export async function replayCompactSummary( + message: { uuid?: unknown; message?: unknown }, + sendUpdate: SendUpdate, + options: { sessionId: string; airClient?: boolean }, +): Promise { + if (!isCompactSummaryMessage(message) || typeof message.uuid !== "string" || !message.uuid) { + return false; + } + const replayed = new ContextCompactionLifecycle(sendUpdate, { + ...options, + presentation: "compaction_update", + }); + replayed.recordSummary(assistantMessageText(message.message)); + await replayed.finish(message.uuid, "completed"); + return true; +} + /** * Translates Claude's compaction signals into one idempotent ACP lifecycle. * diff --git a/src/context-window.ts b/src/context-window.ts new file mode 100644 index 00000000..45a3d60c --- /dev/null +++ b/src/context-window.ts @@ -0,0 +1,166 @@ +/** + * The context window of a session's model, as both the v1 core and the + * v2 core report it in `usage_update.size`: seeded without IPC when the + * session starts or switches models, refined by a background + * `getContextUsage`, and learned from each result's `modelUsage`. + */ +import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk"; +import { DEFAULT_CONTEXT_WINDOW, inferContextWindowFromModel } from "./session-usage.js"; + +/** Cross-session cache of authoritative context windows, keyed by + * `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}). + * The window is a property of (model id, backend): the same resolved model id + * (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys) + * can name different context lanes behind different base URLs, routing + * headers, or credentials, so the key carries both. Caching it module-level + * lets a later session/new or switch that resolves to the same (backend, + * model) — in this session or any other, within the adapter's lifetime — seed + * the correct window synchronously with no IPC. Keying on the resolved id + * (rather than the picker value) means aliases that resolve to the same + * concrete model share one entry; the result handler additionally writes the + * bare assistant-message spelling so seed-time reads that fall back to a + * verbatim live id (rows without `resolvedModel`) can hit too. + * + * Populated authoritatively by each `result.modelUsage` a turn confirms (see + * the consumer's result handler). We deliberately never populate it from + * `getContextUsage`: its `model` spelling and `rawMaxTokens` can differ from + * the `result.modelUsage` key and window (e.g. 967000 vs 1000000 for sonnet), + * so it only refines the live session (`refreshContextWindowInBackground`). + * Cleared on `logout`: 1M-context entitlement can differ per account/tier, so + * windows learned under one login must not seed sessions under the next. */ +const contextWindowCache = new Map(); + +/** The env vars that determine which LLM backend — and which context lane on + * it — a query's API traffic reaches: endpoint selection (base URLs and the + * Bedrock/Vertex switches with their project/region), routing/beta headers + * (an `anthropic-beta: context-1m-…` header flips the same model id at the + * same endpoint between context lanes), and credential identity (extended + * context is entitlement-gated per account). Used to derive the + * provider-cache key from the exact env a query is created with, so + * `providers/set` config, per-session `_meta` env overrides, and ambient + * process env are all distinguished exactly as the CLI will see them. */ +const PROVIDER_ROUTING_ENV_VARS = [ + "ANTHROPIC_BASE_URL", + "ANTHROPIC_BEDROCK_BASE_URL", + "ANTHROPIC_VERTEX_BASE_URL", + "CLAUDE_CODE_USE_BEDROCK", + "CLAUDE_CODE_USE_VERTEX", + "ANTHROPIC_VERTEX_PROJECT_ID", + "CLOUD_ML_REGION", + "AWS_REGION", + "ANTHROPIC_CUSTOM_HEADERS", + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + "CLAUDE_CODE_OAUTH_TOKEN", +] as const; + +/** Stable identifier for the LLM backend a session's query is created against, + * used to scope {@link contextWindowCache} per backend. Positional `\0`-join + * of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade + * as another and unset vars everywhere yield one stable "default" bucket. + * Header/credential values can be secrets; the key only ever lives as an + * in-memory Map key and is never logged or surfaced. Over-keying is the safe + * side: a var change that didn't really change the backend costs one cache + * miss (heuristic seed until the next result), while under-keying would serve + * one backend's window for another's. */ +export function providerCacheKeyFor(env: Record): string { + return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0"); +} + +/** Compose the `contextWindowCache` key from a session's provider key and a + * model id. `\0`-joined so the model segment can't collide with a provider + * segment. */ +export function contextWindowCacheKey(providerCacheKey: string, modelId: string): string { + return `${providerCacheKey}\0${modelId}`; +} + +function cacheContextWindow(modelKey: string, window: number): void { + if (window > 0) { + contextWindowCache.set(modelKey, window); + } +} + +/** Caches the window that a result's `modelUsage` reported, keyed on (the + * session's provider, the resolved model id — the `modelUsage` key, e.g. + * "claude-sonnet-5[1m]") so a later session/new or switch on the same + * provider that resolves to this model seeds the correct window + * synchronously, with no getContextUsage IPC. + * + * Also caches it under the assistant message's own (bare) spelling. Seed-time + * reads fall back to a picker value / verbatim live id when a row carries no + * resolvedModel (the synthesized out-of-allowlist resume row sets it + * undefined on purpose), and those spellings match `.model` from the + * assistant message, not the decorated modelUsage key — without this entry + * such rows could never hit the cache. A non-positive window is not cached. */ +export function cacheResultContextWindow( + providerCacheKey: string, + modelUsageKey: string, + assistantModel: string | null | undefined, + window: number, +): void { + cacheContextWindow(contextWindowCacheKey(providerCacheKey, modelUsageKey), window); + if (assistantModel && assistantModel !== modelUsageKey) { + cacheContextWindow(contextWindowCacheKey(providerCacheKey, assistantModel), window); + } +} + +/** The context window to report *right now* for a model, with NO IPC on the + * critical path: the cached authoritative value if we've learned it (from a + * prior turn's `result.modelUsage`, this or any session on the same backend), + * else the text heuristic over the model row's identity strings, else the + * default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`, + * the same rule at every seed site — so read keys can't drift from the write + * site's spelling. `authoritative` reports whether the value came from the + * cache: an authoritative window can legitimately equal + * DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */ +export function immediateContextWindow( + providerCacheKey: string, + modelId: string, + modelInfo?: Pick, +): { size: number; authoritative: boolean } { + const cached = contextWindowCache.get( + contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId), + ); + if (cached !== undefined) return { size: cached, authoritative: true }; + return { + size: + inferContextWindowFromModel( + modelId, + modelInfo?.resolvedModel, + modelInfo?.displayName, + modelInfo?.description, + ) ?? DEFAULT_CONTEXT_WINDOW, + authoritative: false, + }; +} + +/** Forgets the learned windows, as a logout asks: 1M-context entitlement is + * gated per org/tier, and an OAuth re-login is invisible to the env-derived + * provider cache key, so windows learned under the old login must not seed + * sessions under the next. */ +export function clearContextWindowCache(): void { + contextWindowCache.clear(); +} + +/** + * Reads the window of the query's current model with + * `getContextUsage().rawMaxTokens`. Undefined when Claude Code reports none. + * + * Requests the `summary` detail instead of the default `full` breakdown, + * which issues one `messages/count_tokens` call per category (~18 for a bare + * session) and hit rate limits on every model switch; `summary` answers + * locally with the same `rawMaxTokens`. Callers never await it on the setup + * or switch path: SDK control requests are serialized, so awaiting would + * delay session/new or a model switch (~15s on older CLIs, issues + * #886/#880; ~0.5s on 2.1.283). + * + * The result does not go into the cross-session cache: its `rawMaxTokens` can + * differ from the `result.modelUsage` window (e.g. 967000 vs 1000000 for + * sonnet), so it only refines the live session. + */ +export async function readContextWindow( + query: Pick, +): Promise { + const usage = await query.getContextUsage({ detail: "summary" }); + return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : undefined; +} diff --git a/src/diff.ts b/src/diff.ts index b81aeb6e..a1c83295 100644 --- a/src/diff.ts +++ b/src/diff.ts @@ -717,7 +717,7 @@ function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCa * * The agent builds tool call content in v1 types, and v1 cannot express a v2 * diff, so the diff travels as v1 content. Nothing in the agent reads inside a - * diff, and only a v2 client gets one: the v2 surface sends it as it is + * diff, and only a v2 client gets one: the v2 core sends it as it is * (`src/v2/tool-call.ts`). */ export function v2DiffContent( diff --git a/src/elicitation.ts b/src/elicitation.ts index 3bd46bdb..2605dfb5 100644 --- a/src/elicitation.ts +++ b/src/elicitation.ts @@ -6,7 +6,13 @@ import type { ElicitationSchema, EnumOption, } from "@agentclientprotocol/sdk/unstable"; -import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk"; +import type { + ElicitationRequest, + ElicitationResult, + PermissionResult, + UserDialogRequest, + UserDialogResult, +} from "@anthropic-ai/claude-agent-sdk"; import type { AskUserQuestionInput, AskUserQuestionOutput, @@ -512,3 +518,156 @@ export function refusalFallbackResultFromResponse(response: CreateElicitationRes const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY]; return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT; } + +/** + * What became of an elicitation: the client's response, or `withdrawn` when + * its signal aborted first (Claude Code withdrew the request, or a cancel or + * close of the session did). A response that comes after the abort counts as + * withdrawn too. + */ +export type ElicitationOutcome = { response: CreateElicitationResponse } | { withdrawn: true }; + +/** + * Sends an elicitation with `send` and reads what became of it. A rejection + * while `signal` is not aborted, such as the client's -32602 for a form it + * cannot render, is rethrown. + */ +export async function elicitUnlessWithdrawn( + signal: AbortSignal, + send: (signal: AbortSignal) => Promise, +): Promise { + try { + const response = await send(signal); + return signal.aborted ? { withdrawn: true } : { response }; + } catch (error) { + if (signal.aborted) return { withdrawn: true }; + throw error; + } +} + +/** Sends a built elicitation to the client (see {@link elicitUnlessWithdrawn}). */ +export type Elicit = (request: CreateElicitationRequest) => Promise; + +type ElicitationLogger = { error: (...args: unknown[]) => void }; + +/** + * Answers an MCP server's elicitation (the SDK's `onElicitation`) through + * `elicit`. Only the user's own decline reaches the MCP server as `decline`. + * A request the user never saw, because the client did not advertise its + * mode, it cannot be represented, the client answered with an error, or it + * was withdrawn, is a `cancel`. + */ +export async function answerMcpElicitation( + request: ElicitationRequest, + { + sessionId, + support, + elicit, + logger, + }: { sessionId: string; support: ElicitationSupport; elicit: Elicit; logger: ElicitationLogger }, +): Promise { + const isUrl = request.mode === "url"; + if ((isUrl && !support.url) || (!isUrl && !support.form)) { + return { action: "cancel" }; + } + const createRequest = mcpElicitationToCreateRequest(request, sessionId); + if (!createRequest) { + return { action: "cancel" }; + } + try { + const outcome = await elicit(createRequest); + if ("withdrawn" in outcome) return { action: "cancel" }; + return createElicitationResponseToElicitResult(outcome.response); + } catch (error) { + // The client answered with an error, e.g. -32602 for a form it cannot + // render, so the user never saw the request. MCP's `decline` means the + // user refused; `cancel` means they made no explicit choice. + logger.error(`Failed to forward MCP elicitation: ${error}`); + return { action: "cancel" }; + } +} + +/** + * Presents the AskUserQuestion tool's questions as a form elicitation of + * `sessionId` through `elicit`, and returns the answers as the tool's + * `updatedInput`. A withdrawn question, or one the user cancelled, aborts the + * tool use. `reportUnanswered` is read once the answer is in (see + * {@link applyAskElicitationResponse}). + */ +export async function answerAskUserQuestion( + toolInput: Record, + { + sessionId, + toolUseID, + airClient = false, + reportUnanswered, + elicit, + logger, + }: { + sessionId: string; + toolUseID: string; + airClient?: boolean; + reportUnanswered: () => boolean; + elicit: Elicit; + logger: ElicitationLogger; + }, +): Promise { + const questions = extractAskUserQuestions(toolInput); + if (!questions) { + return { behavior: "deny", message: "AskUserQuestion called with no valid questions." }; + } + const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, airClient); + let outcome: ElicitationOutcome; + try { + outcome = await elicit(createRequest); + } catch (error) { + logger.error(`Failed to present AskUserQuestion elicitation: ${error}`); + return { behavior: "deny", message: "Could not present the question to the user." }; + } + if ("withdrawn" in outcome) { + throw new Error("Tool use aborted"); + } + const answer = applyAskElicitationResponse(outcome.response, toolInput, questions, { + reportUnanswered: reportUnanswered(), + }); + if (answer.action === "cancel") { + throw new Error("Tool use aborted"); + } + return { behavior: "allow", updatedInput: answer.updatedInput }; +} + +/** + * Answers a `request_user_dialog` of Claude Code (the SDK's `onUserDialog`). + * Only kinds declared in `supportedDialogKinds` are ever emitted; everything + * unexpected is answered `cancelled` (the required answer for unrecognized + * kinds), which applies the dialog's default behavior CLI-side. The only + * declared kind is the refusal-fallback consent prompt, rendered as a form + * elicitation. A failed or withdrawn elicitation is `cancelled` too: the CLI + * then keeps the refusal rather than switching models without consent. + */ +export async function answerUserDialog( + request: UserDialogRequest, + { sessionId, elicit, logger }: { sessionId: string; elicit: Elicit; logger: ElicitationLogger }, +): Promise { + if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) { + return { behavior: "cancelled" }; + } + const prompt = extractRefusalFallbackPrompt(request.payload); + if (!prompt) { + logger.error( + `refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`, + ); + return { behavior: "cancelled" }; + } + let outcome: ElicitationOutcome; + try { + outcome = await elicit(refusalFallbackToCreateRequest(prompt, sessionId)); + } catch (error) { + logger.error(`Failed to present refusal fallback elicitation: ${error}`); + return { behavior: "cancelled" }; + } + if ("withdrawn" in outcome) { + return { behavior: "cancelled" }; + } + return { behavior: "completed", result: refusalFallbackResultFromResponse(outcome.response) }; +} diff --git a/src/file-change-audit.ts b/src/file-change-audit.ts index 94adf3ea..1d58bc3f 100644 --- a/src/file-change-audit.ts +++ b/src/file-change-audit.ts @@ -104,11 +104,12 @@ const FILE_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]); * Adds to `turn.editedPaths` the `file_path` (`notebook_path` for NotebookEdit) * of each successful file-tool edit among the tool results of a subagent * message. Root-agent messages (`parentToolUseId === null`) are skipped: the - * checkpoint already tracks their files. Must run before `toAcpNotifications` - * drops the tool use cache entries. + * checkpoint already tracks their files. A turn that v1 marked `settled` + * records nothing. Must run before `toAcpNotifications` drops the tool use + * cache entries. */ export function recordSubagentFileEdits( - turn: (NativeFileChangeReportTurn & { settled: boolean }) | null | undefined, + turn: (NativeFileChangeReportTurn & { settled?: boolean }) | null | undefined, parentToolUseId: string | null, content: unknown, toolUses: Record, diff --git a/src/mcp-oauth.ts b/src/mcp-oauth.ts new file mode 100644 index 00000000..f644dd40 --- /dev/null +++ b/src/mcp-oauth.ts @@ -0,0 +1,214 @@ +/** + * OAuth for the MCP servers that Claude Code reports as `needs-auth`, as an + * ACP URL elicitation. Protocol-neutral: the v1 core and the v2 core + * both start it once a session's setup response is sent. + */ +import { randomUUID } from "node:crypto"; +import type { + CompleteElicitationNotification, + CreateElicitationRequest, + CreateElicitationResponse, +} from "@agentclientprotocol/sdk/unstable"; +import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk"; + +/** Claude Code keeps the OAuth callback listener open in the background after + * `mcpAuthenticate` returns the authorization URL. The SDK does not expose + * that listener's completion promise, so watch the server status while the + * ACP client has the URL elicitation open. */ +const MCP_OAUTH_STATUS_POLL_MS = 1_000; +const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000; + +/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in + * the public `Query` declaration, even though the method is present on the + * SDK query object and backed by Claude Code's `mcp_authenticate` control. */ +type McpOAuthQuery = Query & { + mcpAuthenticate( + serverName: string, + redirectUri?: string, + ): Promise<{ + authUrl?: string; + requiresUserAction: boolean; + callbackExpected: boolean; + redirectScheme?: "localhost" | "custom"; + callbackPort?: number; + state?: string; + }>; +}; + +export function supportsMcpOAuth(query: Query): query is McpOAuthQuery { + return typeof (query as Partial).mcpAuthenticate === "function"; +} + +/** What an OAuth run needs of the client connection. */ +export type McpOAuthClient = { + /** Whether the client advertised URL elicitation. Without it, OAuth does not start. */ + readonly urlElicitation: boolean; + createElicitation( + params: CreateElicitationRequest, + signal: AbortSignal, + ): Promise; + completeElicitation(params: CompleteElicitationNotification): Promise; + readonly logger: { error: (...args: unknown[]) => void }; +}; + +/** What an OAuth run needs of the session. */ +export type McpOAuthSession = { + /** One OAuth run at a time per key: the session object. */ + readonly key: object; + readonly sessionId: string; + /** The session's query now, or `undefined` once the session is gone. */ + currentQuery(): Query | undefined; + /** Aborts when the session closes. */ + readonly closed: AbortSignal; +}; + +const mcpAuthentications = new WeakMap>(); + +/** Start OAuth in the background for the servers in `requestedServers` that + * Claude reports as needing authentication. It does nothing while another + * OAuth run of the session is in progress. */ +export function startMcpServerAuthentication( + client: McpOAuthClient, + session: McpOAuthSession, + requestedServers: Set, +): void { + if (!client.urlElicitation || requestedServers.size === 0) return; + const query = session.currentQuery(); + if (!query || mcpAuthentications.has(session.key)) return; + + const authentication = authenticateMcpServers(client, session, query, requestedServers) + .catch((error) => { + if (!session.closed.aborted) { + client.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`); + } + }) + .finally(() => { + if (mcpAuthentications.get(session.key) === authentication) { + mcpAuthentications.delete(session.key); + } + }); + mcpAuthentications.set(session.key, authentication); +} + +async function authenticateMcpServers( + client: McpOAuthClient, + session: McpOAuthSession, + query: Query, + requestedServers: Set, +): Promise { + if (!supportsMcpOAuth(query)) { + client.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication."); + return; + } + + const statuses = await query.mcpServerStatus(); + for (const status of statuses) { + if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue; + try { + await authenticateMcpServer(client, session, query, status.name); + } catch (error) { + if (session.currentQuery() && !session.closed.aborted) { + client.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`); + } + } + } +} + +/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation. + * Claude opens and owns the localhost callback listener; the ACP client only + * needs to present the returned authorization URL. */ +async function authenticateMcpServer( + client: McpOAuthClient, + session: McpOAuthSession, + query: McpOAuthQuery, + serverName: string, +): Promise { + if (!session.currentQuery()) return; + + const login = await query.mcpAuthenticate(serverName); + if (!login.requiresUserAction) return; + if (!login.authUrl) { + throw new Error("Claude Code requested user action without returning an authorization URL"); + } + + const elicitationId = `mcp-oauth-${randomUUID()}`; + const flowAbort = new AbortController(); + const abortFlow = () => flowAbort.abort(session.closed.reason); + session.closed.addEventListener("abort", abortFlow, { once: true }); + if (session.closed.aborted) abortFlow(); + + try { + const completed = waitForMcpAuthentication(session, query, serverName, flowAbort.signal); + const elicitation = client.createElicitation( + { + mode: "url", + sessionId: session.sessionId, + message: `Authenticate with MCP server ${serverName}`, + url: login.authUrl, + elicitationId, + }, + flowAbort.signal, + ); + const first = await Promise.race([ + completed.then((authenticated) => ({ type: "completed" as const, authenticated })), + elicitation.then((response) => ({ type: "elicitation" as const, response })), + ]); + + // A url-mode accept carries no content to check. The guard + // `CreateElicitationResponse.isAccept` would also reject an accept whose + // `content` holds a v2-only value, such as an object. + if (first.type === "elicitation" && first.response.action !== "accept") { + return; + } + + if (first.type === "elicitation") { + await completed; + } + try { + await client.completeElicitation({ elicitationId }); + } catch (error) { + if (!flowAbort.signal.aborted) { + client.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`); + } + } + } finally { + flowAbort.abort(); + session.closed.removeEventListener("abort", abortFlow); + } +} + +async function waitForMcpAuthentication( + session: McpOAuthSession, + query: Query, + serverName: string, + signal: AbortSignal, +): Promise { + const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS; + while (!signal.aborted && Date.now() < deadline) { + if (session.currentQuery() !== query) return false; + + const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find( + (server) => server.name === serverName, + ); + if (!status || status.status === "failed" || status.status === "disabled") return false; + if (status.status === "connected") return true; + if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false; + } + return false; +} + +/** Wait for a polling interval, resolving false when the session is aborted. */ +export function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(false); + return new Promise((resolve) => { + const timer = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(true); + }, milliseconds); + const onAbort = () => { + clearTimeout(timer); + resolve(false); + }; + signal.addEventListener("abort", onAbort, { once: true }); + }); +} diff --git a/src/mcp-servers.ts b/src/mcp-servers.ts new file mode 100644 index 00000000..e691de58 --- /dev/null +++ b/src/mcp-servers.ts @@ -0,0 +1,33 @@ +import type { McpServer } from "@agentclientprotocol/sdk/unstable"; +import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk"; + +/** + * The SDK configs of a session's ACP MCP servers, by name. HTTP and SSE + * servers keep their URL and headers; a server without `type` is stdio. A + * server of any other transport is left out: the session's `initialize` + * advertises none. + */ +export function sdkMcpServers( + servers: readonly McpServer[] | null | undefined, +): Record { + const configs: Record = {}; + for (const server of servers ?? []) { + if ("type" in server && (server.type === "http" || server.type === "sse")) { + configs[server.name] = { + type: server.type, + url: server.url, + headers: server.headers + ? Object.fromEntries(server.headers.map((e) => [e.name, e.value])) + : undefined, + }; + } else if (!("type" in server)) { + configs[server.name] = { + type: "stdio", + command: server.command, + args: server.args, + env: server.env ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) : undefined, + }; + } + } + return configs; +} diff --git a/src/native-subagents.ts b/src/native-subagents.ts index c5cd5e81..b18fd4d9 100644 --- a/src/native-subagents.ts +++ b/src/native-subagents.ts @@ -8,6 +8,7 @@ import path from "node:path"; import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js"; import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js"; import { MODEL_CONFIG_ID } from "./session-model.js"; +import { orderedSubagentConfiguration } from "./subagent-configuration.js"; import { isFinalToolCallStatus } from "./tool-calls/renderer.js"; /** The work state of a child, as the RFD's `subagent_update.state` carries it. */ @@ -130,7 +131,7 @@ type TaskStarted = { prompt?: unknown; }; -type SubagentIdentity = { +export type SubagentIdentity = { name?: string; description?: string; prompt?: string; @@ -292,30 +293,6 @@ export class NativeSubagentRuntime { return child?.announced ? child.workState : undefined; } - /** - * The RFD form: the `subagent_update` that tells a client that holds nothing - * of the announced child `sessionId` of this live session what it is now: - * its labels, its directory, its `cancel` capability, and its work state. - * Undefined when no such child runs here. - */ - currentUpdate(sessionId: string): AcpSessionNotification | undefined { - if (this.form !== "rfd") return undefined; - const child = this.childOfSession(sessionId); - if (!child?.announced || !child.workState) return undefined; - return { - sessionId: child.parentSessionId, - update: { - sessionUpdate: "subagent_update", - sessionId: child.sessionId, - ...(child.title ? { title: child.title } : {}), - ...(child.description ? { description: child.description } : {}), - ...(child.reportedCwd ? { cwd: child.reportedCwd } : {}), - capabilities: { cancel: {} }, - state: { state: child.workState }, - }, - }; - } - /** * Reports the model of a child response, an exact model id, as the entry of * the root session's `model` option that matches it, else as the id (see @@ -412,14 +389,7 @@ export class NativeSubagentRuntime { // so a delayed publication cannot replace newer settings with older ones. const publishing = config.publishing.then(async () => { if (this.configBySession.get(child.sessionId) !== config || ended()) return; - const priority = ["mode", "model", "thought_level", "model_config"]; - const rank = (option: SessionConfigOption) => { - const index = priority.indexOf(option.category ?? ""); - return index < 0 ? priority.length : index; - }; - const configOptions = [...config.options.values()].sort( - (left, right) => rank(left) - rank(right), - ); + const configOptions = orderedSubagentConfiguration(config.options.values()); const snapshot = JSON.stringify(configOptions); if (config.published === snapshot) return; await this.publish({ @@ -1978,7 +1948,7 @@ function isWorking(child: NativeSubagent): boolean { * A failure is idle with the `error` stop reason and, when the SDK gives its * text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt * response, so this is how a client learns that its work failed. The error is - * an internal error, as for a failed turn on the v2 surface. + * an internal error, as for a failed turn on v2. * * A task that a worker restart orphaned, or that was still running when the * stream ended, is idle with no stop reason: its work ended, but the adapter @@ -2133,7 +2103,7 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole * one), a SendMessage call whose message reached no announced child, or a * failed SubagentHandback call. */ -function unexposedControlFallback( +export function unexposedControlFallback( initial: AcpSessionNotification | undefined, terminal: AcpSessionNotification, sessionId: string, @@ -2227,7 +2197,8 @@ function subagentDescription(prompt: unknown, description: unknown): string { return "Delegated task"; } -function subagentIdentity(input: unknown): SubagentIdentity | undefined { +/** The labels and prompt of a child, from the input of the Agent or Task call that launched it. */ +export function subagentIdentity(input: unknown): SubagentIdentity | undefined { if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined; const value = input as Record; const identity: SubagentIdentity = { @@ -2239,7 +2210,7 @@ function subagentIdentity(input: unknown): SubagentIdentity | undefined { return Object.values(identity).some(Boolean) ? identity : undefined; } -function mergeSubagentIdentity( +export function mergeSubagentIdentity( previous: SubagentIdentity | undefined, next: SubagentIdentity, ): SubagentIdentity { diff --git a/src/permissions/presentation.ts b/src/permissions/presentation.ts index c9735a54..d9bec263 100644 --- a/src/permissions/presentation.ts +++ b/src/permissions/presentation.ts @@ -120,18 +120,64 @@ export function buildClaudePermissionPresentation( }), // Only AIR gets the permission presentation. ...(title && capabilities.air.client - ? { - _meta: withAirMeta(undefined, AIR_PERMISSION_KEY, { - version: 1, - title, - ...(description ? { description } : {}), - // The CLI's own hint, forwarded so a client that can pre-select - // an option keeps the decline focused; the option order already - // leads with the reject options when this is set. - ...(value.defaultToNo === true ? { defaultToNo: true } : {}), - }), - } + ? { _meta: airPermissionMeta({ title, description, defaultToNo: value.defaultToNo }) } : {}), ...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}), }; } + +/** + * The `_meta.jetbrains.air.permission` of a permission request, from the + * heading and explanation that {@link buildClaudePermissionPresentation} + * built. The v2 core adds it for an AIR client, whose tool calls it + * renders without the AIR tool call contract. + */ +export function airPermissionMeta({ + title, + description, + defaultToNo, +}: { + title: string; + description?: string; + defaultToNo?: boolean; +}): Record { + return withAirMeta(undefined, AIR_PERMISSION_KEY, { + version: 1, + title, + ...(description ? { description } : {}), + // The CLI's own hint, forwarded so a client that can pre-select an + // option keeps the decline focused; the option order already leads with + // the reject options when this is set. + ...(defaultToNo === true ? { defaultToNo: true } : {}), + }); +} + +/** + * The `_meta.claudeCode` of a permission request's tool call: which MCP + * server serves an `mcp__*` tool and where its definition came from + * (`mcpServer`, SDK 0.3.274+), so a client can key trust on `source`, and, + * for clients other than AIR, the tool name and the Agent or Task call of a + * subagent's request. Undefined when there is nothing to add. + */ +export function permissionToolCallMeta({ + toolName, + parentToolUseId, + mcpServer, + airClient, +}: { + toolName: string; + parentToolUseId: string | undefined; + mcpServer: { name: string; source: string } | undefined; + airClient: boolean; +}): { claudeCode: Record } | undefined { + if (!mcpServer && !(parentToolUseId && !airClient)) return undefined; + // The name is the config key as authored: untrusted text, so it rides + // `_meta` rather than the title. + return { + claudeCode: { + ...(airClient ? {} : { toolName }), + ...(parentToolUseId && !airClient ? { parentToolUseId } : {}), + ...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}), + }, + }; +} diff --git a/src/prompt-suggestions.ts b/src/prompt-suggestions.ts index 8dc5bf31..b0abd2cd 100644 --- a/src/prompt-suggestions.ts +++ b/src/prompt-suggestions.ts @@ -42,3 +42,51 @@ export function promptSuggestionsMeta(text: string | null): Record | undefined { + if (!this.expected) return undefined; + this.expected = false; + return this.publish(promptSuggestionText(raw)); + } + + /** + * The `_meta` that clears the prediction: a client prompt spent it, or a + * `conversation_reset` dropped the transcript it came from. It also drops + * a prediction still in flight for the last result. Undefined when the + * client holds none. + */ + clear(): Record | undefined { + this.expected = false; + return this.publish(null); + } + + private publish(text: string | null): Record | undefined { + // An unchanged prediction is not sent again. + if ((this.published ?? null) === text) return undefined; + this.published = text; + return promptSuggestionsMeta(text); + } +} diff --git a/src/providers.ts b/src/providers.ts new file mode 100644 index 00000000..5bbce5c7 --- /dev/null +++ b/src/providers.ts @@ -0,0 +1,421 @@ +/** + * Client-managed LLM routing, shared by the v1 core and the v2 core: + * the `main` provider of `providers/list`, `providers/set` and + * `providers/disable`, and the legacy gateway logins (`authenticate` or + * `auth/login` with `gateway` or `gateway-bedrock`). Each core holds its own + * {@link ProviderConfig} and gateway login, and builds the query env of its + * sessions with {@link createEnvForProvider}. + */ +import { + RequestError, + type AuthenticateRequest, + type AuthMethod, + type ListProvidersResponse, + type LlmProtocol, + type ProviderInfo, + type SetProviderRequest, +} from "@agentclientprotocol/sdk/unstable"; +import { + GatewayListingError, + listBedrockModels, + listGatewayModels, + PROVIDER_AUTH_TOKEN_PLACEHOLDER, + selectBedrockRegion, +} from "./gateway-models.js"; + +type ProviderLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void }; + +/** + * Extra metadata for 'gateway' authentication requests. + */ +export type GatewayAuthMeta = { + /** + * These parameters are mapped to environment variables to: + * - Redirect API calls via baseUrl + * - Inject custom headers + * - Bypass the default Claude login requirement + * + * Both members are optional in the type because the payload arrives + * unvalidated from the client. `authenticate` rejects a request that lacks a + * usable `baseUrl`. + */ + gateway?: { + baseUrl?: string; + headers?: Record; + }; +}; + +export type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta }; + +export const SUPPORTED_PROTOCOLS: LlmProtocol[] = ["anthropic", "bedrock", "vertex"]; +export const PROVIDER_ID = "main"; +export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com"; +export const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com"; + +/** + * Vertex needs project + region that the standard `providers/set` payload + * (`apiType`/`baseUrl`/`headers`) does not model, so clients pass them through + * `_meta.claudeCode.vertex`. Required only when `apiType === "vertex"`. + */ +export type SetProviderMeta = { + claudeCode?: { + vertex?: { + projectId: string; + region: string; + }; + }; +}; + +/** + * Resolved, non-secret + secret routing config for the `main` provider. This is + * the shared shape produced by both `providers/set` and the legacy gateway auth + * path, and consumed by {@link createEnvForProvider}. `null` means the provider + * is unconfigured (no client-managed routing in effect). + */ +export type ProviderConfig = { + apiType: LlmProtocol; + baseUrl: string; + headers: Record; + /** Present only for `apiType === "vertex"`. */ + vertex?: { + projectId: string; + region: string; + }; + /** + * Model ids an `anthropic` or `bedrock` gateway listed when it was set via + * `providers/set`; the model picker offers only these. Absent when the + * listing failed or the config came from elsewhere (gateway `authenticate`), + * so nothing is narrowed. + */ + listedModels?: string[]; + /** + * The cross-region prefix of the model ids a `bedrock` gateway + * listed (`eu` for `eu.anthropic.claude-sonnet-5`), passed to the CLI as + * `ANTHROPIC_BEDROCK_REGION_PREFIX` so it calls the models under that prefix. + */ + bedrockRegionPrefix?: string; +}; + +/** + * Adapt a legacy gateway `authenticate` request into the shared + * {@link ProviderConfig} shape. Returns `null` when no gateway request is + * present. `methodId` selects the protocol: `gateway-bedrock` → bedrock, + * otherwise anthropic. + */ +export function gatewayRequestToProviderConfig( + request?: GatewayAuthRequest, +): ProviderConfig | null { + // `authenticate` validates the payload before it stores one, so a stored + // request always carries a usable gateway. Re-check the shape here anyway: + // this function decides whether a provider override is active, and the + // `--hide-claude-auth` guard is off while one is. + const gateway = request?._meta?.gateway; + if (!gateway || !isValidBaseUrl(gateway.baseUrl)) { + return null; + } + return { + apiType: request?.methodId === "gateway-bedrock" ? "bedrock" : "anthropic", + baseUrl: gateway.baseUrl, + headers: gateway.headers ?? {}, + }; +} + +/** + * Map a resolved provider config into the Claude Code env vars that redirect API + * traffic and inject headers. Returns an empty object when routing is + * unconfigured. The token/bypass placeholders (`" "`) are required so the CLI + * skips its normal login/credential checks when a gateway is in use. + */ +export function createEnvForProvider(config: ProviderConfig | null): Record { + if (!config) { + return {}; + } + const resetRouting = { + ANTHROPIC_BASE_URL: "", + ANTHROPIC_BEDROCK_BASE_URL: "", + ANTHROPIC_VERTEX_BASE_URL: "", + CLAUDE_CODE_USE_BEDROCK: "0", + CLAUDE_CODE_USE_VERTEX: "0", + ANTHROPIC_VERTEX_PROJECT_ID: "", + CLOUD_ML_REGION: "", + AWS_REGION: "", + ANTHROPIC_API_KEY: "", + ANTHROPIC_AUTH_TOKEN: "", + CLAUDE_CODE_OAUTH_TOKEN: "", + }; + const customHeaders = Object.entries(config.headers) + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); + + if (config.apiType === "bedrock") { + return { + ...resetRouting, + CLAUDE_CODE_USE_BEDROCK: "1", + AWS_BEARER_TOKEN_BEDROCK: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local AWS credential checks + ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl, + ANTHROPIC_CUSTOM_HEADERS: customHeaders, + ...(config.bedrockRegionPrefix + ? { ANTHROPIC_BEDROCK_REGION_PREFIX: config.bedrockRegionPrefix } + : {}), + }; + } + + if (config.apiType === "vertex") { + // `config.vertex` is guaranteed present for vertex by `unstable_setProvider` + // validation; fall back to empty strings defensively. + // Leaving ANTHROPIC_VERTEX_BASE_URL empty for the default endpoint lets + // Claude Code use its native Vertex endpoint/model resolution. Supplying + // even the default host marks the session as a custom Vertex deployment and + // can hide otherwise-valid models. + return { + ...resetRouting, + CLAUDE_CODE_USE_VERTEX: "1", + ...(config.baseUrl !== DEFAULT_VERTEX_BASE_URL + ? { ANTHROPIC_VERTEX_BASE_URL: config.baseUrl } + : {}), + ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "", + CLOUD_ML_REGION: config.vertex?.region ?? "", + ANTHROPIC_CUSTOM_HEADERS: customHeaders, + }; + } + + return { + ...resetRouting, + ANTHROPIC_BASE_URL: config.baseUrl, + ANTHROPIC_CUSTOM_HEADERS: customHeaders, + ANTHROPIC_AUTH_TOKEN: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local Claude login checks + }; +} + +function sameHeaders(a: Record, b: Record): boolean { + const aKeys = Object.keys(a); + return aKeys.length === Object.keys(b).length && aKeys.every((key) => b[key] === a[key]); +} + +/** + * A provider base URL as logs may show it: its origin only. The URL can come + * from the env or the client and can carry credentials (`https://user:key@host`) + * or a key in its path or query, so a log line must not print it whole. + */ +export function loggableBaseUrl(baseUrl: string | undefined): string { + if (baseUrl === undefined) return "native"; + try { + return new URL(baseUrl).origin; + } catch { + return "invalid"; + } +} + +/** + * Validate a provider base URL: must be a non-empty absolute http(s) URL. + */ +export function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string { + if (typeof baseUrl !== "string" || baseUrl.trim() === "") { + return false; + } + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + return false; + } + return parsed.protocol === "http:" || parsed.protocol === "https:"; +} + +/** The gateway login methods, advertised to a client with `auth._meta.gateway`. */ +export function gatewayAuthMethods(): AuthMethod[] { + return (["anthropic", "bedrock"] as const).map((protocol) => ({ + id: protocol === "anthropic" ? "gateway" : "gateway-bedrock", + name: "Custom model gateway", + description: "Use a custom gateway to authenticate and access models", + _meta: { gateway: { protocol } }, + })); +} + +export function isGatewayAuthMethod(methodId: string): boolean { + return methodId === "gateway" || methodId === "gateway-bedrock"; +} + +/** + * The gateway login `params` asks for, once its payload is usable: the same + * base URL rule as `providers/set`. An unchecked payload either throws a + * `TypeError` deep in session creation, or installs an empty base URL that + * silently turns the `--hide-claude-auth` subscription guard off. + * + * A request that carries no gateway payload at all keeps its historical + * meaning: it installs no override and succeeds. A client that probes the + * method this way must keep working. Only a payload that IS present has to be + * usable. + */ +export function validatedGatewayAuthRequest(params: AuthenticateRequest): GatewayAuthRequest { + const gateway = (params as GatewayAuthRequest)._meta?.gateway; + if (gateway !== undefined && gateway !== null) { + if (typeof gateway !== "object" || !isValidBaseUrl(gateway.baseUrl)) { + throw RequestError.invalidParams( + { baseUrl: (gateway as { baseUrl?: unknown }).baseUrl }, + "`_meta.gateway.baseUrl` must be a non-empty absolute http(s) URL.", + ); + } + if (gateway.headers !== undefined && typeof gateway.headers !== "object") { + throw RequestError.invalidParams( + undefined, + "`_meta.gateway.headers` must be an object of header names to values.", + ); + } + } + return params as GatewayAuthRequest; +} + +/** The native routing of the agent: a gateway login, else the process env. */ +export function defaultProviderConfig(gatewayAuthRequest?: GatewayAuthRequest): ProviderConfig { + const gatewayConfig = gatewayRequestToProviderConfig(gatewayAuthRequest); + if (gatewayConfig) { + return gatewayConfig; + } + if (process.env.CLAUDE_CODE_USE_BEDROCK) { + return { + apiType: "bedrock", + baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com", + headers: {}, + }; + } + if (process.env.CLAUDE_CODE_USE_VERTEX) { + return { + apiType: "vertex", + baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? DEFAULT_VERTEX_BASE_URL, + headers: {}, + }; + } + return { + apiType: "anthropic", + baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL, + headers: {}, + }; +} + +/** The `providers/list` response for the `main` provider routed by `config`. */ +export function listProvidersResponse(config: ProviderConfig): ListProvidersResponse { + const provider: ProviderInfo = { + providerId: PROVIDER_ID, + supported: SUPPORTED_PROTOCOLS, + required: false, + current: { apiType: config.apiType, baseUrl: config.baseUrl }, + }; + return { providers: [provider] }; +} + +/** + * The configuration a `providers/set` request asks for. Rejects unknown IDs, + * unsupported protocols, empty or invalid base URLs, and a `vertex` request + * without its project and region, with `invalid_params`. + */ +export function providerConfigFromSetRequest(params: SetProviderRequest): ProviderConfig { + if (params.providerId !== PROVIDER_ID) { + throw RequestError.invalidParams( + { providerId: params.providerId }, + `Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`, + ); + } + if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) { + throw RequestError.invalidParams( + { apiType: params.apiType, supported: SUPPORTED_PROTOCOLS }, + `Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`, + ); + } + if (!isValidBaseUrl(params.baseUrl)) { + throw RequestError.invalidParams( + { baseUrl: params.baseUrl }, + "baseUrl must be a non-empty absolute http(s) URL.", + ); + } + + const config: ProviderConfig = { + apiType: params.apiType, + baseUrl: params.baseUrl, + headers: params.headers ?? {}, + }; + + // Vertex requires project + region, which the standard payload cannot + // carry, so they arrive via `_meta.claudeCode.vertex`. + if (params.apiType === "vertex") { + const vertex = (params._meta as SetProviderMeta | undefined)?.claudeCode?.vertex; + if ( + !vertex || + typeof vertex.projectId !== "string" || + vertex.projectId.trim() === "" || + typeof vertex.region !== "string" || + vertex.region.trim() === "" + ) { + throw RequestError.invalidParams( + undefined, + "vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.", + ); + } + config.vertex = { projectId: vertex.projectId, region: vertex.region }; + } + return config; +} + +/** + * Adds to `config` the model ids its gateway lists, and for `bedrock` the + * region prefix picked from them, so the sessions it routes offer only those. + * Setting the same gateway as `current` again reuses its listing. `vertex` + * gateways are not listed. A failed listing throws an `internal_error`. + */ +export async function withListedModels( + config: ProviderConfig, + current: ProviderConfig | undefined, + logger: ProviderLogger, +): Promise { + try { + Object.assign(config, await listProviderModels(config, current, logger)); + } catch (error) { + if (error instanceof GatewayListingError) { + throw RequestError.internalError( + { providerId: PROVIDER_ID, apiType: config.apiType, baseUrl: config.baseUrl }, + `Model discovery for provider "${PROVIDER_ID}" failed: ${error.message}`, + ); + } + throw error; + } + logger.log( + `[providers/set] listedModels=${config.listedModels?.length ?? "none"} bedrockRegionPrefix=${config.bedrockRegionPrefix ?? "none"}`, + ); + return config; +} + +async function listProviderModels( + config: ProviderConfig, + current: ProviderConfig | undefined, + logger: ProviderLogger, +): Promise> { + // Reuse needs the very same headers, so a rotated token lists again, and a + // failed listing then fails that `providers/set`. Deliberate: AIR keeps its + // proxy key for the IDE's lifetime and does not rotate it. + if ( + current?.listedModels && + current.apiType === config.apiType && + current.baseUrl === config.baseUrl && + sameHeaders(current.headers, config.headers) + ) { + return { + listedModels: current.listedModels, + bedrockRegionPrefix: current.bedrockRegionPrefix, + }; + } + if (config.apiType === "anthropic") { + return { listedModels: await listGatewayModels(config.baseUrl, config.headers, logger) }; + } + if (config.apiType === "bedrock") { + const modelIds = await listBedrockModels(config.baseUrl, config.headers, logger); + if (!modelIds) return {}; + const region = selectBedrockRegion(modelIds); + if (region.modelIds.length < modelIds.length) { + logger.log( + `[providers/set] Bedrock models span several region prefixes; using ${region.regionPrefix} and dropping ${modelIds.length - region.modelIds.length} id(s) of other prefixes`, + ); + } + return { listedModels: region.modelIds, bedrockRegionPrefix: region.regionPrefix }; + } + return {}; +} diff --git a/src/remote-control.ts b/src/remote-control.ts new file mode 100644 index 00000000..18ddbced --- /dev/null +++ b/src/remote-control.ts @@ -0,0 +1,129 @@ +/** + * `/remote-control`, which the adapter serves itself, and the messages that a + * person types in another client of the session (claude.ai or the Claude + * mobile app, over Remote Control). Shared by the v1 core and the v2 + * core, which run the command each in their own turn model. + */ +/** The content blocks of a prompt, in v1 or v2 form: the command is one text block. */ +type PromptContent = ReadonlyArray<{ type: string; text?: unknown }>; + +import type { + Query, + SDKUserMessage, + SDKUserMessageReplay, + SlashCommand, +} from "@anthropic-ai/claude-agent-sdk"; + +/** Runtime Remote Control control exposed by the pinned Agent SDK. Like + * `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's + * own `/remote-control` is terminal-bound and not offered to SDK hosts, so the + * adapter handles the command itself and answers with the session URL. */ +export type RemoteControlQuery = Query & { + enableRemoteControl( + enabled: boolean, + name?: string, + ): Promise<{ session_url?: string; connect_url?: string }>; +}; + +export function supportsRemoteControl(query: Query): query is RemoteControlQuery { + return typeof (query as Partial).enableRemoteControl === "function"; +} + +export const REMOTE_CONTROL_COMMAND_NAME = "remote-control"; + +export type RemoteControlCommand = { action: "connect"; name?: string } | { action: "disconnect" }; + +/** Parses `/remote-control [name]` and `/remote-control off` from a single-text + * prompt. Connecting is idempotent, so the command never needs to know whether + * Remote Control is already on. */ +export function parseRemoteControlCommand(prompt: PromptContent): RemoteControlCommand | null { + const [first] = prompt; + if (prompt.length !== 1 || first?.type !== "text" || typeof first.text !== "string") return null; + const match = /^\/remote-control(?:\s+([\s\S]*))?$/.exec(first.text.trim()); + if (!match) return null; + const argument = match[1]?.trim(); + if (argument?.toLowerCase() === "off") return { action: "disconnect" }; + return argument ? { action: "connect", name: argument } : { action: "connect" }; +} + +/** Whether Claude Code offers a slash command of this name to an ACP client. + * This is the rule `getAvailableSlashCommands` applies when it lists the + * commands, so the adapter stands in for the command exactly when the client + * sees the adapter's entry: Claude Code's terminal-bound commands are not + * offered, and a user's own command or skill of the name is never shadowed. */ +export function claudeCodeOffersCommand( + commands: readonly SlashCommand[], + terminalCommands: readonly string[] | undefined, + name: string, +): boolean { + return commands.some( + (command) => command.name === name && !terminalCommands?.includes(command.name), + ); +} + +/** + * The `/remote-control` command of `prompt` that the adapter runs: the query + * serves Remote Control and Claude Code does not offer a command of that name. + * `null` for any other prompt, which goes to Claude Code. + */ +export async function adapterRemoteControlCommand( + prompt: PromptContent, + query: Query, + terminalCommands: readonly string[] | undefined, +): Promise<{ query: RemoteControlQuery; command: RemoteControlCommand } | null> { + const command = parseRemoteControlCommand(prompt); + if (!command || !supportsRemoteControl(query)) return null; + if ( + claudeCodeOffersCommand( + await query.supportedCommands(), + terminalCommands, + REMOTE_CONTROL_COMMAND_NAME, + ) + ) { + return null; + } + return { query, command }; +} + +/** + * Connects Remote Control (again, if it is already on) or disconnects it, and + * returns the text that tells the user how it went. A failure is in the text: + * this never throws. + */ +export async function runRemoteControlCommand( + query: RemoteControlQuery, + command: RemoteControlCommand, + logError: (message: string) => void, +): Promise { + if (command.action === "disconnect") { + try { + await query.enableRemoteControl(false); + return "Remote Control is off. Run /remote-control to reconnect."; + } catch (error) { + logError(`Failed to disable Remote Control: ${error}`); + return `Could not stop Remote Control: ${error instanceof Error ? error.message : error}`; + } + } + try { + const result = await query.enableRemoteControl(true, command.name); + return result.session_url + ? `Remote Control is active. Continue this session from claude.ai/code or the Claude mobile app:\n\n${result.session_url}\n\nRun /remote-control off to disconnect.` + : "Remote Control is active, but Claude Code did not return a session URL."; + } catch (error) { + logError(`Failed to enable Remote Control: ${error}`); + return `Could not start Remote Control: ${error instanceof Error ? error.message : error}`; + } +} + +/** A top-level message a person typed in another client of the session, such + * as claude.ai or the Claude mobile app over Remote Control. Tool results and + * the CLI's own synthetic messages are user-role too, but carry no human + * origin. */ +export function isRemoteHumanMessage(message: SDKUserMessage | SDKUserMessageReplay): boolean { + return ( + message.type === "user" && + message.origin?.kind === "human" && + message.parent_tool_use_id === null && + !message.isSynthetic + ); +} diff --git a/src/result-end.ts b/src/result-end.ts new file mode 100644 index 00000000..a8a81d61 --- /dev/null +++ b/src/result-end.ts @@ -0,0 +1,62 @@ +/** + * How Claude Code's `result` ends the work that it reports: the rules that + * both cores read a turn's result with (the v1 prompt loop in `acp-agent.ts` + * and `v2/outcome.ts`). Each core adds its own side effects, such as v1's + * session failure category. + */ +import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; + +export type ResultEnd = + | { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" } + /** + * The result failed the work, with Claude Code's error text. `loginRequired`: + * the text of a `success` result tells the user to run `/login`. + */ + | { error: string; loginRequired: boolean }; + +/** + * True when CLI error text tells the user to sign in with `/login`. The CLI + * words this several ways — "Not logged in · Please run /login", "Not logged + * in · Run /login", "Login expired · Run /login to sign in again, or + * re-authenticate your Anthropic profile" — so match the "run /login" + * instruction rather than one sentence. + */ +export function isLoginRequiredText(text: string): boolean { + return /\brun \/login\b/i.test(text); +} + +/** + * - Any subtype with `stop_reason: "refusal"`: `refusal`, also on an error. + * - `success`: an error with the `/login` text fails the work, then + * `stop_reason: "max_tokens"` is `max_tokens`, then any other error fails + * the work. Otherwise `end_turn`. + * - `error_during_execution`: `max_tokens` as for `success`, then an error + * fails the work. Otherwise `end_turn`. + * - `error_max_turns`, `error_max_budget_usd` and + * `error_max_structured_output_retries`: an error fails the work, whatever + * the stop reason. Otherwise `max_turn_requests`. + */ +export function resultEnd(result: SDKResultMessage): ResultEnd { + if (result.stop_reason === "refusal") return { stopReason: "refusal" }; + switch (result.subtype) { + case "success": + if (result.is_error && isLoginRequiredText(result.result)) { + return { error: result.result, loginRequired: true }; + } + if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" }; + if (result.is_error) return { error: result.result, loginRequired: false }; + return { stopReason: "end_turn" }; + case "error_during_execution": + if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" }; + if (result.is_error) return failed(result); + return { stopReason: "end_turn" }; + default: + // `error_max_turns`, `error_max_budget_usd`, `error_max_structured_output_retries`. + if (result.is_error) return failed(result); + return { stopReason: "max_turn_requests" }; + } +} + +function failed(result: Exclude): ResultEnd { + return { error: result.errors.join(", ") || result.subtype, loginRequired: false }; +} diff --git a/src/serve.ts b/src/serve.ts index 3f11699d..2e28f19a 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -1,7 +1,7 @@ import * as v2 from "@agentclientprotocol/sdk/experimental/v2"; import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js"; import { nodeToWebReadable, nodeToWebWritable } from "./utils.js"; -import { v2AgentApp } from "./v2/agent.js"; +import { v2AgentApp, type AgentHandle } from "./v2/agent.js"; /** * The ACP versions that the adapter serves: `v1` or `v2` alone, or both with @@ -32,13 +32,13 @@ export function serveAcp( const input = nodeToWebReadable(process.stdin); // The router opens the connection of the negotiated version only when it // reads `initialize`, so a client that leaves before then leaves no agent. - let agent: ClaudeAcpAgent | undefined; - const onAgent = (created: ClaudeAcpAgent) => { + let agent: AgentHandle | undefined; + const onAgent = (created: AgentHandle) => { agent = created; }; - const { closed } = acpProtocolRouter(logger, onAgent, options.protocols).connect( - v2.ndJsonStream(output, input), - ); + const { closed } = acpProtocolRouter(logger, onAgent, options.protocols, { + onV2Agent: onAgent, + }).connect(v2.ndJsonStream(output, input)); return { closed, dispose: async () => { @@ -53,15 +53,20 @@ export function serveAcp( * the draft ACP v2, and every other client gets ACP v1. With `v1`, a v2 * client is negotiated down to v1. With `v2`, the router rejects the * `initialize` of a v1 client as an unsupported protocol version. `onAgent` - * receives the agent of the connection once the router has opened it. + * receives the agent of a v1 connection (`ClaudeAcpAgent`) once the router + * has opened it, and `onV2Agent` the agent of a v2 connection, which the + * v2 core (`src/v2/`) serves. */ export function acpProtocolRouter( logger: Logger | undefined, onAgent: (agent: ClaudeAcpAgent) => void, protocols: AcpProtocols = "auto", + { onV2Agent = () => {} }: { onV2Agent?: (agent: AgentHandle) => void } = {}, ): v2.AgentProtocolRouter { const router = v2.agentProtocolRouter(); if (protocols !== "v2") router.withV1(v1AgentApp(logger, onAgent)); - if (protocols !== "v1") router.withV2(v2AgentApp(logger, onAgent)); + if (protocols !== "v1") { + router.withV2(v2AgentApp(logger, onV2Agent)); + } return router; } diff --git a/src/session-config-ids.ts b/src/session-config-ids.ts index 3a17a7f5..b2635eab 100644 --- a/src/session-config-ids.ts +++ b/src/session-config-ids.ts @@ -1,5 +1,6 @@ /** 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 AGENT_CONFIG_ID = "agent"; export const EFFORT_CONFIG_ID = "effort"; export const ULTRACODE_CONFIG_ID = "ultracode"; diff --git a/src/session-config-options.ts b/src/session-config-options.ts new file mode 100644 index 00000000..17f4001c --- /dev/null +++ b/src/session-config-options.ts @@ -0,0 +1,244 @@ +/** + * The config options of a session, as both the v1 core and the v2 core + * build them: mode, model, effort, Fast mode, ultracode and the main-thread + * agent. + */ +import { + RequestError, + type ClientCapabilities, + type SessionConfigOption, + type SessionModeState, + type SetSessionConfigOptionRequest, +} from "@agentclientprotocol/sdk/unstable"; +import type { AgentInfo, ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk"; +import { + AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, + clientSupportsAirCapability, +} from "./air-extension.js"; +import { AGENT_CONFIG_ID, DEFAULT_AGENT_ID } from "./session-config-ids.js"; +import { buildEffortConfigOption } from "./session-effort.js"; +import { + createFastModeConfigOption, + FAST_MODE_CONFIG_ID, + resolveFastModeEnabled, + type FastModeOptionState, +} from "./session-fast-mode.js"; +import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js"; +import { + buildModelConfigOption, + MODEL_CONFIG_ID, + resolveModelPreference, + type SessionModelState, +} from "./session-model.js"; +import { + createUltracodeConfigOption, + resolveUltracodeEnabled, + ULTRACODE_CONFIG_ID, + type UltracodeOptionState, +} from "./session-ultracode.js"; + +// `supportedAgents()` always returns Claude Code's built-in subagents — the +// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user +// has configured none of their own. Those aren't meaningful *main-thread* +// personas, so we filter them out and only surface the Agent picker when the +// user (or a plugin/project) has configured custom agents. Update this set if +// the SDK's built-in roster changes. +export const BUILTIN_AGENT_NAMES = new Set([ + "claude", + "general-purpose", + "Explore", + "Plan", + "statusline-setup", +]); + +// Value of the synthetic "Default" entry in the agent picker, which maps to the +// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a +// 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. +/** 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 + * creation. */ +export async function discoverCustomAgents(q: Query): Promise { + try { + return customAgents(await q.supportedAgents()); + } catch { + return []; + } +} + +/** The configured agents of `agents`: neither a built-in subagent nor the "default" sentinel. */ +export function customAgents(agents: readonly AgentInfo[]): AgentInfo[] { + return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID); +} + +/** Stable ids for the session config options surfaced via `configOptions`. + * Centralized so the option declarations in `buildConfigOptions` and the + * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the + * same identifiers and can't drift apart. */ +export { MODE_CONFIG_ID }; +export { AGENT_CONFIG_ID }; + +/** Whether the Client advertised support for boolean session config options + * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"` + * config options to Clients that opt in; otherwise we fall back to a `select`. + * See https://agentclientprotocol.com/rfds/boolean-config-option. */ +export function clientSupportsBooleanConfigOptions( + clientCapabilities?: ClientCapabilities | null, +): boolean { + return clientCapabilities?.session?.configOptions?.boolean != null; +} + +/** Whether the Client advertised the AIR `recommendedValue` capability for + * select-style model and effort options. Missing or malformed AIR metadata + * retains the legacy `default` entries. */ +export function clientSupportsRecommendedConfigValue( + clientCapabilities?: ClientCapabilities | null, +): boolean { + return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY); +} + +export type ConfigOptionPresentation = { + /** Replace ambiguous `default` rows with concrete values and advertise the + * SDK/adapter recommendation as `_meta.jetbrains.air.recommendedValue`. */ + useRecommendedValue: boolean; +}; + +export function buildConfigOptions( + modes: SessionModeState, + models: SessionModelState, + modelInfos: ModelInfo[], + currentEffortLevel?: string, + agents: AgentInfo[] = [], + currentAgent: string = DEFAULT_AGENT_ID, + fastMode?: FastModeOptionState, + presentation?: ConfigOptionPresentation, + ultracode?: UltracodeOptionState, +): SessionConfigOption[] { + const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)]; + // A gateway that lists none of the SDK's models leaves no model to offer, so + // there is no model option at all. + if (models.availableModels.length > 0) { + options.push( + buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true), + ); + } + const effort = buildEffortConfigOption( + modelInfos, + models.currentModelId, + currentEffortLevel, + presentation?.useRecommendedValue === true, + ); + if (effort) options.push(effort); + + // Surface the Fast mode toggle only when the current model supports it. The + // option renders as a native boolean toggle for Clients that opted in, and a + // two-value select otherwise. + if (fastMode?.supported) { + options.push( + createFastModeConfigOption( + fastMode.enabled, + fastMode.useBooleanOption, + fastMode.disabledReason, + ), + ); + } + + // Surface the ultracode toggle only while the CLI can run it: dynamic + // workflows are enabled and the current model supports it. + if (ultracode?.supported) { + options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption)); + } + + // Only surface the Agent picker when there's a real choice — i.e. the user + // has configured at least one custom agent (built-ins are filtered out in + // discoverCustomAgents). With none configured, "Default" would be the only + // entry, so we omit the option entirely. + if (agents.length > 0) { + options.push({ + id: AGENT_CONFIG_ID, + name: "Agent", + description: "Main-thread agent persona", + type: "select", + currentValue: currentAgent, + options: [ + { value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" }, + ...agents.map((a) => ({ + value: a.name, + name: a.name, + description: a.description || undefined, + })), + ], + }); + } + + return options; +} + +/** + * The value that a `session/set_config_option` request sets on `option`, the + * option as the session advertised it. The value must match the option's + * type: a `type: "boolean"` option takes `true`/`false`, not "on"/"off", and a + * `type: "select"` option takes one of its values. + * + * Fast mode and ultracode give `enabled`: a boolean, or the "on"/"off" select + * fallback for clients without boolean config options. A model takes an alias + * too, such as "opus" or "sonnet" for a full model id. It is resolved against + * `modelInfos` first: those entries carry `resolvedModel`, so a full model id + * (in either hint spelling) lands on the right row via the exact tier instead + * of a fuzzier one picking a same-family sibling from a different context + * lane. The option's own entries (which never carry `resolvedModel`) remain as + * a fallback for resolutions that don't map back onto a selectable option + * (e.g. a fuzzy hit on an out-of-picker verbatim entry). `value` is then the + * canonical option value, the model id rather than the alias. + */ +export function configOptionValue( + option: SessionConfigOption, + params: SetSessionConfigOptionRequest, + modelInfos: ModelInfo[], +): { enabled: boolean } | { value: string } { + const expectedType = option.type === "boolean" ? "boolean" : "string"; + if (typeof params.value !== expectedType) { + throw RequestError.invalidParams( + { configId: params.configId, value: params.value }, + `Config option ${params.configId} takes a ${expectedType} value, but received: ${JSON.stringify(params.value)}`, + ); + } + if (params.configId === FAST_MODE_CONFIG_ID) return { enabled: resolveFastModeEnabled(params) }; + if (params.configId === ULTRACODE_CONFIG_ID) { + return { enabled: resolveUltracodeEnabled(params) }; + } + if (typeof params.value !== "string") { + throw RequestError.invalidParams( + undefined, + `Invalid value for config option ${params.configId}: ${params.value}`, + ); + } + const requested = params.value; + const allValues = + "options" in option && Array.isArray(option.options) + ? option.options.flatMap((o) => ("options" in o ? o.options : [o])) + : []; + let validValue = allValues.find((o) => o.value === requested); + if (!validValue && params.configId === MODEL_CONFIG_ID) { + const toOptionValue = (resolved: ModelInfo | null) => + resolved ? allValues.find((o) => o.value === resolved.value) : undefined; + validValue = toOptionValue(resolveModelPreference(modelInfos, requested)); + if (!validValue) { + const optionInfos: ModelInfo[] = allValues.map((o) => ({ + value: o.value, + displayName: o.name, + description: o.description ?? "", + })); + validValue = toOptionValue(resolveModelPreference(optionInfos, requested)); + } + } + if (!validValue) { + throw RequestError.invalidParams( + undefined, + `Invalid value for config option ${params.configId}: ${requested}`, + ); + } + return { value: validValue.value }; +} diff --git a/src/session-effort.ts b/src/session-effort.ts index 68b69cfc..21351cea 100644 --- a/src/session-effort.ts +++ b/src/session-effort.ts @@ -25,6 +25,22 @@ export function effortFlagSettings( return ultracode ? { effortLevel, ultracode: true } : { effortLevel }; } +/** + * `params` without `_meta.claudeCode.options.effort`, for a restart of the + * session's query: the restart restores the current effort pin instead of + * the one the session was created with. + */ +export function withoutEffort(params: T): T { + const meta = params._meta as + { claudeCode?: { options?: { effort?: unknown } } } | null | undefined; + if (meta?.claudeCode?.options?.effort === undefined) { + return params; + } + const options = { ...meta.claudeCode.options }; + delete options.effort; + return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } }; +} + function canonicalizeModelSettingsKey(value: string): string { return value .trim() @@ -133,3 +149,130 @@ export function buildEffortConfigOption( : {}), }; } + +/** A session's effort bookkeeping: the level the user pinned through the + * picker, and the level last applied to the SDK's flag layer. */ +export type EffortState = { + pinned: string | undefined; + applied: string | undefined; +}; + +/** The effort after a switch to `newModelInfo`, before the option is rebuilt + * (see {@link settleEffortAfterModelSwitch}). */ +export type EffortSwitch = { + /** The effort to build the new model's option with. */ + seed: string | undefined; + /** Whether the user's pin is one of the new model's levels. */ + pinnedForNewModel: boolean; + before: EffortState; + /** The level applied before the switch, restored if the sync fails. */ + appliedBeforeSwitch: string | undefined; +}; + +/** The effort seed of a model switch. It depends on who owns the choice: a + * user pin made through the ACP picker this session lives at the SDK's flag + * layer and follows the session across switches, so it carries forward. + * Otherwise the CLI resolves effort itself from the persisted settings — the + * NEW model's `modelSettings` entry first (the CLI persists /effort per + * model), then the legacy top-level value — so the option shows what it + * will actually run instead of dragging the old model's value along. */ +export function effortForModelSwitch(input: { + state: EffortState; + /** The current value of the effort option, if it is shown. */ + displayed: string | undefined; + newModelInfo: ModelInfo | undefined; + modelId: string; + /** The merged effort settings (see {@link mergeEffortSettings}). */ + settings: Settings; + useRecommendedValue: boolean; +}): EffortSwitch { + const { state, displayed, newModelInfo, useRecommendedValue } = input; + const pinned = state.pinned; + const pinnedForNewModel = + pinned !== undefined && + newModelInfo?.supportsEffort === true && + newModelInfo.supportedEffortLevels?.some((level) => level === pinned) === true; + return { + seed: pinnedForNewModel + ? pinned + : settingsEffortForModel(input.settings, newModelInfo, input.modelId), + pinnedForNewModel, + before: state, + appliedBeforeSwitch: + state.applied ?? + (useRecommendedValue && typeof displayed === "string" && displayed !== "default" + ? displayed + : pinned), + }; +} + +/** + * Applies the effort of a model switch to the SDK's flag layer, once the new + * model's effort `option` is built from `change.seed`. Returns the new state + * and the option to show (undefined to hide it). + * + * Opted-in clients (`useRecommendedValue`) apply the concrete displayed effort + * on every switch, including settings-derived values, so a previous automatic + * flag cannot shadow the new model's settings. This does not create a user + * pin. For other clients, the flag is synced only when a user pin is lost + * across the switch — the new model clamped it away, where the flag must be + * cleared too or the SDK would keep running the old pin invisibly. + * Settings-derived seeds are display-only: the CLI resolves persisted effort + * itself, and pinning it at the flag layer would shadow the per-model values + * on every later switch. + * + * The model switch already succeeded, so a failed sync is returned as + * `error`, not thrown. The old bookkeeping stays, and the unapplied value is + * never shown: the option shows the last applied value when the new model can + * select it, else it is hidden until a later switch rebuilds it. + */ +export async function settleEffortAfterModelSwitch( + change: EffortSwitch, + option: SessionConfigOption | undefined, + ultracode: boolean, + useRecommendedValue: boolean, + applyFlagSettings: (settings: ReturnType) => Promise, +): Promise<{ state: EffortState; option: SessionConfigOption | undefined; error?: unknown }> { + const { pinnedForNewModel, before } = change; + const sync = useRecommendedValue || (before.pinned !== undefined && !pinnedForNewModel); + if (!sync) { + return { + state: pinnedForNewModel ? { pinned: before.pinned, applied: before.pinned } : before, + option, + }; + } + const newEffort = typeof option?.currentValue === "string" ? option.currentValue : undefined; + try { + // A legacy client's unpinned effort is display-only: the CLI resolves the + // persisted value for the new model. When an old user pin is no longer + // supported, clear the flag layer instead of replacing it with that + // displayed value. Opted-in clients deliberately apply their concrete + // displayed effort. + await applyFlagSettings( + effortFlagSettings(useRecommendedValue ? newEffort : undefined, ultracode), + ); + return { + state: { + pinned: pinnedForNewModel ? before.pinned : undefined, + applied: useRecommendedValue && newEffort !== "default" ? newEffort : undefined, + }, + option, + }; + } catch (error) { + const applied = change.appliedBeforeSwitch; + const selectable = + applied !== undefined && + option?.type === "select" && + option.options.some((entry) => + "value" in entry + ? entry.value === applied + : entry.options.some((nested) => nested.value === applied), + ); + return { + state: { pinned: before.pinned, applied }, + option: + selectable && option?.type === "select" ? { ...option, currentValue: applied } : undefined, + error, + }; + } +} diff --git a/src/session-env.ts b/src/session-env.ts new file mode 100644 index 00000000..7a41d0b2 --- /dev/null +++ b/src/session-env.ts @@ -0,0 +1,81 @@ +/** + * Session options that come from the agent's environment, shared by the v1 + * core and the v2 core. + */ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import type { Options, Settings, ThinkingConfig } from "@anthropic-ai/claude-agent-sdk"; + +/** Translate the legacy `MAX_THINKING_TOKENS` env var, plus the optional + * `CLAUDE_CODE_THINKING_DISPLAY` env var, into the SDK's `thinking` option. + * The `maxThinkingTokens` option it used to feed is deprecated and reduced + * to on/off on current models, so map the value to explicit thinking config + * instead: unset → `undefined` (SDK default, adaptive on models that support + * it) unless a display mode is requested, in which case it becomes explicit + * `adaptive`; `0` → disabled; a positive integer → a fixed token budget. + * Anything else is ignored with a warning. `CLAUDE_CODE_THINKING_DISPLAY` + * must be `summarized` or `omitted`; it has no effect when thinking is + * disabled. */ +export function resolveThinkingConfig( + rawTokens: string | undefined, + rawDisplay: string | undefined, + logger: { error: (...args: unknown[]) => void }, +): ThinkingConfig | undefined { + let display: "summarized" | "omitted" | undefined; + if (rawDisplay !== undefined) { + if (rawDisplay === "summarized" || rawDisplay === "omitted") { + display = rawDisplay; + } else { + logger.error( + `Ignoring CLAUDE_CODE_THINKING_DISPLAY: expected 'summarized' or 'omitted', got '${rawDisplay}'.`, + ); + } + } + + if (rawTokens === undefined) { + return display ? { type: "adaptive", display } : undefined; + } + const parsed = Number.parseInt(rawTokens, 10); + if (Number.isNaN(parsed) || parsed < 0) { + logger.error( + `Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${rawTokens}'.`, + ); + return display ? { type: "adaptive", display } : undefined; + } + return parsed === 0 + ? { type: "disabled" } + : { type: "enabled", budgetTokens: parsed, ...(display && { display }) }; +} + +/** The `CLAUDE_MODEL_CONFIG` JSON: Bedrock model overrides and an allowlist, say. */ +function parseModelConfig( + raw: string | undefined, +): { modelOverrides?: Record; availableModels?: string[] } | undefined { + if (!raw) return undefined; + const parsed = JSON.parse(raw); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object"); + } + const result: { modelOverrides?: Record; availableModels?: string[] } = {}; + if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides; + if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels; + return Object.keys(result).length > 0 ? result : undefined; +} + +/** + * The programmatic settings of a session, before the provider routing: the + * models of `CLAUDE_MODEL_CONFIG`, then the client's `settings` option, an + * object or a file path relative to `cwd`. Throws for a `CLAUDE_MODEL_CONFIG` + * that is no JSON object, before it reads the file. + */ +export async function configuredSettings( + provided: Options["settings"], + cwd: string, +): Promise { + const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG); + const object = + typeof provided === "string" + ? (JSON.parse(await fs.readFile(path.resolve(cwd, provided), "utf8")) as Settings) + : (provided as Settings | undefined); + return modelConfig ? { ...modelConfig, ...object } : object; +} diff --git a/src/session-fast-mode.ts b/src/session-fast-mode.ts new file mode 100644 index 00000000..3ceb99a3 --- /dev/null +++ b/src/session-fast-mode.ts @@ -0,0 +1,190 @@ +/** + * The Fast mode toggle of a session: its config option, the value a + * `session/set_config_option` request sets, and the state Claude Code reports + * (`fast_mode_state` and `fast_mode_disabled_reason`, on `system`/init and on + * results). Shared by the v1 core and the v2 core. + */ +import { RequestError } from "@agentclientprotocol/sdk/unstable"; +import type { + SessionConfigOption, + SetSessionConfigOptionRequest, +} from "@agentclientprotocol/sdk/unstable"; +import type { FastModeDisabledReason, FastModeState } from "@anthropic-ai/claude-agent-sdk"; +import { sentenceCase } from "./session-notices.js"; + +export const FAST_MODE_CONFIG_ID = "fast"; + +/** Select-fallback values used when the client has not opted into boolean + * config options (see {@link createFastModeConfigOption}). */ +export const FAST_MODE_ON = "on"; +export const FAST_MODE_OFF = "off"; +const FAST_MODE_DESCRIPTION = "Faster responses on supported models"; + +/** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle. + * `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK + * docs) keeps the toggle on so it reflects the user's intent — only an + * explicit `off` clears it. */ +export function fastModeStateEnabled(state: FastModeState): boolean { + return state !== "off"; +} + +/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values + * that a user can act on (or at least wants to know about). Deliberately + * partial — the omitted reasons are not worth surfacing: + * - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the + * opt-in), so it describes the default, not a problem. + * - `preference`: the user turned Fast mode off themselves. + * - `pending`: eligibility is still resolving; the next report supersedes it. + * - `unknown`: nothing meaningful to say. + * Unknown future reasons fall through the same way (open set — the SDK's docs + * say to ignore values you don't handle). */ +const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial> = { + free: "not available on the free plan", + extra_usage_disabled: "requires extra usage to be enabled for this account", + model_not_allowed: "not available for the selected model", + not_first_party: "not available on this API provider", + disabled_by_env: "disabled by environment configuration", + network_error: "eligibility could not be verified (network error)", +}; + +/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain: + * a reason we have an explanation for, else `undefined`. Keeping only + * explainable reasons means state comparisons (see `fastModeStateChange`) track + * exactly what the user can see, so routine reports like + * `sdk_opt_in_required` never churn the config option. */ +export function normalizeFastModeDisabledReason( + reason: FastModeDisabledReason | undefined, +): FastModeDisabledReason | undefined { + return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined; +} + +/** Build the Fast mode config option. When the Client supports boolean config + * options we expose a native `type: "boolean"` toggle; otherwise we degrade to + * a two-value `select` ("on"/"off") so older Clients still get a usable + * control. + * + * `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the + * description while the toggle reads off, so a user whose account or provider + * can't serve Fast mode sees why instead of a switch that silently refuses to + * stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown` + * state isn't blocking anything right now. */ +export function createFastModeConfigOption( + enabled: boolean, + useBooleanOption: boolean, + disabledReason?: FastModeDisabledReason, +): SessionConfigOption { + const explanation = enabled + ? undefined + : disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason]; + const base = { + id: FAST_MODE_CONFIG_ID, + name: "Fast mode", + description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION, + category: "model_config", + } as const; + + if (useBooleanOption) { + return { ...base, type: "boolean", currentValue: enabled }; + } + + return { + ...base, + type: "select", + currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF, + options: [ + { value: FAST_MODE_ON, name: "On" }, + { value: FAST_MODE_OFF, name: "Off" }, + ], + }; +} + +/** Resolve the requested Fast mode value from a `session/set_config_option` + * request. Accepts a native boolean (boolean-capable Clients) or the + * "on"/"off" select-fallback strings. */ +export function resolveFastModeEnabled(params: SetSessionConfigOptionRequest): boolean { + const value = params.value; + if (typeof value === "boolean") { + return value; + } + if (value === FAST_MODE_ON) { + return true; + } + if (value === FAST_MODE_OFF) { + return false; + } + throw RequestError.invalidParams( + undefined, + `Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`, + ); +} + +/** Per-model Fast mode state threaded into `buildConfigOptions`. The + * option is only surfaced when the current model `supported`s fast mode. */ +export type FastModeOptionState = { + supported: boolean; + enabled: boolean; + /** Whether the Client opted into boolean config options. */ + useBooleanOption: boolean; + /** Latest explainable `fast_mode_disabled_reason`, folded into the option's + * description while the toggle reads off. */ + disabledReason?: FastModeDisabledReason; +}; + +/** What a reported `fast_mode_state` changes in a Fast mode toggle that the + * current model shows. The SDK can flip fast mode independently of the + * user — e.g. back to `on` once a rate-limit `cooldown` clears — so + * definitive on/off changes are mirrored into the option. + * + * Undefined when nothing changes: an absent state, a `cooldown` (a transient + * suspension of an already-enabled fast mode: the toggle stays as-is rather + * than flapping, and a stray cooldown never enables a toggle the user has + * off), or the same state with the same explainable reason. + * + * `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the + * state. Only explainable reasons are retained (see + * {@link normalizeFastModeDisabledReason}), so a routine + * `sdk_opt_in_required` report on every turn's result can't churn the + * option, while a real blocker updates the description even when the + * toggle's own value is unchanged. + * + * `notice` is set when the user had Fast mode on and the SDK turned it off + * for an explainable reason: the description carries the same explanation, + * but a toggle silently snapping back is worth saying out loud once. */ +export function fastModeStateChange( + current: { enabled: boolean; disabledReason?: FastModeDisabledReason }, + state: FastModeState | undefined, + reason: FastModeDisabledReason | undefined, +): + | { enabled: boolean; disabledReason?: FastModeDisabledReason; notice?: FastModeOffNotice } + | undefined { + if (state === undefined || state === "cooldown") return undefined; + const enabled = state === "on"; + // A reason only describes an off state; drop any that rides an `on` report + // so it can't decorate the option the next time fast mode goes off. + const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason); + if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined; + const explanation = + disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined; + if (!current.enabled || enabled || explanation === undefined) { + return { enabled, disabledReason }; + } + return { + enabled, + disabledReason, + notice: { + notice: { + severity: "warning", + title: "Fast mode turned off", + description: `${sentenceCase(explanation)}.`, + }, + transcript: `**Fast mode turned off:** ${explanation}.`, + }, + }; +} + +/** The notice of Fast mode that the SDK turned off, and its transcript line + * for a client without `notice` updates. */ +export type FastModeOffNotice = { + notice: { severity: "warning"; title: string; description: string }; + transcript: string; +}; diff --git a/src/session-index/connection.ts b/src/session-index/connection.ts index 69b762aa..72e029e5 100644 --- a/src/session-index/connection.ts +++ b/src/session-index/connection.ts @@ -121,13 +121,11 @@ export class SessionIndexConnection { constructor(private readonly host: SessionIndexHost) {} - /** Sets the index up for the client of `initialize`. ACP v2 does not route - * the session index methods yet. */ - negotiate(request: InitializeRequest, options: { v2: boolean }): void { + /** Sets the index up for the client of `initialize`. */ + negotiate(request: InitializeRequest): void { this.service?.dispose(); this.service = undefined; if ( - !options.v2 && this.host.isAirClient() && clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY) ) { diff --git a/src/session-mode.ts b/src/session-mode.ts index 2004e97e..2eabaf35 100644 --- a/src/session-mode.ts +++ b/src/session-mode.ts @@ -405,7 +405,7 @@ export class SessionModeManager { } } -function buildAvailableModes( +export function buildAvailableModes( { allowBypass, allowAuto }: ModeAvailability, air = false, ): SessionModeState["availableModes"] { diff --git a/src/session-model.ts b/src/session-model.ts index 9df55fed..57de4198 100644 --- a/src/session-model.ts +++ b/src/session-model.ts @@ -84,39 +84,73 @@ function versionedModelDisplayName(model: ModelInfo): string { return `${withoutContext} ${version}${contextSuffix[0]}`; } -function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } { - const lower = model.trim().toLowerCase(); - const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase(); - const tokens = lower - .replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ") +function modelWords(value: string): string[] { + return value + .toLowerCase() .split(/[^a-z0-9]+/) - .filter(Boolean) - .map((token) => { - if (token === "opusplan") return "opus"; - if (token === "best" || token === "default") return ""; - return token; - }) - .filter((token) => token && token !== "claude") - .filter((token) => /[a-z]/.test(token) || token.endsWith("m")); - return { tokens, contextHint }; + .filter(Boolean); } -function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number { - const haystack = `${model.value} ${model.displayName}`.toLowerCase(); - let score = 0; - let nonHintMatched = false; - for (const token of tokens) { - if (!haystack.includes(token)) continue; - if (token !== contextHint) nonHintMatched = true; - score += token === contextHint ? 3 : 1; +/** Whether `needle`'s words appear in `haystack`'s words, adjacent and in + * order: "opus" in "claude-opus-4-5", but not "son" in "sonnet" or "a" in + * "Default (recommended)". */ +function containsWords(haystack: string, needle: string): boolean { + const words = modelWords(haystack); + const wanted = modelWords(needle); + if (wanted.length === 0) return false; + for (let start = 0; start + wanted.length <= words.length; start++) { + if (wanted.every((word, offset) => words[start + offset] === word)) return true; } - return contextHint && !nonHintMatched ? 0 : score; + return false; } +/** `id` without the gateway spellings that `normalizeModelId` keeps for + * `deniedModels` and gateway listings: path segments before `claude-` + * (`vertex_ai/`, `openrouter/anthropic/`, `publishers/anthropic/models/`) + * and the Anthropic API's `-latest` suffix (`claude-3-5-haiku-latest`). */ +function withoutGatewaySpelling(id: string): string { + return id.replace(/^(?:[^/]+\/)+(?=claude-)/, "").replace(/-latest$/, ""); +} + +/** The words of `preference` that name a model: without what + * `normalizeModelId` drops (provider prefixes, dates, `-v1:0`, `-fast`, + * context hints), version numbers, "claude", and "best"/"default". + * "us.anthropic.claude-sonnet-4-5-v1:0" gives ["sonnet"], "opusplan" gives + * ["opus"]. */ +function modelPreferenceTokens(preference: string): string[] { + return modelWords(withoutGatewaySpelling(normalizeModelId(preference))) + .map((token) => (token === "opusplan" ? "opus" : token)) + .filter((token) => /[a-z]/.test(token) && !["claude", "best", "default"].includes(token)); +} + +/** Whether every one of `tokens` is a word of `model`'s value or display name. */ +function modelCoversTokens(model: ModelInfo, tokens: string[]): boolean { + const words = new Set(modelWords(`${model.value} ${model.displayName}`)); + return tokens.length > 0 && tokens.every((token) => words.has(token)); +} + +/** + * The entry of `models` that `preference` names, or null when it names none. + * `best` names the entry `opus` resolves to, unless an entry's value is `best`. + * In order: an entry whose value, display name or `resolvedModel` equals it + * (in either context-hint spelling); an entry whose value or display name + * contains its words ("opus" for "claude-opus-4-5"), or whose value it + * contains while every model word of the preference is the entry's + * ("sonnet" for "us.anthropic.claude-sonnet-5-v1:0"); then any entry with all + * of its model words, preferring one with the same context hint ("sonnet[1m]" + * for "claude-sonnet-5-1m"). The fuzzy tiers need a compatible version, and + * words match whole: "not-a-model", "son" and "foo-sonnet" name no entry. + */ export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null { const trimmed = preference.trim(); if (!trimmed) return null; const lower = trimmed.toLowerCase(); + // The CLI resolves `best` to its flagship: Opus, unless a server-side flag + // names another family that this adapter cannot read. The CLI ignores a + // `[1m]` hint on `best`. A listed entry named `best` still matches directly. + if (stripContextHints(lower) === "best" && !models.some((model) => model.value === trimmed)) { + return resolveModelPreference(models, "opus"); + } const canonicalPreference = canonicalizeModelId(trimmed); const directMatch = models.find( (model) => @@ -134,28 +168,26 @@ export function resolveModelPreference(models: ModelInfo[], preference: string): if (resolvedMatch) return resolvedMatch; const preferenceHint = contextHintOf(trimmed); + const tokens = modelPreferenceTokens(trimmed); const includesMatch = models.find((model) => { if (!modelVersionsCompatible(trimmed, model)) return false; if (contextHintOf(model.value) !== preferenceHint) return false; - const value = model.value.toLowerCase(); - const display = model.displayName.toLowerCase(); - return value.includes(lower) || display.includes(lower) || lower.includes(value); + return ( + containsWords(model.value, trimmed) || + containsWords(model.displayName, trimmed) || + (containsWords(trimmed, model.value) && modelCoversTokens(model, tokens)) + ); }); if (includesMatch) return includesMatch; - const { tokens, contextHint } = tokenizeModelPreference(trimmed); - if (tokens.length === 0) return null; - let bestMatch: ModelInfo | null = null; - let bestScore = 0; - for (const model of models) { - if (!modelVersionsCompatible(trimmed, model)) continue; - const score = scoreModelMatch(model, tokens, contextHint); - if (score > 0 && (!bestMatch || score > bestScore)) { - bestMatch = model; - bestScore = score; - } - } - return bestMatch; + const candidates = models.filter( + (model) => modelVersionsCompatible(trimmed, model) && modelCoversTokens(model, tokens), + ); + return ( + candidates.find((model) => contextHintOf(model.value) === preferenceHint) ?? + candidates[0] ?? + null + ); } export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo { @@ -517,6 +549,61 @@ export function narrowToGatewayModels( : []; } +/** + * The capabilities of the session's current model, and the model infos that + * every later lookup reads. + * + * A resumed session can be running a model outside the `availableModels` + * allowlist (currentModelId is then the verbatim live id, see + * `matchResumedModel`); its capabilities are still known to the SDK's + * unfiltered list, so they are resolved from that before the model is treated + * as unknown — otherwise auto mode would be spuriously clamped and the + * Fast-mode/Effort options hidden for a model that supports them. + * + * The fallback-resolved capabilities are registered under the verbatim live + * id, so every modelInfos consumer (the effort option, later rebuilds) agrees + * with that gating. `buildModelConfigOption` lists this model as its own + * entry while it is current and drops it once the user switches to a listed + * model; it is never added to `availableModels`. The spread keeps every + * capability flag (current and future); the identity fields are overridden + * because the fuzzy-matched sibling's resolvedModel/displayName/description + * can describe a different context lane and would poison later resolvedModel + * matching (a model switch that the adapter did not make) and context-window + * inference if they traveled under this id. `allowlistedModelInfo` is the + * allowlisted entry alone, the only one whose identity strings are a + * trustworthy window signal. + */ +export function withCurrentModelInfo( + allowedModels: ModelInfo[], + sdkModels: ModelInfo[], + currentModelId: string, +): { + allowlistedModelInfo: ModelInfo | undefined; + currentModelInfo: ModelInfo | undefined; + modelInfos: ModelInfo[]; +} { + const allowlistedModelInfo = allowedModels.find((m) => m.value === currentModelId); + const fallbackModelInfo = allowlistedModelInfo + ? undefined + : (resolveModelPreference(sdkModels, currentModelId) ?? undefined); + return { + allowlistedModelInfo, + currentModelInfo: allowlistedModelInfo ?? fallbackModelInfo, + modelInfos: fallbackModelInfo + ? [ + ...allowedModels, + { + ...fallbackModelInfo, + value: currentModelId, + displayName: currentModelId, + description: "", + resolvedModel: undefined, + }, + ] + : allowedModels, + }; +} + /** Whether a rejected `setModel` was vetoed by a user-configured * PreModelSwitch hook. The CLI's rejection message is the stable, * distinguishable marker ("Model switch blocked by a PreModelSwitch hook: diff --git a/src/session-notices.ts b/src/session-notices.ts index 48b42d79..b8ee81f9 100644 --- a/src/session-notices.ts +++ b/src/session-notices.ts @@ -118,3 +118,98 @@ export function splitNoticeText( } return rest ? { title: firstLine, description: rest } : { title: firstLine }; } + +/** The notice of Claude Code's `informational` message (e.g. why a Stop hook + * blocked continuation): a warning at level `warning`, else info. */ +export function informationalNotice(message: { level: string; content: string }): SessionNotice { + const severity = message.level === "warning" ? "warning" : "info"; + return { + severity, + ...splitNoticeText( + message.content, + severity === "warning" ? "Claude reported a warning" : "Claude reported a notice", + ), + }; +} + +/** The notice of a client MCP server that the `system/init` lists as not + * started: `failed`, or `needs-auth` when the client cannot run the OAuth + * flow (`oauthAvailable`). Undefined for any other status. */ +export function mcpServerStartupNotice( + server: { name: string; status: string }, + oauthAvailable: boolean, +): SessionNotice | undefined { + if (server.status === "failed") { + return { + severity: "warning", + title: `MCP server "${server.name}" failed to start`, + description: "Its tools are unavailable in this session. Run /mcp for the error.", + }; + } + if (server.status === "needs-auth" && !oauthAvailable) { + return { + severity: "info", + title: `MCP server "${server.name}" needs authentication`, + description: "Its tools are unavailable until the server is authenticated.", + }; + } + return undefined; +} + +/** + * What Claude Code's `model_refusal_fallback` tells the user: the SDK retried + * a refused turn on the fallback model. + * + * Current CLIs only emit direction "retry", a swap that lasts for the + * session. "revert"/"sticky" are retained in the SDK enum for older CLIs, + * where "revert" marked a turn-only fallback: the session stays on the + * original model. `scope` (CLI 2.1.232+) "local" means a subagent, + * side-question or background fork response fell back and the session model + * is unchanged. Absent scope means an older CLI, where every retry was a + * session-level swap. `persistent` is whether the session now runs + * `fallback_model`, so its model option must follow. + */ +export function modelRefusalFallback(message: { + original_model: string; + fallback_model: string; + direction?: string; + scope?: string; + api_refusal_category?: string | null; + api_refusal_explanation?: string | null; +}): { + persistent: boolean; + /** The one-line summary. */ + summary: string; + explanation: string | undefined; + /** The notice: the summary as its title when it is short enough, else a + * generic title with everything in the description. */ + notice: SessionNotice; + /** The transcript line for a client without `notice` updates. */ + transcript: string; +} { + const local = message.scope === "local"; + const persistent = message.direction !== "revert" && !local; + const category = message.api_refusal_category ? ` (${message.api_refusal_category})` : ""; + const outcome = persistent + ? `The session will continue on ${message.fallback_model}.` + : local + ? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.` + : `The session stays on ${message.original_model}.`; + const summary = + `${message.original_model} declined this request${category}; ` + + `retried with ${message.fallback_model}. ${outcome}`; + const explanation = message.api_refusal_explanation || undefined; + const full = explanation ? `${summary}\n\n${explanation}` : summary; + return { + persistent, + summary, + explanation, + notice: { + severity: "warning", + ...(summary.length > MAX_NOTICE_TITLE_LENGTH + ? { title: "Model fallback", description: full } + : { title: summary, ...(explanation ? { description: explanation } : {}) }), + }, + transcript: `**Model fallback:** ${full}`, + }; +} diff --git a/src/session-titles.ts b/src/session-titles.ts index 8fbe4e51..e6266776 100644 --- a/src/session-titles.ts +++ b/src/session-titles.ts @@ -11,19 +11,42 @@ * `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or * `CLAUDE_CODE_DISABLE_TERMINAL_TITLE` set), the same request generates it. * - * {@link SessionTitles} holds the per-session state and is owned by `Session`. - * `acp-agent.ts` drives it from five places: {@link SessionTitles.onPrompt} and - * {@link SessionTitles.onAssistantText} to collect text, - * {@link SessionTitles.onPromptQueued} once a prompt is sent to the CLI, - * {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, and - * {@link SessionTitles.reset} on `conversation_reset`. + * {@link SessionTitles} holds the per-session state and is owned by the + * session, in the v1 core (`acp-agent.ts`) and the v2 core + * (`v2/session.ts`). Each drives it from the same places: + * {@link SessionTitles.onPrompt} and {@link SessionTitles.onAssistantText} to + * collect text, {@link SessionTitles.onPromptQueued} once a prompt is sent to + * the CLI, {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, + * and, in v1, {@link SessionTitles.reset} on `conversation_reset`. */ -import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk/unstable"; +import type { + ContentBlock, + PromptRequest, + SessionNotification, +} from "@agentclientprotocol/sdk/unstable"; import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk"; -import type { ClaudeAcpAgent, Session } from "./acp-agent.js"; import { ExplicitTitle } from "./session-index/explicit-title.js"; +/** What titles read of their session. */ +export interface TitledSession { + readonly query: Query; + readonly queryClosed?: boolean; + /** Whether a cancel stopped the session's work: no title is generated then. */ + readonly cancelled?: boolean; + readonly cwd: string; +} + +/** What titles need of the core that serves the session (`ClaudeAcpAgent` in v1). */ +export interface TitleOwner { + readonly client: { sessionUpdate(notification: SessionNotification): Promise }; + readonly logger: { error: (...args: any[]) => void }; + /** The live sessions by id: a session replaced or closed meanwhile adopts no title. */ + readonly sessions: Readonly>; + /** The title as the client shows it, such as with an archive prefix removed. */ + clientTitle?(title: string): string; +} + const MAX_TITLE_LENGTH = 256; /** How much session text the title is generated from. The CLI caps its own @@ -137,7 +160,7 @@ export class SessionTitles { } constructor( - private readonly agent: ClaudeAcpAgent, + private readonly agent: TitleOwner, private readonly sessionId: string, ) {} @@ -158,7 +181,7 @@ export class SessionTitles { * prompt alone is the description, because the CLI titles from the prompt * alone. When this yields no title, the stored summary is published and * the next prompt tries again. */ - onPromptQueued(session: Session, prompt: PromptRequest["prompt"]): void { + onPromptQueued(session: TitledSession, prompt: PromptRequest["prompt"]): void { this.askedAtPrompt = false; // `session.cancelled` is not checked: after a cancelled turn it stays set // until the next turn starts, which is after this prompt is queued. @@ -209,7 +232,7 @@ export class SessionTitles { * turn-end must not wait on it. That covers a prompt too short to title * from, such as "hi". Only when that is not possible do we fall back to * `summary`, which for an SDK-driven session is just the raw first prompt. */ - async onTurnEnd(session: Session): Promise { + async onTurnEnd(session: TitledSession): Promise { if (this.explicit.settledByClient) { // No title is generated over the one the client set, but a custom title // stored since by someone else (a `/rename`) is adopted, as before. @@ -301,7 +324,7 @@ export class SessionTitles { * have it. Both come from the session file, as `session/list` reports * them; a session with no file yet sends the last title published, if any, * without `updatedAt`. */ - async publishCurrent(session: Session): Promise { + async publishCurrent(session: TitledSession): Promise { const info = await this.readSessionInfo(session); // A title published but not yet stored (a generated title still being // written) wins over `summary`, which is then only the raw first prompt. @@ -329,7 +352,7 @@ export class SessionTitles { /** Read the SDK's stored info for this session. A missing session file or read * error is non-fatal: the title is best-effort and another turn will retry. */ - private async readSessionInfo(session: Session): Promise { + private async readSessionInfo(session: TitledSession): Promise { try { return await getSessionInfo(this.sessionId, { dir: session.cwd }); } catch (error) { @@ -357,7 +380,7 @@ export class SessionTitles { /** Whether to ask the SDK for a generated title now: once per session, on a * live query, with enough collected text for the generator to work with. */ - private canRequest(session: Session): boolean { + private canRequest(session: TitledSession): boolean { if (this.settled || session.queryClosed || session.cancelled) { return false; } @@ -383,7 +406,7 @@ export class SessionTitles { * session's title, or a `/rename`), else one generated from `description`. * The file is read first because the CLI checks only the titles it holds in * memory before generating one. */ - private async titleFromPrompt(session: Session, description: string): Promise { + private async titleFromPrompt(session: TitledSession, description: string): Promise { const info = await this.readSessionInfo(session); if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) { return; @@ -405,7 +428,7 @@ export class SessionTitles { * prompt or turn end retries, and publishes `fallback` (the stored summary) * so a backend that can't generate titles is no worse off than before. */ private async requestGenerateTitle( - session: Session, + session: TitledSession, description: string, fallback: TitleFallback | undefined, ): Promise { diff --git a/src/session-usage.ts b/src/session-usage.ts new file mode 100644 index 00000000..9217715d --- /dev/null +++ b/src/session-usage.ts @@ -0,0 +1,259 @@ +/** + * Token usage as Claude Code reports it: the context window figures of + * `usage_update` and the `_meta.quota` of a turn. Shared by the v1 core and + * the v2 core. + */ +import type { ModelUsage } from "@anthropic-ai/claude-agent-sdk"; + +export type AccumulatedUsage = { + inputTokens: number; + outputTokens: number; + cachedReadTokens: number; + cachedWriteTokens: number; +}; + +/** Per-model token tallies keyed by the model id the SDK reported them under + * (its resolved spelling, e.g. "claude-opus-5[1m]"). */ +export type ModelTokenTally = Record; + +export type UsageSnapshot = { + input_tokens: number; + output_tokens: number; + cache_read_input_tokens: number; + cache_creation_input_tokens: number; +}; + +export const ZERO_USAGE = Object.freeze({ + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, +}); + +export const DEFAULT_CONTEXT_WINDOW = 200000; + +/** `_meta.quota` for work that spent `usage` in its main agent loop and + * `modelUsage` by model (see `turnQuotaMeta` in `acp-agent.ts`). */ +export function quotaMeta(usage: AccumulatedUsage, modelUsage: ModelTokenTally | undefined) { + return { + quota: { + token_count: quotaTokenCount(usage), + model_usage: Object.entries(modelUsage ?? {}).map(([model, usage]) => ({ + model, + token_count: quotaTokenCount(usage), + })), + }, + }; +} + +/** One `token_count` object. `cachedInputTokens` is cache reads, matching + * codex's field; Claude also reports cache writes, which codex has no slot + * for, so those ride along in an extra sibling (the same name the ACP `usage` + * field already uses) and are counted in `totalTokens`. `reasoningOutputTokens` + * is always 0: Claude bills thinking inside its output tokens and never breaks + * it out — the key is kept so the shape stays uniform across agents. */ +function quotaTokenCount(usage: AccumulatedUsage) { + return { + totalTokens: tallyTotal(usage), + inputTokens: usage.inputTokens, + cachedInputTokens: usage.cachedReadTokens, + cachedWriteTokens: usage.cachedWriteTokens, + outputTokens: usage.outputTokens, + reasoningOutputTokens: 0, + }; +} + +/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes + * the cache counters, so this is not double-counting (see `totalTokens`). */ +export function tallyTotal(usage: AccumulatedUsage): number { + return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens; +} + +/** Project `result.modelUsage` into our tally shape. Counters are coerced the + * way `snapshotFromUsage` coerces the stream's: third-party backends have been + * observed omitting fields, and a missing or NaN counter must not reach the + * wire as NaN (which `JSON.stringify` writes as `null`). */ +export function normalizeModelUsage( + modelUsage: Record | undefined, +): ModelTokenTally { + const tally: ModelTokenTally = {}; + for (const [model, usage] of Object.entries(modelUsage ?? {})) { + tally[model] = { + inputTokens: finiteCount(usage?.inputTokens), + outputTokens: finiteCount(usage?.outputTokens), + cachedReadTokens: finiteCount(usage?.cacheReadInputTokens), + cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens), + }; + } + return tally; +} + +function finiteCount(value: number | null | undefined): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0; +} + +/** `current - previous` per model, dropping models with nothing to report so a + * turn only lists the models it actually ran on. A reading that fell BELOW the + * previous one means the running total restarted under us (a mid-session + * /clear, a zeroed crash result): there is no usable reference left to + * subtract, so the reading itself is the increment. */ +export function modelUsageIncrement( + current: ModelTokenTally, + previous: ModelTokenTally, +): ModelTokenTally { + const increment: ModelTokenTally = {}; + for (const [model, usage] of Object.entries(current)) { + const base = previous[model]; + const subtracted: AccumulatedUsage = base + ? { + inputTokens: usage.inputTokens - base.inputTokens, + outputTokens: usage.outputTokens - base.outputTokens, + cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens, + cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens, + } + : usage; + const rewound = Object.values(subtracted).some((count) => count < 0); + const resolved = rewound ? usage : subtracted; + if (tallyTotal(resolved) > 0) { + increment[model] = resolved; + } + } + return increment; +} + +/** The per-model rows for the first result of a resumed session. Its + * `modelUsage` reading continues from the totals the transcript saved (SDK + * 0.3.277+; older transcripts may hold none), so there is no earlier reading + * to subtract from, and taking the reading itself would charge the whole + * pre-resume history to this one turn. The result's own `usage` is per-turn + * (main agent loop only, like the response's `usage`), so it stands in under + * the turn's top-level model — `key` is that model's `modelUsage` spelling + * when the reading has one, else the assistant message's own. Any subagent + * spend on this one turn goes unlisted rather than over-listed. */ +export function resumedFirstResultModelUsage( + usage: { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; + }, + key: string | null, +): ModelTokenTally { + if (key === null) { + return {}; + } + const row: AccumulatedUsage = { + inputTokens: finiteCount(usage.input_tokens), + outputTokens: finiteCount(usage.output_tokens), + cachedReadTokens: finiteCount(usage.cache_read_input_tokens), + cachedWriteTokens: finiteCount(usage.cache_creation_input_tokens), + }; + return tallyTotal(row) > 0 ? { [key]: row } : {}; +} + +/** Fold `increment` into `base` per model — the per-model counterpart of the + * `+=` the turn's flat accumulator uses. */ +export function addModelUsage(base: ModelTokenTally, increment: ModelTokenTally): ModelTokenTally { + const merged: ModelTokenTally = { ...base }; + for (const [model, usage] of Object.entries(increment)) { + const existing = merged[model]; + merged[model] = existing + ? { + inputTokens: existing.inputTokens + usage.inputTokens, + outputTokens: existing.outputTokens + usage.outputTokens, + cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens, + cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens, + } + : usage; + } + return merged; +} + +/** Sum all four fields as a proxy for post-turn context occupancy: the current + * turn's output becomes next turn's input. Per the Anthropic API, input_tokens + * excludes cache tokens — cache_read and cache_creation are reported + * separately — so summing all four is not double-counting. */ +export function totalTokens(usage: UsageSnapshot): number { + return ( + usage.input_tokens + + usage.output_tokens + + usage.cache_read_input_tokens + + usage.cache_creation_input_tokens + ); +} + +/** Project a nullable API usage object into our non-null snapshot shape. + * Both SDK message_start and assistant message `usage` have `number | null` + * cache fields; we coerce absent values to 0 so `totalTokens` never hits + * NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but + * synthetic or third-party-backend stream events have been observed emitting + * them as null/undefined — coerce those too so a malformed upstream event + * can't leak NaN into the wire `used` field. Delta events have different + * semantics (cumulative + prev fallback) and are handled inline. */ +export function snapshotFromUsage(usage: { + input_tokens?: number | null; + output_tokens?: number | null; + cache_read_input_tokens?: number | null; + cache_creation_input_tokens?: number | null; +}): UsageSnapshot { + return { + input_tokens: usage.input_tokens ?? 0, + output_tokens: usage.output_tokens ?? 0, + cache_read_input_tokens: usage.cache_read_input_tokens ?? 0, + cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0, + }; +} + +function commonPrefixLength(a: string, b: string) { + let i = 0; + while (i < a.length && i < b.length && a[i] === b[i]) { + i++; + } + return i; +} + +/** Best-effort first guess of a model's context window, used to seed the + * window synchronously (via `immediateContextWindow`) until a `result` message + * arrives with the authoritative `modelUsage` value. + * + * Anthropic 1M-context variants encode "1m" as a distinct token in the SDK + * model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also + * matching things like "10m" or embedded substrings. Semantic aliases like + * `default` carry no such token in the ID, but their `resolvedModel` and the + * SDK's human-facing `displayName`/`description` can (e.g. + * "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too. + * This text scan can't catch every model — some resolve to extended-context + * models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively + * ~1M). Such a miss falls back to the default window, is refined by the + * background `getContextUsage` (`refreshContextWindowInBackground`), and is + * corrected by `result.modelUsage` (and cached) within one turn. */ +export function inferContextWindowFromModel(...texts: Array): number | null { + if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000; + return null; +} + +export function getMatchingModelUsage( + modelUsage: Record, + currentModel: string, +) { + let bestKey: string | null = null; + let bestLen = 0; + + for (const key of Object.keys(modelUsage)) { + const len = commonPrefixLength(key, currentModel); + if (len > bestLen) { + bestLen = len; + bestKey = key; + } + } + + if (bestKey) { + // `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"), + // the same spelling as ModelInfo.resolvedModel — the primary key the + // window is cached under. `currentModel` (the assistant message's + // `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result + // handler caches under that spelling too, for seed-time reads that fall + // back to a bare id (rows without `resolvedModel`). + return { key: bestKey, usage: modelUsage[bestKey] }; + } +} diff --git a/src/streamed-text.ts b/src/streamed-text.ts new file mode 100644 index 00000000..aed3fa33 --- /dev/null +++ b/src/streamed-text.ts @@ -0,0 +1,86 @@ +/** + * The text and thinking of an assistant message that streamed live as + * deltas, and what its consolidated message adds to them. Shared by the v1 + * core and the v2 core. + */ +import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk"; + +/** Text or thinking that streamed live as deltas, accumulated per block. */ +export type StreamedBlock = { index: number; type: "text" | "thinking"; text: string }; + +/** + * Records the text or thinking that `event` streamed in `streamedBlocks`. + * Contiguous deltas of the same block (same index and type) extend the + * current entry; anything else opens a new one. Other events change nothing. + */ +export function recordStreamedDelta( + streamedBlocks: StreamedBlock[], + event: SDKPartialAssistantMessage["event"], +): void { + if (event.type !== "content_block_delta") return; + const delta = event.delta; + const chunk = + delta.type === "text_delta" + ? { type: "text" as const, text: delta.text } + : delta.type === "thinking_delta" + ? { type: "thinking" as const, text: delta.thinking } + : undefined; + // Skip empty deltas (some gateways emit empty thinking chunks — #793): + // appending "" is a no-op, but pushing a "" entry would create a block the + // `text.length > 0` guard of `unstreamedRemainder` can never consume, + // stalling the diff cursor and re-emitting the next block as a duplicate. + if (!chunk?.text) return; + const last = streamedBlocks[streamedBlocks.length - 1]; + if (last && last.index === event.index && last.type === chunk.type) { + last.text += chunk.text; + } else { + streamedBlocks.push({ index: event.index, type: chunk.type, text: chunk.text }); + } +} + +/** + * The blocks of a consolidated assistant message without the text that + * already streamed as deltas. + * + * Each assembled text/thinking block is diffed against the streamed blocks in + * document order: nothing is left if it streamed in full (the common case), + * the whole block if it never streamed (a non-streaming gateway), and just the + * tail if the stream was cut short mid-block. Matching on content rather than + * the message id keeps the dedupe robust for gateways without a stable id. + * Tool-use and other blocks pass through untouched. + */ +export function unstreamedRemainder( + blocks: Block[], + streamedBlocks: StreamedBlock[], +): Block[] { + const kept: Block[] = []; + let streamPos = 0; + for (const item of blocks) { + if (item.type !== "text" && item.type !== "thinking") { + kept.push(item); + continue; + } + const block = item as Block & { text?: string; thinking?: string }; + const full = (item.type === "text" ? block.text : block.thinking) ?? ""; + // Empty assembled blocks carry nothing: drop them. + if (full.length === 0) continue; + // A streamed block of the same type whose text is a prefix of this one + // was already delivered, at least partly. A non-empty streamed text is + // required so an empty or aborted streamed block does not swallow it. + const streamed = streamedBlocks[streamPos]; + if ( + streamed && + streamed.type === item.type && + streamed.text.length > 0 && + full.startsWith(streamed.text) + ) { + streamPos++; + const remainder = full.slice(streamed.text.length); + if (remainder.length === 0) continue; + kept.push({ ...item, [item.type === "text" ? "text" : "thinking"]: remainder }); + continue; + } + kept.push(item); + } + return kept; +} diff --git a/src/subagent-configuration.ts b/src/subagent-configuration.ts new file mode 100644 index 00000000..9d793b2a --- /dev/null +++ b/src/subagent-configuration.ts @@ -0,0 +1,96 @@ +/** + * The configuration of a subagent's session, as its hooks report it. Both + * the v1 core (`ClaudeAcpAgent.subagentConfigurationHook`, for both forms of + * subagent sessions) and the v2 core (`v2/child-config.ts`) + * build a child's config options with these. + */ +import type { SessionConfigOption, SessionModeState } from "@agentclientprotocol/sdk/unstable"; +import type { AgentInfo, HookInput } from "@anthropic-ai/claude-agent-sdk"; +import { childModelOption, responseModelEntry, type SubagentModels } from "./native-subagents.js"; +import { AGENT_CONFIG_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js"; +import { effortLevelName } from "./session-effort.js"; +import { SessionModeManager } from "./session-mode.js"; + +/** What the root session knows that a child's options name. */ +export type SubagentConfigurationContext = { + /** The root session's modes, for the name of the child's mode. */ + availableModes: SessionModeState["availableModes"]; + /** The root session's `model` entries, for the child's model. */ + models: SubagentModels | undefined; + /** The custom agents of the session, for the description of the child's agent type. */ + agents: readonly AgentInfo[]; +}; + +/** + * The config options that a hook of a subagent (`agent_id`) reports of it: + * its permission mode, its model after a `PostModelSwitch`, its effort, and + * its agent type. Each option lists only the child's current value. Empty + * for a hook of the root session, and for one that reports none of these. + */ +export function subagentHookOptions( + input: HookInput, + context: SubagentConfigurationContext, +): SessionConfigOption[] { + if (!input.agent_id) return []; + const options: SessionConfigOption[] = []; + if (input.permission_mode) { + const mode = context.availableModes.find((mode) => mode.id === input.permission_mode); + options.push( + SessionModeManager.configOption({ + currentModeId: input.permission_mode, + availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }], + }), + ); + } + const switchedModel = + input.hook_event_name === "PostModelSwitch" + ? responseModelEntry(input.to_model, context.models) + : undefined; + // The entry of the root session's `model` option, as for a child response. + if (switchedModel) options.push(childModelOption(switchedModel)); + if (input.effort?.level) { + options.push({ + id: EFFORT_CONFIG_ID, + name: "Effort", + description: "Current reasoning effort", + category: "thought_level", + type: "select", + currentValue: input.effort.level, + options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }], + }); + } + if (input.agent_type) { + const agent = context.agents.find((agent) => agent.name === input.agent_type); + options.push({ + id: AGENT_CONFIG_ID, + name: "Agent", + description: "Subagent type", + type: "select", + currentValue: input.agent_type, + options: [ + { + value: input.agent_type, + name: input.agent_type, + ...(agent?.description ? { description: agent.description } : {}), + }, + ], + }); + } + return options; +} + +/** + * A child's configuration, as it goes to the client: every option it holds, + * the mode first, then the model, the effort and the model config, then the + * rest in the order they came. + */ +export function orderedSubagentConfiguration( + options: Iterable, +): SessionConfigOption[] { + const priority = ["mode", "model", "thought_level", "model_config"]; + const rank = (option: SessionConfigOption) => { + const index = priority.indexOf(option.category ?? ""); + return index < 0 ? priority.length : index; + }; + return [...options].sort((left, right) => rank(left) - rank(right)); +} diff --git a/src/subagent-replay.ts b/src/subagent-replay.ts index 92119658..91437ecd 100644 --- a/src/subagent-replay.ts +++ b/src/subagent-replay.ts @@ -376,17 +376,7 @@ export class RfdSubagentReplay { } const missing = this.omitted.size + this.withoutHistory; if (missing > 0 && supportsNotices) { - await this.publish({ - sessionId: rootSessionId, - update: noticeUpdate({ - severity: "info", - title: "Some subagent history is not available", - description: - missing === 1 - ? "The history of 1 subagent of this session could not be restored." - : `The history of ${missing} subagents of this session could not be restored.`, - }), - }); + await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) }); } } @@ -498,12 +488,10 @@ export class RfdSubagentReplay { message: SessionMessage, text: string, ): Promise { - const names = [child.sessionId, child.record.name, child.input.name]; - const call = this.sendMessages.find( - (candidate) => - candidate.recipientSessionId === undefined && - names.includes(candidate.to) && - text.includes(candidate.message.trim()), + const call = deliveredCall( + this.sendMessages, + [child.sessionId, child.record.name, child.input.name], + text, ); if (call) { call.recipientSessionId = child.sessionId; @@ -619,6 +607,38 @@ function isDirectory(dir: string): boolean { } /** A message that a SendMessage call delivered to the child. */ -function isDelivery(message: SessionMessage): boolean { +export function isDelivery(message: SessionMessage): boolean { return (message as { origin?: { kind?: unknown } | null }).origin?.kind === "coordinator"; } + +/** + * The SendMessage call of `calls` that a delivered message `text` came from, + * for the child that `names` names (its agent id, its name). Claude Code + * frames the message for the child's model ("The coordinator sent a message + * while you were working:", then the message, then an instruction), so it is + * the first undelivered call to the child whose message the text contains. + */ +export function deliveredCall< + T extends { to: string; message: string; recipientSessionId?: string }, +>(calls: readonly T[], names: readonly unknown[], text: string): T | undefined { + return calls.find( + (call) => + call.recipientSessionId === undefined && + names.includes(call.to) && + text.includes(call.message.trim()), + ); +} + +/** The notice that the history of `missing` subagents of a replayed session is not available. */ +export function missingHistoryNotice( + missing: number, +): Extract { + return noticeUpdate({ + severity: "info", + title: "Some subagent history is not available", + description: + missing === 1 + ? "The history of 1 subagent of this session could not be restored." + : `The history of ${missing} subagents of this session could not be restored.`, + }) as Extract; +} diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 9f5db43b..74833eea 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -67,7 +67,6 @@ import { getSessionInfo, getSessionMessages, getSubagentMessages, - type HookCallback, importSessionToStore, listSessions, type ModelInfo, @@ -5297,7 +5296,7 @@ describe("permission request cancellation", () => { toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork(sessionId, false), + background: new SessionBackgroundWork(sessionId), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), @@ -5367,14 +5366,14 @@ describe("permission request cancellation", () => { }); describe("a cancelled outcome", () => { - function setup(options: { v2?: boolean } = {}) { + function setup() { const mockClient = { sessionUpdate: async () => {}, requestPermission: async (): Promise => ({ outcome: { outcome: "cancelled" }, }), } as unknown as AcpClient; - const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }, options); + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); const session = injectSession(agent, "session-1"); session.emittedToolCalls.add("tool-1"); const ask = (agentID?: string) => @@ -5396,29 +5395,6 @@ describe("permission request cancellation", () => { await expect(ask("agent-fg")).rejects.toThrow("Tool use aborted"); }); - it("denies the tool use of foreground work on v2 without a session/cancel", async () => { - // On v2 the client also answers `cancelled` when the user dismissed the - // prompt or its policy declined it. The turn goes on without the tool. - const { session, ask } = setup({ v2: true }); - session.liveBackgroundTasks.set("agent-fg", { isSubagent: true }); - - for (const agentID of [undefined, "agent-fg"]) { - await expect(ask(agentID)).resolves.toEqual({ - behavior: "deny", - message: "User refused permission to run tool", - toolUseID: "tool-1", - decisionClassification: "user_reject", - }); - } - }); - - it("aborts the tool use of foreground work on v2 during a session/cancel", async () => { - const { session, ask } = setup({ v2: true }); - session.cancelled = true; - - await expect(ask()).rejects.toThrow("Tool use aborted"); - }); - it("denies the tool use of a background subagent", async () => { const { session, ask } = setup(); session.liveBackgroundTasks.set("agent-bg", { isSubagent: true, backgrounded: true }); @@ -7610,76 +7586,6 @@ describe("subagent permission attribution (issue #851)", () => { }; } - it("releases a tool call that ends cancelled on v2, as one that ends failed", async () => { - const updates: AcpSessionNotification[] = []; - const agent = new ClaudeAcpAgent( - { - sessionUpdate: async (update: AcpSessionNotification) => { - updates.push(update); - }, - } as unknown as AcpClient, - { log: () => {}, error: () => {} }, - { v2: true }, - ); - await agent.initialize({ protocolVersion: 1, clientCapabilities: {} }); - injectGeneratorSession( - agent, - makeGenerator([ - { - type: "assistant", - uuid: randomUUID(), - session_id: "test-session", - parent_tool_use_id: null, - message: { - id: "msg-bash", - model: "claude-sonnet-4-5", - role: "assistant", - type: "message", - stop_reason: "tool_use", - content: [ - { type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "make" } }, - ], - usage: { input_tokens: 0, output_tokens: 0 }, - }, - }, - { - type: "user", - uuid: randomUUID(), - session_id: "test-session", - parent_tool_use_id: null, - // The user interrupted the turn while the command ran. - tool_result_meta: [{ id: "toolu_bash", non_execution_kind: "interrupted" }], - message: { - role: "user", - content: [ - { - type: "tool_result", - tool_use_id: "toolu_bash", - content: "[Request interrupted by user for tool use]", - is_error: true, - }, - ], - }, - }, - successResult(), - ]), - ); - - await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] }); - - expect( - updates - .map(({ update }) => update) - .filter( - (update) => - update.sessionUpdate === "tool_call_update" && "status" in update && update.status, - ) - .at(-1), - ).toMatchObject({ toolCallId: "toolu_bash", status: "cancelled" }); - // The agent stops tracking the call once it ends, whichever way. - expect(agent.sessions["test-session"]?.dispatchedToolCalls?.has("toolu_bash")).toBe(false); - }); - it("records task_started's task_id → tool_use_id mapping while consuming the stream", async () => { const agent = new ClaudeAcpAgent( { sessionUpdate: vi.fn(async () => {}) } as unknown as AcpClient, @@ -15096,7 +15002,7 @@ describe("session/close", () => { toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork(sessionId, false), + background: new SessionBackgroundWork(sessionId), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), @@ -15220,7 +15126,7 @@ describe("session/delete", () => { toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork(sessionId, false), + background: new SessionBackgroundWork(sessionId), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), @@ -15385,7 +15291,7 @@ describe("getOrCreateSession param change detection", () => { toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork(sessionId, false), + background: new SessionBackgroundWork(sessionId), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), @@ -19923,7 +19829,7 @@ describe("post-error recovery", () => { toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork("test-session", false), + background: new SessionBackgroundWork("test-session"), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), @@ -21919,23 +21825,6 @@ describe("output after a cancel", () => { toolUseID: id, } as any); /** The v2 PreToolUse preview hook for the Write call `id`, as Claude Code calls it. */ - const previewWrite = (agent: ClaudeAcpAgent, id: string) => - ( - agent as unknown as { fileChangePreviewHook(sessionId: string): HookCallback } - ).fileChangePreviewHook("test-session")( - { - hook_event_name: "PreToolUse", - tool_name: "Write", - tool_input: writeInput, - tool_use_id: id, - session_id: "test-session", - transcript_path: "", - cwd: "/workspace/proj", - } as any, - id, - { signal: new AbortController().signal }, - ); - /** Sends the prompt `text` and waits until its turn is active. Returns the * prompt and the session's consumer, which ends with the scripted stream. */ async function promptActive(agent: ClaudeAcpAgent, text: string) { @@ -22084,7 +21973,6 @@ describe("output after a cancel", () => { async (_when, order, streamed) => { const cancelled = Promise.withResolvers(); let asked: Promise | undefined; - let previewed: Promise | undefined; const { agent, texts, updates, permissionRequests } = setup((input) => (async function* () { const iter = input[Symbol.asyncIterator](); @@ -22097,14 +21985,12 @@ describe("output after a cancel", () => { yield abortedResult(first.uuid); yield lifecycleFrame(first.uuid, "cancelled"); // Claude Code had already taken the second prompt, so it still runs - // it, and its answer writes a file. It calls the preview hook and - // asks for the permission on its control channel, which can run - // ahead of the consumer. + // it, and its answer writes a file. It asks for the permission on + // its control channel, which can run ahead of the consumer. yield lifecycleFrame(second.uuid, "started"); yield requesting(second.uuid); yield userEcho(second); const ask = () => { - previewed = previewWrite(agent, "toolu_late"); asked = askToWrite(agent, "toolu_late"); }; if (order === "before") ask(); @@ -22113,13 +21999,13 @@ describe("output after a cancel", () => { // Claude Code can run a tool whose input is complete before it // sends the whole message. ask(); - await Promise.all([previewed, asked]); + await asked; } yield* streamed ? toolUseMessage("toolu_late", "Write", writeInput) : [toolUseMessage("toolu_late", "Write", writeInput)[0]]; if (order === "after") ask(); - await Promise.all([previewed, asked]); + await asked; yield toolResult("toolu_late", "The user cancelled this prompt.", true); yield stampedResult(second.uuid); yield lifecycleFrame(second.uuid, "completed"); @@ -23184,67 +23070,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)", await agent.sessions["test-session"]?.consumer; }); - it("resumes a held turn's foreground work for a steer, and ends it again at the steered result", async () => { - const foreground: string[] = []; - const agent = new ClaudeAcpAgent( - { sessionUpdate: async () => {} } as unknown as AcpClient, - { log: () => {}, error: () => {} }, - { - foreground: () => ({ - running: () => foreground.push("running"), - awaitingUser: () => foreground.push("awaitingUser"), - ended: (outcome) => - foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`), - failed: () => foreground.push("failed"), - }), - }, - ); - injectGeneratorSession(agent, (input) => { - async function* messageGenerator() { - const iter = input[Symbol.asyncIterator](); - yield userEcho((await iter.next()).value); - yield running(); - yield subagentStarted("agent-1"); - yield resultMessage(); // held for agent-1 - yield idle(); - // The steer runs as a cycle of the held turn. - yield userEcho((await iter.next()).value); - yield assistantText("Steered answer"); - yield resultMessage(); - // The steered turn settles at an idle, held again for agent-1. - yield idle(); - yield taskNotification("agent-1"); - yield assistantText("promised summary"); - yield resultMessage({ origin: { kind: "task-notification" } }); - yield idle(); - } - return messageGenerator(); - }); - - const turn = agent.prompt({ - sessionId: "test-session", - prompt: [{ type: "text", text: "explore" }], - }); - await vi.waitFor(() => - expect(agent.sessions["test-session"]?.activeTurn?.deferredSettle).toBeDefined(), - ); - await agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also X" }] }); - - expect((await turn).stopReason).toBe("end_turn"); - await agent.sessions["test-session"]?.consumer; - expect(foreground).toEqual([ - "running", - "ended end_turn 15", - // The steer resumes the turn's work, which ends at the steered result - // with the turn's usage, both of its cycles. - "running", - "ended end_turn 30", - // The follow-up after the subagent finished is a cycle of its own. - "running", - "ended end_turn 15", - ]); - }); - describe("ExitPlanMode answered inside a held turn's followup (issue #1167)", () => { // Plan mode + a background Explore agent: the user turn's result holds, // the subagent finishes, and the model writes the plan and calls @@ -23445,98 +23270,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)", ]); }); - it("keeps the turn as the session's foreground work across the clear-context restart", async () => { - const foreground: string[] = []; - const agent = new ClaudeAcpAgent( - { - sessionUpdate: async () => {}, - requestPermission: async (request: any) => ({ - outcome: { - outcome: "selected", - optionId: request.options.find((option: any) => option.kind === "allow_once") - .optionId, - }, - }), - } as unknown as AcpClient, - { log: () => {}, error: () => {} }, - { - foreground: () => ({ - running: () => foreground.push("running"), - awaitingUser: () => foreground.push("awaitingUser"), - ended: (outcome) => - foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`), - failed: () => foreground.push("failed"), - }), - }, - ); - vi.spyOn(agent as any, "createSession").mockImplementation( - async (_params: any, options: any) => { - const input = new Pushable(); - async function* freshGenerator() { - const next = await input[Symbol.asyncIterator]().next(); - yield userEcho(next.value); - yield running(); - // The continuation asks for permission in the fresh query. - await agent.canUseTool("test-session")( - "Bash", - { command: "ls" }, - { - signal: new AbortController().signal, - suggestions: [], - toolUseID: "toolu_ls", - requestId: "request-ls", - }, - ); - yield assistantText("Implemented"); - yield resultMessage({ stop_reason: "max_tokens" }); - yield idle(); - } - 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* messageGenerator() { - const { value: first } = await input[Symbol.asyncIterator]().next(); - yield* heldTurnPlanningFollowup(first); - yield idle(); - } - return messageGenerator(); - }, - { - creationParams: { cwd: "/test", mcpServers: [] }, - pendingExitPlanContextReset: { - toolUseId: "tool-plan", - plan: "Implement it", - mode: "default", - }, - }, - ); - - await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] }); - - // The turn's work ends at its result, though v1's answer is held. The - // follow-up that plans is a cycle, which the continuation takes over - // without a new `running`. The restart replaces the session behind the - // id; the work stays with the id. - expect(foreground).toEqual([ - "running", - "ended end_turn 15", - "running", - "awaitingUser", - "running", - // The turn's usage: the carried 15 and the continuation's. - "ended max_tokens 30", - ]); - }); - it("cancels the held turn when the user keeps planning", async () => { const agent = createMockAgent(); const createSession = vi.spyOn(agent as any, "createSession"); @@ -23561,40 +23294,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)", expect(agent.sessions["test-session"].pendingExitPlanModeInterruption).toBeUndefined(); }); - it("ends the follow-up cycle cancelled, not failed, when the user keeps planning", async () => { - const foreground: string[] = []; - const agent = new ClaudeAcpAgent( - { sessionUpdate: async () => {} } as unknown as AcpClient, - { log: () => {}, error: () => {} }, - { - foreground: () => ({ - running: () => foreground.push("running"), - awaitingUser: () => foreground.push("awaitingUser"), - ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`), - failed: () => foreground.push("failed"), - }), - }, - ); - injectGeneratorSession(agent, (input) => { - async function* messageGenerator() { - const { value: first } = await input[Symbol.asyncIterator]().next(); - yield* heldTurnPlanningFollowup(first); - yield idle(); - } - return messageGenerator(); - }); - - const response = await agent.prompt({ - sessionId: "test-session", - prompt: [{ type: "text", text: "plan" }], - }); - - expect(response.stopReason).toBe("cancelled"); - // The turn's work ended at its result; the follow-up that planned is a - // cycle, which the error-shaped diagnostic of the answer ends cancelled. - expect(foreground).toEqual(["running", "ended end_turn", "running", "ended cancelled"]); - }); - describe("with backgroundSubagents", () => { it("asks out of the turn without a clear-context option and drops the interrupt", async () => { let request: RequestPermissionRequest | undefined; @@ -28436,7 +28135,7 @@ describe("session/cancel wedge recovery (issue #680)", () => { toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork("test-session", false), + background: new SessionBackgroundWork("test-session"), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), @@ -30346,7 +30045,7 @@ describe("agent selection config option", () => { toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork(sessionId, false), + background: new SessionBackgroundWork(sessionId), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), diff --git a/src/tests/acp-scenarios-v2.test.ts b/src/tests/acp-scenarios-v2.test.ts index fbfcc1ce..84b1a3de 100644 --- a/src/tests/acp-scenarios-v2.test.ts +++ b/src/tests/acp-scenarios-v2.test.ts @@ -1,6 +1,7 @@ /** * Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2 - * surface: through the protocol router, with the SDK's v2 client. + * core (`src/v2/`): through the protocol router, with the SDK's v2 + * client. * * - Status: each scenario passes, every prompt turn ending with a stop reason, * unless {@link V2_STOPS} names why it stops. Every scenario passes today; @@ -11,15 +12,15 @@ * `tool_call`, so a report of a tool call that the client has not seen * creates it, where v1 drops it. * - Golden files: `acp-scenarios/__snapshots__/v2/.jsonl` for each - * passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u` - * to update them. + * passing scenario, every message of the run. Run + * `npx vitest run src/tests/acp-scenarios-v2.test.ts -u` to update them. */ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import type { ClaudeAcpAgent } from "../acp-agent.js"; +import type { AgentHandle } from "../v2/agent.js"; import { resetIds, runScenarioV2, @@ -61,8 +62,8 @@ const here = path.dirname(fileURLToPath(import.meta.url)); const PASSES = "passes"; /** - * The scenarios that stop on the v2 surface, each with the reason, which names - * the step of `docs/acp-v2.md` that removes it. Every other scenario passes. + * The scenarios that stop on the v2 surface, each with the reason. Every + * other scenario passes. */ const V2_STOPS: Record = {}; @@ -86,12 +87,15 @@ beforeAll(async () => { const { acpProtocolRouter } = await import("../serve.js"); for (const scenario of SCENARIOS) { resetIds(); - const agents: ClaudeAcpAgent[] = []; + const agents: { dispose(): Promise }[] = []; const quiet = { log: () => {}, error: () => {} }; runs.set( scenario.name, await runScenarioV2( - (stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream), + (stream) => + acpProtocolRouter(quiet, (agent) => agents.push(agent), "auto", { + onV2Agent: (agent: AgentHandle) => agents.push(agent), + }).connect(stream), scenario, ), ); @@ -163,10 +167,10 @@ describe("ACP v2 scenarios", () => { const passing = SCENARIOS.map((scenario) => scenario.name).filter( (scenario) => statusOf(scenario) === PASSES, ); + const golden = (scenario: string) => + path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`); it.each(passing)("%s", async (scenario) => { - await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot( - path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`), - ); + await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(golden(scenario)); }); }); }); diff --git a/src/tests/acp-scenarios/harness.ts b/src/tests/acp-scenarios/harness.ts index 74ab42ba..813e8463 100644 --- a/src/tests/acp-scenarios/harness.ts +++ b/src/tests/acp-scenarios/harness.ts @@ -94,6 +94,7 @@ export const PROFILES: Record = { type QueryOptions = { sessionId?: string; resume?: string; + env?: Record; canUseTool?: ( toolName: string, input: Record, @@ -234,6 +235,16 @@ export function mockedQuery(args: { prompt: AsyncIterable; options: QueryOp ); }, }; + // With `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS`, Claude Code reports that it + // works once it takes a prompt in, and that it is idle after the result. + const stateEvents = options.env?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS === "1"; + const state = (value: "running" | "idle") => ({ + type: "system", + subtype: "session_state_changed", + state: value, + uuid: `state-${value}`, + session_id: sessionId, + }); async function* run() { const iterator = args.prompt[Symbol.asyncIterator](); for (const turn of script.scenario.turns) { @@ -241,6 +252,7 @@ export function mockedQuery(args: { prompt: AsyncIterable; options: QueryOp if (next.done) return; const user = next.value; ctx.promptUuid = user.uuid; + if (stateEvents) yield state("running"); yield { type: "user", message: user.message, @@ -250,6 +262,7 @@ export function mockedQuery(args: { prompt: AsyncIterable; options: QueryOp isReplay: true, }; yield* turn(ctx); + if (stateEvents) yield state("idle"); } // Stay open like a live Claude Code process. await iterator.next(); diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index 2b87ab74..de29b0b2 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -18,25 +18,23 @@ import { type Options, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; -import type { ClaudeAcpAgent } from "../acp-agent.js"; import type { AuthStatusUpdateNotification } from "../auth-status.js"; -import { clientSupportsCompactionUpdates } from "../context-compaction.js"; +import { clearContextWindowCache } from "../context-window.js"; import { v2DiffContent } from "../diff.js"; import { type AcpProtocols, acpProtocolRouter, acpProtocolsFromEnv } from "../serve.js"; -import { clientSupportsNotices } from "../session-notices.js"; -import { v1PermissionResponse, v2PermissionRequest } from "../v2/permission.js"; +import { v1PermissionResponse, v2PermissionRequest } from "../v2/permissions.js"; import { v1PromptRequest } from "../v2/prompt.js"; import { V2Terminals } from "../v2/terminal.js"; import { V2ToolCallMeta } from "../v2/tool-call-meta.js"; import { v2RestartedToolCall } from "../v2/tool-call.js"; -import { v2ChildStateUpdate, v2SessionUpdate } from "../v2/session-update.js"; +import { v2SessionUpdate } from "../v2/session-update.js"; +import type { AgentHandle } from "../v2/agent.js"; import { v1ForkSessionRequest, v1NewSessionRequest, - v1RestoreSessionRequest, v1SetSessionConfigOptionRequest, v2ConfigOptions, -} from "../v2/session.js"; +} from "../v2/session-requests.js"; import { startupFailure } from "./helpers.js"; import packageJson from "../../package.json" with { type: "json" }; @@ -50,11 +48,19 @@ vi.mock("node:child_process", async () => { /** What the mocked Claude Agent SDK saw and answers. Reset before each test. */ const sdk = vi.hoisted(() => ({ queryOptions: [] as Options[], - /** What Claude Code sends, given the prompts it receives (see `scriptTurns`). */ + /** + * What Claude Code sends, given the prompts it receives (see `scriptTurns`). + * By default it sends nothing and, like the real CLI, runs until its input + * ends. + */ run: async function* ( - _prompts: AsyncIterable, + prompts: AsyncIterable, _options: Options, - ): AsyncGenerator> {}, + ): AsyncGenerator> { + const input = prompts[Symbol.asyncIterator](); + while (!(await input.next()).done); + yield* []; + }, /** Claude Code receives an interrupt, as `cancel()` sends one. */ interrupt: async (): Promise => {}, /** Claude Code stops one task, as `cancel()` does for a child session. */ @@ -72,6 +78,17 @@ const sdk = vi.hoisted(() => ({ ], }), mcpServerStatus: async (): Promise> => [], + /** Claude Code's agents (`supportedAgents`), with its built-in subagents. */ + supportedAgents: async (): Promise> => [ + { name: "general-purpose", description: "General-purpose agent" }, + ], + /** The flag settings that the agent applied (`applyFlagSettings`), in order. */ + flagSettings: [] as Array>, + /** What `getContextUsage({ detail: "summary" })` answers. */ + contextUsage: async (): Promise<{ totalTokens: number; rawMaxTokens: number }> => ({ + totalTokens: 0, + rawMaxTokens: 200000, + }), supportedCommands: async (): Promise< Array<{ name: string; description: string; argumentHint: string }> > => [{ name: "review", description: "Review a change", argumentHint: "" }], @@ -81,6 +98,19 @@ const sdk = vi.hoisted(() => ({ requiresUserAction: false, callbackExpected: false, }), + /** The query replaces its MCP servers, as a resume of the active session asks. */ + setMcpServers: async (_servers: Record): Promise> => ({ + added: [], + removed: [], + errors: {}, + }), + /** The account that the query cached at its start, which `accountInfo()` reads. */ + accountInfo: async (): Promise> => ({}), + /** The query switches its model (`setModel`). */ + setModel: async (_model?: string): Promise => {}, + /** Set to serve Remote Control, as a query of a CLI that supports it does. */ + enableRemoteControl: undefined as + ((enabled: boolean, name?: string) => Promise<{ session_url?: string }>) | undefined, })); const sdkDefaults = { ...sdk }; @@ -100,10 +130,19 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { initializationResult: () => sdk.initializationResult(), supportedCommands: () => sdk.supportedCommands(), mcpServerStatus: () => sdk.mcpServerStatus(), + supportedAgents: () => sdk.supportedAgents(), + applyFlagSettings: async (settings: Record) => { + sdk.flagSettings.push(settings); + }, + getContextUsage: () => sdk.contextUsage(), mcpAuthenticate: (serverName: string) => sdk.mcpAuthenticate(serverName), interrupt: () => sdk.interrupt(), stopTask: (taskId: string) => sdk.stopTask(taskId), close: () => sdk.close(), + accountInfo: () => sdk.accountInfo(), + setMcpServers: (servers: Record) => sdk.setMcpServers(servers), + setModel: (model?: string) => sdk.setModel(model), + ...(sdk.enableRemoteControl ? { enableRemoteControl: sdk.enableRemoteControl } : {}), }); return Object.assign(messages, controls); }, @@ -137,7 +176,7 @@ const CLI_SUBSCRIPTION = JSON.stringify({ const V2_CLIENT_INFO = { name: "v2-test-client", version: "1.0.0" }; -const agents: ClaudeAcpAgent[] = []; +const agents: AgentHandle[] = []; /** An empty Claude config directory and a session cwd, so no test reads the real ones. */ let tempDir: string; let cwd: string; @@ -152,7 +191,7 @@ beforeEach(async () => { tempDir = await mkdtemp(path.join(os.tmpdir(), "acp-v2-")); cwd = await mkdtemp(path.join(tempDir, "project-")); vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude")); - Object.assign(sdk, sdkDefaults, { queryOptions: [] }); + Object.assign(sdk, sdkDefaults, { queryOptions: [], flagSettings: [] }); }); afterEach(async () => { @@ -167,13 +206,34 @@ afterEach(async () => { function connectRouter(protocols?: AcpProtocols): v2.Stream { const toAgent = new TransformStream(); const toClient = new TransformStream(); - acpProtocolRouter(undefined, (agent) => agents.push(agent), protocols).connect({ + acpProtocolRouter(undefined, (agent) => agents.push(agent), protocols, { + onV2Agent: (agent) => agents.push(agent), + }).connect({ readable: toAgent.readable, writable: toClient.writable, }); return { readable: toClient.readable, writable: toAgent.writable }; } +/** The v2 core's session `sessionId` (a `V2Session`), for waits on its internals. */ +function v2Session(sessionId: string): any { + return (agents[0] as unknown as { sessions: Map }).sessions.get(sessionId); +} + +/** + * Waits until Claude Code has the prompt queued behind the running turn: the + * core has pushed the prompt and waits for its insertion. + */ +async function waitForQueuedPrompt(sessionId: string): Promise { + await vi.waitFor(() => + expect( + [...v2Session(sessionId).pending.values()].filter( + (prompt: { pushed: boolean }) => prompt.pushed, + ), + ).toHaveLength(1), + ); +} + /** * A v2 client app that collects every `_auth/status_update`, session update, * elicitation, and permission request it receives, answers elicitations with @@ -351,8 +411,10 @@ describe("ACP protocol routing", () => { }); // The v2 app registers neither `_session/async_task/stop` nor `_session/goal`. + // The core lists only the AIR extensions that v2 does not cover (see + // "ACP v2" in docs/air-extensions.md). const air = (response._meta as any)?.jetbrains?.air; - expect(air.capabilities).toContain("sessionFailure"); + expect(air.capabilities).toEqual(["agentFileChangeReport", "recommendedValue"]); expect(air.capabilities).not.toContain("asyncTasks"); expect(air).not.toHaveProperty("goal"); }); @@ -578,6 +640,63 @@ describe("ACP v2 sessions", () => { expect(sdk.queryOptions).toHaveLength(1); }); + it.each([ + ["a mode it does not support", { mode: "_custom", content: text("Be brief.") }], + [ + "content other than text", + { mode: "append", content: [{ type: "image", mimeType: "image/png", data: "iVBORw0KGgo=" }] }, + ], + ] as const)( + "rejects a systemPrompt with %s on resume of the active session and on fork, and copies nothing", + async (_case, systemPrompt) => { + const { app, authUpdate } = v2Client(); + const invalid = { code: -32602, data: { systemPrompt: expect.anything() } }; + await app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + vi.mocked(forkSession).mockClear(); + await expect( + agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + systemPrompt: systemPrompt as v2.SystemPrompt, + }), + ).rejects.toMatchObject(invalid); + await expect( + agent.request(v2.methods.agent.session.fork, { + sessionId, + cwd, + systemPrompt: systemPrompt as v2.SystemPrompt, + }), + ).rejects.toMatchObject(invalid); + await authUpdate(1); + }); + expect(forkSession).not.toHaveBeenCalled(); + expect(sdk.queryOptions).toHaveLength(1); + }, + ); + + it("restarts the active session with the additional directories that a resume brings", async () => { + const extra = await mkdtemp(path.join(tempDir, "extra-")); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + additionalDirectories: [extra], + }); + await vi.waitFor(() => expect(sdk.queryOptions).toHaveLength(2)); + await client.authUpdate(1); + }); + + expect(sdk.queryOptions[1]).toMatchObject({ + resume: sdk.queryOptions[0].sessionId, + additionalDirectories: [extra], + }); + }); + it("lists, resumes, closes, and deletes sessions", async () => { const { app, authUpdate } = v2Client(); await app.connectWith(connectRouter(), async (agent) => { @@ -601,20 +720,112 @@ describe("ACP v2 sessions", () => { expect(resumed).not.toHaveProperty("modes"); expect(resumed.configOptions?.[0].configId).toBe("mode"); - // v1 replays only from the start. + // Both cores replay only from the start. Another cursor is rejected, + // and the session is not resumed without its replay. + const queries = sdk.queryOptions.length; + await agent.request(v2.methods.agent.session.close, { sessionId }); await expect( agent.request(v2.methods.agent.session.resume, { sessionId, cwd, replayFrom: { type: "_message", messageId: "m" }, }), - ).rejects.toMatchObject({ code: -32602 }); + ).rejects.toMatchObject({ + code: -32602, + message: expect.stringContaining("Replaying from a _message cursor is not supported"), + }); + expect(sdk.queryOptions).toHaveLength(queries); await agent.request(v2.methods.agent.session.delete, { sessionId }); await authUpdate(1); }); }); + it("offers a resumed Fable 5.2 session's model as `fable`, as a new session lists it", async () => { + // On resume, the CLI lists the transcript's Fable row under its id, with no `fable` row. + const row = (value: string, resolvedModel: string, displayName: string) => ({ + value, + resolvedModel, + displayName, + description: "", + }); + sdk.initializationResult = async () => ({ + models: [ + row("default", "claude-opus-5-5", "Default (recommended)"), + row("claude-fable-5-2", "claude-fable-5-2", "Fable 5.2"), + ], + }); + const setModel = vi.fn(sdkDefaults.setModel); + sdk.setModel = setModel; + scriptTurns(); + let model: unknown; + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.close, { sessionId }); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + transcriptEntry("assistant", randomUUID(), { + id: "msg_fable", + role: "assistant", + model: "claude-fable-5-2", + content: [{ type: "text", text: "ok" }], + }), + ] as unknown as Awaited>); + const resumed = await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: { type: "start" }, + }); + model = resumed.configOptions?.find((option) => option.configId === "model"); + await client.authUpdate(1); + }); + + expect(model).toMatchObject({ + currentValue: "fable", + options: [{ value: "default" }, { value: "fable", name: "Fable 5.2" }], + }); + expect(setModel).not.toHaveBeenCalled(); + }); + + it("keeps the running turn of the active session that a resume gives other MCP servers", async () => { + const release = Promise.withResolvers(); + scriptTurns(async function* (options) { + await release.promise; + yield result(options); + }); + sdk.setMcpServers = vi.fn(sdkDefaults.setMcpServers); + const server = (name: string) => ({ + type: "http" as const, + name, + url: `https://${name}.dev/mcp`, + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { + cwd, + mcpServers: [server("linear")], + }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("work") }); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + mcpServers: [server("github")], + }); + release.resolve(); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates).at(-1)).toMatch(/^idle/)); + await client.authUpdate(1); + }); + + // v2 asks that ongoing work continue. + expect(sdk.queryOptions).toHaveLength(1); + expect(sdk.setMcpServers).toHaveBeenCalledExactlyOnceWith({ + github: expect.objectContaining({ type: "http", url: "https://github.dev/mcp" }), + }); + expect(turnTrace(client.sessionUpdates).at(-1)).toBe("idle end_turn"); + }); + it("resumes an unavailable mode using the configured mode in the v2 response", async () => { const project = path.join(tempDir, "claude", "projects", "-project"); await mkdir(project, { recursive: true }); @@ -695,14 +906,347 @@ describe("ACP v2 sessions", () => { expect(client.updates("config_option_update")).toEqual([]); }); - it("tells the agent that a v2 client takes notices and compaction updates", async () => { - const { app, authUpdate } = v2Client(); - await app.connectWith(connectRouter(), async (agent) => { - await initializeV2(agent); - await authUpdate(1); + describe("the model's config options", () => { + /** A model with effort levels, Fast mode and ultracode, and one with none of them. */ + const OPUS = { + value: "claude-opus-4-8", + displayName: "Claude Opus", + description: "Most capable", + supportsAutoMode: true, + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "xhigh"], + supportsFastMode: true, + }; + const HAIKU = { + value: "claude-haiku-4-5", + displayName: "Claude Haiku", + description: "Fastest", + supportsAutoMode: true, + }; + + beforeEach(() => { + sdk.initializationResult = async () => ({ models: [OPUS, HAIKU] }); + sdk.supportedAgents = async () => [ + { name: "general-purpose", description: "General-purpose agent" }, + { name: "reviewer", description: "Reviews changes" }, + ]; + }); + + /** The options of a session, by id: their type and current value. */ + const values = (options: v2.SessionConfigOption[] | null | undefined) => + Object.fromEntries( + (options ?? []).map((option) => [option.configId, [option.type, option.currentValue]]), + ); + + it("offers effort, Fast mode, ultracode and the custom agents, and sets each on Claude Code", async () => { + const client = v2Client(); + const { created, set } = await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const created = await agent.request(v2.methods.agent.session.new, { cwd }); + const { sessionId } = created; + const set = []; + for (const request of [ + { configId: "effort", type: "id", value: "high" }, + { configId: "fast", type: "boolean", value: true }, + { configId: "ultracode", type: "boolean", value: true }, + { configId: "agent", type: "id", value: "reviewer" }, + ] as const) { + set.push( + await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + ...request, + }), + ); + } + await client.authUpdate(1); + return { created, set }; + }); + + // Every v2 client takes boolean options. The built-in subagents are no + // main-thread agent. + expect(values(created.configOptions)).toEqual({ + mode: ["select", "auto"], + model: ["select", "claude-opus-4-8"], + effort: ["select", "default"], + fast: ["boolean", false], + ultracode: ["boolean", false], + agent: ["select", "default"], + }); + expect(created.configOptions?.find((option) => option.configId === "agent")).toMatchObject({ + options: [ + { value: "default", name: "Default" }, + { value: "reviewer", name: "reviewer", description: "Reviews changes" }, + ], + }); + // Each value goes to Claude Code before the option shows it. + expect(sdk.flagSettings).toEqual([ + { effortLevel: "high" }, + { fastMode: true }, + { ultracode: true }, + { agent: "reviewer" }, + ]); + expect(values(set.at(-1)!.configOptions)).toMatchObject({ + effort: ["select", "high"], + fast: ["boolean", true], + ultracode: ["boolean", true], + agent: ["select", "reviewer"], + }); + expect(client.updates("config_option_update")).toHaveLength(4); + }); + + it.each([ + [ + "a select value for a boolean option", + { configId: "fast", type: "id", value: "on" }, + 'Config option fast takes a boolean value, but received: "on"', + ], + [ + "a boolean for a select option", + { configId: "effort", type: "boolean", value: true }, + "Config option effort takes a string value, but received: true", + ], + [ + "a level the model does not offer", + { configId: "effort", type: "id", value: "max" }, + "Invalid value for config option effort: max", + ], + // "a" is a substring of "claude": a substring match would pick Opus. + [ + "a model the session does not offer", + { configId: "model", type: "id", value: "not-a-model" }, + "Invalid value for config option model: not-a-model", + ], + [ + "a mode the session does not offer", + { configId: "mode", type: "id", value: "not-a-mode" }, + "Invalid value for config option mode: not-a-mode", + ], + [ + "an agent the session does not offer", + { configId: "agent", type: "id", value: "not-an-agent" }, + "Invalid value for config option agent: not-an-agent", + ], + ] as const)( + "rejects %s and leaves the options as they were", + async (_case, request, message) => { + const models: Array = []; + sdk.setModel = async (model) => { + models.push(model); + }; + const client = v2Client(); + const { created, listed } = await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const created = await agent.request(v2.methods.agent.session.new, { cwd }); + const { sessionId } = created; + await expect( + agent.request(v2.methods.agent.session.setConfigOption, { sessionId, ...request }), + ).rejects.toMatchObject({ code: -32602, message: expect.stringContaining(message) }); + // Setting a valid value again returns every option's current value. + const listed = await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "agent", + type: "id", + value: "default", + }); + await client.authUpdate(1); + return { created, listed }; + }); + expect(sdk.flagSettings).toEqual([{ agent: null }]); + expect(models).toEqual([]); + expect(values(listed.configOptions)).toEqual(values(created.configOptions)); + }, + ); + + it("rebuilds the options for a model switch, and clears an effort the new model lacks", async () => { + const client = v2Client(); + const response = await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "effort", + type: "id", + value: "high", + }); + const response = await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "model", + type: "id", + value: "claude-haiku-4-5", + }); + await client.authUpdate(1); + return response; + }); + + // Effort, Fast mode and ultracode show only on a model that has them. + expect(values(response.configOptions)).toEqual({ + mode: ["select", "auto"], + model: ["select", "claude-haiku-4-5"], + agent: ["select", "default"], + }); + // The pin the new model cannot run is cleared in Claude Code too. + expect(sdk.flagSettings).toEqual([{ effortLevel: "high" }, { effortLevel: null }]); + }); + + it("runs config changes of a session in the order they came", async () => { + // The model switch takes a while: the effort change waits for it, and + // then finds no effort option on the new model. + sdk.setModel = () => new Promise((resolve) => setTimeout(resolve, 20)); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + const model = agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "model", + type: "id", + value: "claude-haiku-4-5", + }); + const effort = agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "effort", + type: "id", + value: "high", + }); + await model; + await expect(effort).rejects.toMatchObject({ code: -32602 }); + await client.authUpdate(1); + }); + + expect(sdk.flagSettings).toEqual([]); + }); + + it("keeps the client's effort, Fast mode, ultracode and agent across a restart of the query", async () => { + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + // The effort that the session was created with is no longer the client's choice. + const { sessionId } = await agent.request(v2.methods.agent.session.new, { + cwd, + _meta: { claudeCode: { options: { effort: "high" } } }, + }); + for (const request of [ + { configId: "effort", type: "id", value: "low" }, + { configId: "fast", type: "boolean", value: true }, + { configId: "ultracode", type: "boolean", value: true }, + { configId: "agent", type: "id", value: "reviewer" }, + ] as const) { + await agent.request(v2.methods.agent.session.setConfigOption, { sessionId, ...request }); + } + sdk.flagSettings = []; + await agent.request(v2.methods.agent.providers.set, { + providerId: "main", + apiType: "anthropic", + baseUrl: "https://gateway.example.com", + }); + await vi.waitFor(() => + // The gateway lists no models, so the model option is gone. + expect(values(client.updates("config_option_update").at(-1)?.configOptions)).toEqual({ + mode: ["select", "auto"], + effort: ["select", "low"], + fast: ["boolean", true], + ultracode: ["boolean", true], + agent: ["select", "reviewer"], + }), + ); + await client.authUpdate(1); + }); + + expect(sdk.queryOptions).toHaveLength(2); + expect(sdk.queryOptions[1].effort).toBeUndefined(); + expect(sdk.flagSettings).toEqual([ + { ultracode: true }, + { fastMode: true }, + { effortLevel: "low", ultracode: true }, + { agent: "reviewer" }, + ]); + }); + + it("turns Fast mode off with a notice when Claude Code reports it off", async () => { + sdk.initializationResult = async () => ({ models: [OPUS], fast_mode_state: "on" }); + scriptTurns(async function* (options) { + yield assistantText(options, "Done."); + yield result(options, { + fast_mode_state: "off", + fast_mode_disabled_reason: "extra_usage_disabled", + }); + yield idleState(options); + }); + const client = v2Client(); + const created = await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const created = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { + sessionId: created.sessionId, + prompt: text("hi"), + }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + return created; + }); + + expect(values(created.configOptions).fast).toEqual(["boolean", true]); + expect(client.updates("notice")).toEqual([ + { + sessionUpdate: "notice", + severity: "warning", + title: "Fast mode turned off", + description: "Requires extra usage to be enabled for this account.", + }, + ]); + const [update] = client.updates("config_option_update"); + expect(update.configOptions.find((option) => option.configId === "fast")).toMatchObject({ + currentValue: false, + description: + "Faster responses on supported models — requires extra usage to be enabled for this account", + }); + }); + + it("reports a refusal fallback with a notice and follows the model it swapped to", async () => { + sdk.initializationResult = async () => ({ models: [OPUS, HAIKU] }); + scriptTurns(async function* (options) { + yield { + type: "system", + subtype: "model_refusal_fallback", + trigger: "refusal", + direction: "retry", + scope: "session", + original_model: "claude-opus-4-8", + fallback_model: "claude-haiku-4-5", + request_id: "req_1", + api_refusal_category: "cyber", + content: "banner", + uuid: randomUUID(), + session_id: options.sessionId, + }; + yield assistantText(options, "Done."); + yield result(options); + yield idleState(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + }); + + expect(client.updates("notice")).toEqual([ + { + sessionUpdate: "notice", + severity: "warning", + title: + "claude-opus-4-8 declined this request (cyber); retried with claude-haiku-4-5. The session will continue on claude-haiku-4-5.", + }, + ]); + const [update] = client.updates("config_option_update"); + expect(values(update.configOptions)).toEqual({ + mode: ["select", "auto"], + model: ["select", "claude-haiku-4-5"], + agent: ["select", "default"], + }); }); - expect(clientSupportsNotices(agents[0].clientCapabilities)).toBe(true); - expect(clientSupportsCompactionUpdates(agents[0].clientCapabilities)).toBe(true); }); it("forwards the URL elicitation of MCP OAuth to a v2 client", async () => { @@ -742,6 +1286,40 @@ describe("ACP v2 sessions", () => { ]); }); + it.each([ + ["renders URL elicitations, which the sign-in uses", { elicitation: { url: {} } }, []], + ["renders no URL elicitations", {}, ['MCP server "linear" needs authentication']], + ])( + "reports an MCP server that needs a sign-in as a notice only when the client %s", + async (_case, capabilities, notices) => { + scriptTurns(async function* (options) { + yield { + type: "system", + subtype: "init", + mcp_servers: [{ name: "linear", status: "needs-auth" }], + session_id: options.sessionId, + uuid: randomUUID(), + }; + yield assistantText(options, "Done."); + yield result(options); + yield idleState(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent, capabilities); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { + cwd, + mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }], + }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + }); + + expect(client.updates("notice").map((notice) => notice.title)).toEqual(notices); + }, + ); + it("sends the URL elicitation of MCP OAuth only after the session/new response", async () => { sdk.mcpServerStatus = async () => [{ name: "linear", status: "needs-auth" }]; sdk.mcpAuthenticate = async () => ({ @@ -812,6 +1390,54 @@ function transcriptEntry(type: "user" | "assistant", uuid: string, message: unkn return { type, uuid, session_id: "session", parent_tool_use_id: null, message }; } +/** A record of a Claude Code transcript file, as Claude Code writes it. */ +function transcriptRecord( + sessionId: string, + uuid: string, + parentUuid: string | null, + message: { role: string } & Record, + fields: Record = {}, +) { + return { + type: message.role, + uuid, + parentUuid, + sessionId, + cwd, + version: "2.0.0", + isSidechain: false, + userType: "external", + timestamp: "2026-09-24T00:00:00.000Z", + message, + ...fields, + }; +} + +/** + * Writes the history that Claude Code keeps of a session: its transcript + * (`root`), and the subagent `agentId`'s own transcript (`records`) beside + * it, with the metadata file that names the subagent's launch (`meta`). + */ +async function writeSubagentHistory( + sessionId: string, + root: Array>, + agentId: string, + meta: Record, + records: Array>, +) { + const project = path.join(tempDir, "claude", "projects", "-project"); + const subagents = path.join(project, sessionId, "subagents"); + await mkdir(subagents, { recursive: true }); + const lines = (entries: Array>) => + entries.map((entry) => JSON.stringify(entry) + "\n").join(""); + await writeFile(path.join(project, `${sessionId}.jsonl`), lines(root)); + await writeFile( + path.join(subagents, `agent-${agentId}.jsonl`), + lines(records.map((record) => ({ ...record, isSidechain: true, agentId }))), + ); + await writeFile(path.join(subagents, `agent-${agentId}.meta.json`), JSON.stringify(meta)); +} + function stream(options: Options, event: Record) { return { type: "stream_event", @@ -1357,48 +1983,26 @@ describe("ACP v2 prompts", () => { }); it("replays a subagent's history to its own session, and clears each replayed message there first", async () => { + // Claude Code keeps its stream open while the session lives. + scriptTurns(); const client = v2Client(); await client.app.connectWith(connectRouter(), async (agent) => { await initializeV2(agent); const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); - // Claude Code keeps the subagent's history in its own transcript, beside - // the session's, with a metadata file that names its launch. - const project = path.join(tempDir, "claude", "projects", "-project"); - const subagents = path.join(project, sessionId, "subagents"); - await mkdir(subagents, { recursive: true }); - const entry = (uuid: string, parentUuid: string | null, message: unknown, sidechain = true) => - JSON.stringify({ - type: (message as { role: string }).role, - uuid, - parentUuid, - sessionId, - cwd, - version: "2.0.0", - isSidechain: sidechain, - ...(sidechain ? { agentId: "abc123" } : {}), - userType: "external", - timestamp: "2026-09-24T00:00:00.000Z", - message, - }); - await writeFile( - path.join(project, `${sessionId}.jsonl`), - entry("r1", null, { role: "user", content: "Explore it" }, false) + "\n", - ); - await writeFile( - path.join(subagents, "agent-abc123.jsonl"), + await writeSubagentHistory( + sessionId, + [transcriptRecord(sessionId, "r1", null, { role: "user", content: "Explore it" })], + "abc123", + { agentType: "Explore", name: "scout", toolUseId: "toolu_agent" }, [ - entry("c1", null, { role: "user", content: "Look around" }), - entry("c2", "c1", { + transcriptRecord(sessionId, "c1", null, { role: "user", content: "Look around" }), + transcriptRecord(sessionId, "c2", "c1", { id: "msg_child", role: "assistant", model: "claude-haiku-4-5", content: [{ type: "text", text: "Child report." }], }), - ].join("\n") + "\n", - ); - await writeFile( - path.join(subagents, "agent-abc123.meta.json"), - JSON.stringify({ agentType: "Explore", name: "scout", toolUseId: "toolu_agent" }), + ], ); vi.mocked(getSessionMessages).mockResolvedValueOnce([ transcriptEntry("assistant", randomUUID(), { @@ -1475,6 +2079,133 @@ describe("ACP v2 prompts", () => { ).toEqual([]); }); + it("replays a SendMessage call's delivery, a hand-back and a child's model change as live", async () => { + scriptTurns(); + const client = v2Client(); + let root = ""; + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + root = sessionId; + const response = (uuid: string, parent: string, model: string, content: unknown[]) => + transcriptRecord(sessionId, uuid, parent, { + id: `msg_${uuid}`, + role: "assistant", + model, + content, + }); + await writeSubagentHistory( + sessionId, + [transcriptRecord(sessionId, "r1", null, { role: "user", content: "Explore it" })], + "abc123", + { agentType: "Explore", name: "scout", toolUseId: "toolu_agent" }, + [ + transcriptRecord(sessionId, "c1", null, { role: "user", content: "Look around" }), + response("c2", "c1", "claude-haiku-4-5", [ + { + type: "tool_use", + id: "toolu_handback", + name: "SubagentHandback", + input: { message: "Found it." }, + }, + ]), + transcriptRecord(sessionId, "c3", "c2", { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_handback", content: "Delivered" }], + }), + // Claude Code frames a SendMessage call's message for the child's model. + transcriptRecord( + sessionId, + "c4", + "c3", + { + role: "user", + content: + "The coordinator sent a message while you were working:\nLook again\n\nAddress this before completing your current task.", + }, + { isMeta: true, origin: { kind: "coordinator" } }, + ), + response("c5", "c4", "claude-sonnet-4-6", [{ type: "text", text: "Still fine." }]), + ], + ); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + transcriptEntry("assistant", randomUUID(), { + id: "msg_agent", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_agent", + name: "Agent", + input: { name: "scout", subagent_type: "Explore", prompt: "Look around" }, + }, + ], + }), + transcriptEntry("user", randomUUID(), { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_agent", content: "Delivered." }], + }), + transcriptEntry("assistant", randomUUID(), { + id: "msg_send", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_send", + name: "SendMessage", + input: { to: "scout", message: "Look again" }, + }, + ], + }), + transcriptEntry("user", randomUUID(), { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_send", content: "Message sent" }], + }), + ] as unknown as Awaited>); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: { type: "start" }, + }); + await client.authUpdate(1); + }); + + const messages = client.sessionUpdates.flatMap(({ sessionId, update }) => { + if (update.sessionUpdate !== "session_message") return []; + const name = (id: string | null | undefined) => (id === root ? "root" : id); + const { messageId, senderSessionId, recipientSessionId } = update as { + messageId: string; + senderSessionId?: string; + recipientSessionId: string; + }; + return [ + `${name(sessionId)} ${messageId} ${name(senderSessionId)} -> ${name(recipientSessionId)}`, + ]; + }); + // The hand-back is the child's message to its parent, so the launch's + // result is no reply. The delivered message replaces its call. + expect(messages).toEqual([ + "root toolu_agent root -> abc123", + "abc123 toolu_agent root -> abc123", + "abc123 toolu_handback abc123 -> root", + "root toolu_handback abc123 -> root", + "abc123 toolu_send root -> abc123", + "root toolu_send root -> abc123", + ]); + expect(client.updates("tool_call_update")).toEqual([]); + // The child's model goes again when a response changes it. + expect( + client.sessionUpdates.flatMap(({ sessionId, update }) => + sessionId === "abc123" && update.sessionUpdate === "config_option_update" + ? [(update as { configOptions: Array<{ currentValue: unknown }> }).configOptions[0]!] + : [], + ), + ).toEqual([ + expect.objectContaining({ currentValue: "claude-haiku-4-5" }), + expect.objectContaining({ currentValue: "claude-sonnet-4-6" }), + ]); + }); + it("ends a resume with replay with one end event for a client with loadReplayEnd", async () => { const client = v2Client(); const isEnd = (notification: v2.UpdateSessionNotification) => @@ -1524,7 +2255,12 @@ describe("ACP v2 prompts", () => { _meta: { jetbrains: { air: { version: 1, loadReplayEnd: true } } }, }, }); - expect(atResponse.at(-1)?.update).toEqual({ sessionUpdate: "state_update", state: "idle" }); + // An AIR client gets v2's `background` list too. + expect(atResponse.at(-1)?.update).toEqual({ + sessionUpdate: "state_update", + state: "idle", + background: [], + }); expect(client.sessionUpdates.filter(isEnd)).toHaveLength(1); }); @@ -1877,7 +2613,7 @@ describe("ACP v2 prompts", () => { const queued = agent .request(v2.methods.agent.session.prompt, { sessionId, prompt: text("second") }) .finally(() => (answered = true)); - await vi.waitFor(() => expect(agents[0].sessions[sessionId]?.turnQueue).toHaveLength(2)); + await waitForQueuedPrompt(sessionId); expect(answered).toBe(false); firstAnswer.resolve(); @@ -1936,7 +2672,7 @@ describe("ACP v2 prompts", () => { sessionId, prompt: text("second"), }); - await vi.waitFor(() => expect(agents[0].sessions[sessionId]?.turnQueue).toHaveLength(2)); + await waitForQueuedPrompt(sessionId); await agent.notify(v2.methods.agent.session.cancel, { sessionId }); @@ -1954,6 +2690,171 @@ describe("ACP v2 prompts", () => { ]); }); + it("sends the tail of a text block whose stream was cut short from the consolidated message", async () => { + scriptTurns(async function* (options) { + yield stream(options, { + type: "message_start", + message: { id: "msg_answer", role: "assistant", content: [], usage: {} }, + }); + yield stream(options, { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }); + yield stream(options, { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Here is" }, + }); + // The stream loses the rest of the block, and the consolidated message holds it. + yield assistantText(options, "Here is the answer."); + yield result(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + }); + + expect( + client.updates("agent_message_chunk").map((update) => ({ + messageId: update.messageId, + text: (update.content as { text: string }).text, + })), + ).toEqual([ + { messageId: "msg_answer", text: "Here is" }, + { messageId: "msg_answer", text: " the answer." }, + ]); + }); + + it("drops the consolidated messages of the work a cancel stops, except the results of its shown tool calls", async () => { + const interrupted = Promise.withResolvers(); + sdk.interrupt = async () => interrupted.resolve(); + scriptTurns(async function* (options) { + yield stream(options, { + type: "message_start", + message: { id: "msg_working", role: "assistant", content: [], usage: {} }, + }); + yield stream(options, { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }); + yield stream(options, { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Working on" }, + }); + yield stream(options, { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "toolu_shown", name: "Bash", input: {} }, + }); + await interrupted.promise; + // What Claude Code had when the interrupt arrived: the consolidated + // message, with the text's tail and a tool use that never streamed. + yield { + ...assistantText(options, "Working on it", "msg_working"), + message: { + ...assistantText(options, "", "msg_working").message, + content: [ + { type: "text", text: "Working on it" }, + { type: "tool_use", id: "toolu_shown", name: "Bash", input: { command: "ls" } }, + { type: "tool_use", id: "toolu_unseen", name: "Bash", input: { command: "pwd" } }, + ], + stop_reason: "tool_use", + }, + }; + // The interrupt gives both tools an error result. + yield { + type: "user", + message: { + role: "user", + content: ["toolu_shown", "toolu_unseen"].map((id) => ({ + type: "tool_result", + tool_use_id: id, + content: "[Request interrupted by user for tool use]", + is_error: true, + })), + }, + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: options.sessionId, + }; + yield result(options, ABORTED_TOOLS); + yield idleState(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go") }); + await vi.waitFor(() => + expect(client.updates("tool_call_update").map((u) => u.toolCallId)).toContain( + "toolu_shown", + ), + ); + + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle cancelled")); + await client.authUpdate(1); + }); + + expect( + client.updates("agent_message_chunk").map((u) => (u.content as { text: string }).text), + ).toEqual(["Working on"]); + const toolCalls = client.updates("tool_call_update"); + expect(toolCalls.some((update) => update.toolCallId === "toolu_unseen")).toBe(false); + expect(toolCalls.filter((update) => update.toolCallId === "toolu_shown").at(-1)).toMatchObject({ + status: "cancelled", + }); + }); + + it.each([ + ["Claude Code goes idle", false], + ["the stream dies", true], + ])( + "reports zero usage on the idle of a prompt's turn that a cancel stops before its result, when %s", + async (_, dies) => { + const interrupted = Promise.withResolvers(); + sdk.interrupt = async () => interrupted.resolve(); + scriptTurns(async function* (options) { + yield assistantText(options, "Working on it"); + await interrupted.promise; + // The interrupt stops the turn before its result. + if (dies) throw new Error("Claude Code process terminated by signal SIGTERM"); + yield idleState(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go") }); + await vi.waitFor(() => + expect(turnTrace(client.sessionUpdates)).toContain("agent_message_chunk"), + ); + + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + + await vi.waitFor(() => + expect(turnTrace(client.sessionUpdates)).toContain("idle cancelled"), + ); + await client.authUpdate(1); + }); + + const zero = { inputTokens: 0, outputTokens: 0, totalTokens: 0 }; + expect(client.updates("state_update").at(-1)).toMatchObject({ + stopReason: "cancelled", + usage: { ...zero, cachedReadTokens: 0, cachedWriteTokens: 0 }, + _meta: { quota: { token_count: zero, model_usage: [] } }, + }); + }, + ); + it("reports idle cancelled when the stream dies while a cancel stops the turn", async () => { const interrupted = Promise.withResolvers(); sdk.interrupt = async () => interrupted.resolve(); @@ -1998,13 +2899,13 @@ describe("ACP v2 prompts", () => { await client.app.connectWith(connectRouter(), async (agent) => { await initializeV2(agent); const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); - // The prompt waits in the subscription guard while the close runs. + // The prompt waits while the close runs, for the interrupt of an + // earlier cancel. const guard = Promise.withResolvers(); const guarded = Promise.withResolvers(); - vi.spyOn(agents[0] as any, "runClaudeSubscriptionGuard").mockImplementation(() => { - guarded.resolve(); - return guard.promise; - }); + sdk.interrupt = () => guard.promise; + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + guarded.resolve(); const prompt = agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go"), @@ -2036,7 +2937,7 @@ describe("ACP v2 prompts", () => { await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("agent_message_chunk"), ); - agents[0].sessions[sessionId]!.cancelController = new AbortController(); + // The close ends the work itself: it waits for no consumer. vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); try { @@ -2563,7 +3464,8 @@ describe("ACP v2 prompts", () => { }); const updates = client.sessionUpdates as unknown as Seen[]; - // The child reported idle first, and then its command ended in its session. + // The command ends in the child's session, before the child's idle: v2 + // asks for the updates after a cancel before the idle that reports it. const childIdle = updates.findIndex( ({ sessionId, update }) => sessionId === "agent-1" && @@ -2576,7 +3478,7 @@ describe("ACP v2 prompts", () => { sessionId: "agent-1", update: { status: "cancelled" }, }); - expect(lastStatusAt(updates, "toolu_child")).toBeGreaterThan(childIdle); + expect(lastStatusAt(updates, "toolu_child")).toBeLessThan(childIdle); // Claude Code's result for the command was dropped, so only one report ended it. expect( updates.filter( @@ -2800,6 +3702,256 @@ describe("ACP v2 prompts", () => { }); expect(echoed).toEqual([]); }); + + describe("the context window before the first result", () => { + afterEach(() => clearContextWindowCache()); + + /** The `size` of each `usage_update` of a session's first turn. */ + async function firstTurnSizes( + modelUsage: Record = {}, + ): Promise> { + scriptTurns(async function* (options) { + yield assistantText(options, "Done."); + yield result(options, { modelUsage }); + yield idleState(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + }); + return client.updates("usage_update").map((update) => update.size); + } + + it("reports the window that Claude Code reads for the model", async () => { + sdk.contextUsage = async () => ({ totalTokens: 0, rawMaxTokens: 1_000_000 }); + expect(await firstTurnSizes()).toEqual([1_000_000]); + }); + + it("reports a window that another session's result reported for the model", async () => { + // The first session learns the window from its result. + expect( + await firstTurnSizes({ + "claude-sonnet-4-6": { + inputTokens: 3, + outputTokens: 2, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 0, + webSearchRequests: 0, + costUSD: 0, + contextWindow: 500_000, + maxOutputTokens: 32_000, + }, + }), + ).toEqual([500_000]); + // The second session cannot read its window from Claude Code. + sdk.contextUsage = () => Promise.reject(new Error("Unsupported control request")); + expect(await firstTurnSizes()).toEqual([500_000]); + }); + }); + + it("clears the Task plan when Claude Code starts a fresh conversation", async () => { + scriptTurns( + async function* (options) { + yield toolUseMessage(options, "toolu_task", "TaskCreate", { + subject: "Run tests", + description: "Run the test suite", + }); + yield { + ...toolResultMessage(options, "toolu_task", {}), + tool_use_result: { task: { id: "1", subject: "Run tests" } }, + }; + yield result(options); + yield idleState(options); + }, + async function* (options) { + yield { + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + timestamp: new Date().toISOString(), + session_id: options.sessionId, + }; + yield result(options, { num_turns: 0 }); + yield idleState(options); + }, + ); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("plan") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("/clear") }); + await vi.waitFor(() => + expect(turnTrace(client.sessionUpdates).filter((e) => e === "idle end_turn")).toHaveLength( + 2, + ), + ); + await client.authUpdate(1); + }); + + expect(client.updates("plan_update").at(-1)).toEqual({ + sessionUpdate: "plan_update", + plan: { + type: "items", + planId: "plan", + entries: [{ content: "Run tests", status: "pending", priority: "medium" }], + }, + }); + expect(client.updates("plan_removed")).toEqual([ + { sessionUpdate: "plan_removed", planId: "plan" }, + { sessionUpdate: "plan_removed", planId: "plan-mode" }, + ]); + }); + + it.each([ + ["a model with Auto", true, "auto"], + // Auto runs as its fallback, with no notice: the answer chose the mode. + ["a model without Auto", false, "acceptEdits"], + ])( + "carries out a plan accepted with clearing the context in a fresh context, in the same turn, on %s", + async (_case, supportsAutoMode, mode) => { + sdk.initializationResult = async () => ({ + models: [ + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Fast", + supportsAutoMode, + }, + ], + }); + /** + * Claude Code, as the bundled CLI answers each prompt: the client's + * prompt plans and calls ExitPlanMode, `/clear` mounts a fresh + * conversation, and the plan's prompt carries it out. Each echo carries + * the prompt's `origin`, as the bundled CLI's does. + */ + const prompts: string[] = []; + sdk.run = async function* (input, options) { + for await (const prompt of input) { + const content = prompt.message.content; + const promptText = + typeof content === "string" + ? content + : content.map((block) => (block.type === "text" ? block.text : "")).join(""); + prompts.push(promptText); + yield { + type: "user", + message: prompt.message, + parent_tool_use_id: null, + uuid: prompt.uuid, + session_id: options.sessionId, + isReplay: true, + origin: prompt.origin, + }; + if (promptText === "/clear") { + yield { + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + user_message_uuid: prompt.uuid, + timestamp: new Date().toISOString(), + session_id: options.sessionId, + }; + yield result(options, { + num_turns: 0, + stop_reason: null, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }); + yield idleState(options); + } else if (promptText.startsWith("Implement the following plan")) { + yield assistantText(options, "Implemented.", "msg_implemented"); + yield result(options, { stop_reason: "max_tokens" }); + yield idleState(options); + } else { + yield toolUseMessage(options, "toolu_plan", "ExitPlanMode", { plan: "Implement it" }); + const decision = await options.canUseTool!("ExitPlanMode", { plan: "Implement it" }, { + signal: new AbortController().signal, + toolUseID: "toolu_plan", + suggestions: [], + } as unknown as Parameters>[2]); + expect(decision).toMatchObject({ behavior: "deny", interrupt: true }); + yield { + ...toolResultMessage(options, "toolu_plan", { + content: "User accepted the plan and requested a fresh context", + is_error: true, + }), + tool_result_meta: [{ id: "toolu_plan", non_execution_kind: "user-rejected" }], + }; + yield result(options, { + subtype: "error_during_execution", + is_error: true, + stop_reason: null, + errors: [ + "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use", + ], + }); + yield idleState(options); + } + } + }; + const client = v2Client({ + answerPermission: () => ({ + outcome: { outcome: "selected", optionId: "exit-plan-clear-auto" }, + }), + }); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("plan") }); + await vi.waitFor(() => + expect(turnTrace(client.sessionUpdates)).toContain("idle max_tokens"), + ); + await client.authUpdate(1); + }); + + // The option says how much of the context is in use. + expect(client.permissionRequests[0].options).toContainEqual({ + optionId: "exit-plan-clear-auto", + name: "Yes, clear context (0% used) and use auto mode", + kind: "allow_always", + }); + expect(prompts.at(-1)).toBe("Implement the following plan:\n\nImplement it"); + // One turn: the adapter's prompts show no user message, and the turn ends with its result. + expect(client.updates("user_message_chunk")).toEqual([]); + expect(turnTrace(client.sessionUpdates)).toEqual([ + "user_message", + "running", + "requires_action", + "running", + "agent_message_chunk", + "idle max_tokens", + ]); + // The accepted plan's tool call completes, and the fresh context starts empty. + expect(client.updates("tool_call_update").at(-1)).toMatchObject({ + toolCallId: "toolu_plan", + status: "completed", + }); + expect(client.updates("usage_update").map(({ used }) => used)).toContain(0); + expect(client.updates("plan_update").at(-1)).toEqual({ + sessionUpdate: "plan_update", + plan: { type: "markdown", planId: "plan-mode", content: "Implement it" }, + }); + expect( + client + .updates("config_option_update") + .at(-1) + ?.configOptions.find((option) => option.configId === "mode")?.currentValue, + ).toBe(mode); + }, + ); }); describe("ACP v2 idle compaction", () => { @@ -3117,8 +4269,8 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => { const { root, updates } = await cancelCycle("toolu_child"); // The parent does not show the Agent call: the child's idle reports the - // stop. The command ends in the child's session, after the child's idle - // and before the cycle's. + // stop. The command ends in the child's session, before the child's + // idle, as v2 asks of the updates after a cancel. expect(statusesOf(updates, "toolu_agent")).toEqual([]); expect(updates[lastIdleAt(updates, "agent-1")]).toMatchObject({ update: { stopReason: "cancelled" }, @@ -3129,7 +4281,7 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => { "pending", "cancelled", ]); - expect(end).toBeGreaterThan(lastIdleAt(updates, "agent-1")); + expect(end).toBeLessThan(lastIdleAt(updates, "agent-1")); expect(updates[lastIdleAt(updates, root)]).toMatchObject({ update: { stopReason: "cancelled" }, }); @@ -4018,7 +5170,7 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => { sessionId, prompt: text("and test it"), }); - await vi.waitFor(() => expect(agents[0].sessions[sessionId]?.turnQueue).toHaveLength(2)); + await waitForQueuedPrompt(sessionId); await agent.notify(v2.methods.agent.session.cancel, { sessionId }); await expect(queued).rejects.toMatchObject({ code: -32800 }); }); @@ -4130,7 +5282,7 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => { sessionId, prompt: text("and write it down"), }); - await vi.waitFor(() => expect(agents[0].sessions[sessionId]?.turnQueue).toHaveLength(2)); + await waitForQueuedPrompt(sessionId); await agent.notify(v2.methods.agent.session.cancel, { sessionId }); await expect(queued).rejects.toMatchObject({ code: -32800 }); await vi.waitFor(() => expect(decision).toBeDefined()); @@ -4251,7 +5403,7 @@ describe("ACP v2 model cycles that Claude Code starts on its own", () => { const client = await promptAndWait(2, async (agent, sessionId, client) => { await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toEqual(endedTurn)); await vi.waitFor(() => - expect(agents[0].sessions[sessionId]?.modelRequestSinceResult).toBe(true), + expect(v2Session(sessionId).stopped.modelRequestSinceResult).toBe(true), ); await agent.notify(v2.methods.agent.session.cancel, { sessionId }); }); @@ -4402,6 +5554,744 @@ describe("ACP v2 requests that subagents make", () => { }); }); + describe("a subagent's session", () => { + /** Runs the hooks of `event` for a hook input of a subagent, as Claude Code does. */ + async function runHooks(options: Options, event: string, input: Record) { + const hooks = (options.hooks ?? {}) as Record< + string, + NonNullable["PreToolUse"] + >; + for (const matcher of hooks?.[event] ?? []) { + for (const hook of matcher.hooks) { + await hook( + { + hook_event_name: event, + session_id: options.sessionId!, + transcript_path: "", + cwd, + ...input, + } as never, + input.tool_use_id as string | undefined, + { signal: new AbortController().signal }, + ); + } + } + } + + /** Claude Code's report of the end of a run of the subagent `taskId`. */ + const taskNotification = ( + options: Options, + taskId: string, + fields: Record, + ) => ({ + type: "system", + subtype: "task_notification", + task_id: taskId, + tool_use_id: `toolu_${taskId}`, + status: "completed", + summary: "Done", + output_file: "", + uuid: randomUUID(), + session_id: options.sessionId, + ...fields, + }); + + /** A message of the subagent that the Agent call `parent` launched. */ + const childMessage = (options: Options, message: Record, parent: string) => ({ + ...message, + parent_tool_use_id: parent, + }); + + /** The session messages, as ` -> `. */ + const sessionMessages = (client: ReturnType, root: string) => + client.sessionUpdates.flatMap(({ sessionId, update }) => { + if (update.sessionUpdate !== "session_message") return []; + const name = (id: string | null | undefined) => (id === root ? "root" : id); + const { messageId, senderSessionId, recipientSessionId, content } = update as { + messageId: string; + senderSessionId?: string; + recipientSessionId: string; + content: Array<{ text: string }>; + }; + return [ + `${name(sessionId)} ${messageId} ${name(senderSessionId)} -> ${name(recipientSessionId)} ${content[0]?.text}`, + ]; + }); + + /** The tool calls the client was shown, by id. */ + const shownToolCalls = (client: ReturnType) => [ + ...new Set( + client.sessionUpdates.flatMap(({ update }) => + update.sessionUpdate === "tool_call_update" + ? [(update as { toolCallId: string }).toolCallId] + : [], + ), + ), + ]; + + it("reports the mode, effort and agent type of a subagent's hooks, with its model, as its configuration", async () => { + scriptTurns(async function* (options) { + yield toolUseMessage(options, "toolu_agent-1", "Agent", { + description: "Explore the project", + prompt: "Look around", + subagent_type: "Explore", + }); + yield taskStarted(options, "agent-1", { prompt: "Look around" }); + await runHooks(options, "PostToolUse", { + agent_id: "agent-1", + agent_type: "Explore", + permission_mode: "acceptEdits", + effort: { level: "high" }, + tool_name: "Read", + tool_input: {}, + tool_response: {}, + tool_use_id: "toolu_read", + }); + yield childMessage(options, assistantText(options, "Done.", "msg_child"), "toolu_agent-1"); + yield taskNotification(options, "agent-1", {}); + yield result(options); + yield idleState(options); + }); + const client = await promptToIdle(v2Client()); + + const configurations = client.sessionUpdates.flatMap(({ sessionId, update }) => + sessionId === "agent-1" && update.sessionUpdate === "config_option_update" + ? [ + ( + update as { configOptions: Array<{ configId: string; currentValue: unknown }> } + ).configOptions.map(({ configId, currentValue }) => `${configId}=${currentValue}`), + ] + : [], + ); + // The hook's options, then all of them again with the model of the + // child's response, the mode first. + expect(configurations).toEqual([ + ["mode=acceptEdits", "effort=high", "agent=Explore"], + ["mode=acceptEdits", "model=claude-sonnet-4-6", "effort=high", "agent=Explore"], + ]); + }); + + it("sends a live child's configuration again after a replay's response, when the replay sent another", async () => { + scriptTurns(async function* (options) { + yield toolUseMessage(options, "toolu_agent-1", "Agent", { + description: "Explore the project", + prompt: "Look around", + subagent_type: "Explore", + }); + yield taskStarted(options, "agent-1", { prompt: "Look around" }); + await runHooks(options, "PostToolUse", { + agent_id: "agent-1", + agent_type: "Explore", + permission_mode: "acceptEdits", + tool_name: "Read", + tool_input: {}, + tool_response: {}, + tool_use_id: "toolu_read", + }); + yield childMessage(options, assistantText(options, "Done.", "msg_child"), "toolu_agent-1"); + yield taskNotification(options, "agent-1", {}); + yield result(options); + yield idleState(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await writeSubagentHistory( + sessionId, + [transcriptRecord(sessionId, "r1", null, { role: "user", content: "hi" })], + "agent-1", + { agentType: "Explore", toolUseId: "toolu_agent-1" }, + [ + transcriptRecord(sessionId, "c1", null, { role: "user", content: "Look around" }), + transcriptRecord(sessionId, "c2", "c1", { + id: "msg_child", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "Done." }], + }), + ], + ); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + transcriptEntry("assistant", randomUUID(), { + id: "msg_agent", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_agent-1", + name: "Agent", + input: { subagent_type: "Explore", prompt: "Look around" }, + }, + ], + }), + transcriptEntry("user", randomUUID(), { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_agent-1", content: "Done." }], + }), + ] as unknown as Awaited>); + client.sessionUpdates.length = 0; + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: { type: "start" }, + }); + client.sessionUpdates.push({ + sessionId: "agent-1", + update: { sessionUpdate: "response" }, + } as never); + await vi.waitFor(() => + expect(client.sessionUpdates.at(-1)?.update.sessionUpdate).toBe("config_option_update"), + ); + await client.authUpdate(1); + }); + + const configurations = client.sessionUpdates.flatMap(({ sessionId, update }) => { + if (sessionId !== "agent-1") return []; + if (update.sessionUpdate === "response") return ["response"]; + if (update.sessionUpdate !== "config_option_update") return []; + return [ + ( + update as { configOptions: Array<{ configId: string; currentValue: unknown }> } + ).configOptions + .map(({ configId, currentValue }) => `${configId}=${currentValue}`) + .join(" "), + ]; + }); + // The replay sends the model of the child's response. The live child's + // configuration has its mode too, so it goes again after the response. + expect(configurations).toEqual([ + "model=claude-sonnet-4-6", + "response", + "mode=acceptEdits model=claude-sonnet-4-6 agent=Explore", + ]); + }); + + it("names a replayed child's model by the session's model list, on a resume of a closed session", async () => { + scriptTurns(); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.close, { sessionId }); + await writeSubagentHistory( + sessionId, + [transcriptRecord(sessionId, "r1", null, { role: "user", content: "hi" })], + "agent-1", + { agentType: "Explore", toolUseId: "toolu_agent-1" }, + [ + transcriptRecord(sessionId, "c1", null, { role: "user", content: "Look around" }), + transcriptRecord(sessionId, "c2", "c1", { + id: "msg_child", + role: "assistant", + model: "claude-sonnet-4-6", + content: [{ type: "text", text: "Done." }], + }), + ], + ); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + transcriptEntry("assistant", randomUUID(), { + id: "msg_agent", + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_agent-1", + name: "Agent", + input: { subagent_type: "Explore", prompt: "Look around" }, + }, + ], + }), + transcriptEntry("user", randomUUID(), { + role: "user", + content: [{ type: "tool_result", tool_use_id: "toolu_agent-1", content: "Done." }], + }), + ] as unknown as Awaited>); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: { type: "start" }, + }); + client.sessionUpdates.push({ + sessionId: "agent-1", + update: { sessionUpdate: "response" }, + } as never); + // The child's current state follows the response. + await vi.waitFor(() => + expect( + client.sessionUpdates.filter( + ({ sessionId, update }) => + sessionId === "agent-1" && update.sessionUpdate === "state_update", + ), + ).toHaveLength(2), + ); + await client.authUpdate(1); + }); + + const models = client.sessionUpdates.flatMap(({ sessionId, update }) => { + if (sessionId !== "agent-1") return []; + if (update.sessionUpdate === "response") return ["response"]; + if (update.sessionUpdate !== "config_option_update") return []; + const [option] = ( + update as { + configOptions: Array<{ currentValue: unknown; options: Array<{ name: string }> }>; + } + ).configOptions; + return [`${option!.currentValue} ${option!.options[0]!.name}`]; + }); + // The core replays after Claude Code started, so the model goes as the + // model list's entry at once. + expect(models).toEqual(["claude-sonnet-4-6 Claude Sonnet 4.6", "response"]); + }); + + it("drops a foreground child's consolidated messages after the root's cancel, except the results of its shown tool calls", async () => { + const interrupted = Promise.withResolvers(); + sdk.interrupt = async () => interrupted.resolve(); + const child = (message: Record) => + childMessage(options!, message, "toolu_agent-1"); + let options: Options | undefined; + scriptTurns(async function* (turnOptions) { + options = turnOptions; + yield toolUseMessage(turnOptions, "toolu_agent-1", "Agent", { + description: "Explore the project", + prompt: "Look around", + }); + yield taskStarted(turnOptions, "agent-1", { prompt: "Look around" }); + yield child( + stream(turnOptions, { + type: "message_start", + message: { id: "msg_child", role: "assistant", content: [], usage: {} }, + }), + ); + yield child( + stream(turnOptions, { + type: "content_block_start", + index: 0, + content_block: { type: "text", text: "" }, + }), + ); + yield child( + stream(turnOptions, { + type: "content_block_delta", + index: 0, + delta: { type: "text_delta", text: "Working on" }, + }), + ); + yield child( + stream(turnOptions, { + type: "content_block_start", + index: 1, + content_block: { type: "tool_use", id: "toolu_shown", name: "Bash", input: {} }, + }), + ); + await interrupted.promise; + // What the child had when the interrupt arrived: the consolidated + // message, with the text's tail and a tool use that never streamed. + yield child({ + ...assistantText(turnOptions, "", "msg_child"), + message: { + ...assistantText(turnOptions, "", "msg_child").message, + content: [ + { type: "text", text: "Working on it" }, + { type: "tool_use", id: "toolu_shown", name: "Bash", input: { command: "ls" } }, + { type: "tool_use", id: "toolu_unseen", name: "Bash", input: { command: "pwd" } }, + ], + stop_reason: "tool_use", + }, + }); + yield child({ + type: "user", + message: { + role: "user", + content: ["toolu_shown", "toolu_unseen"].map((id) => ({ + type: "tool_result", + tool_use_id: id, + content: "[Request interrupted by user for tool use]", + is_error: true, + })), + }, + uuid: randomUUID(), + session_id: turnOptions.sessionId, + }); + yield taskNotification(turnOptions, "agent-1", { status: "stopped" }); + yield toolResultMessage(turnOptions, "toolu_agent-1", { + content: "[Request interrupted by user for tool use]", + is_error: true, + }); + yield result(turnOptions, ABORTED_TOOLS); + yield idleState(turnOptions); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go") }); + await vi.waitFor(() => + expect(client.updates("tool_call_update").map((u) => u.toolCallId)).toContain( + "toolu_shown", + ), + ); + + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + + await vi.waitFor(() => + expect(turnTrace(client.sessionUpdates)).toContain("idle cancelled"), + ); + await client.authUpdate(1); + }); + + const ofChild = client.sessionUpdates.flatMap(({ sessionId, update }) => + sessionId === "agent-1" ? [update] : [], + ); + expect( + ofChild.flatMap((update) => + update.sessionUpdate === "agent_message_chunk" + ? [(update.content as { text: string }).text] + : [], + ), + ).toEqual(["Working on"]); + const toolCalls = ofChild.flatMap((update) => + update.sessionUpdate === "tool_call_update" + ? [update as { toolCallId: string; status?: string }] + : [], + ); + expect(toolCalls.some((update) => update.toolCallId === "toolu_unseen")).toBe(false); + expect( + toolCalls.filter((update) => update.toolCallId === "toolu_shown").at(-1), + ).toMatchObject({ status: "cancelled" }); + }); + + it("ends the subagents that work when Claude Code starts a fresh conversation", async () => { + scriptTurns( + async function* (options) { + yield toolUseMessage(options, "toolu_agent-1", "Agent", { + description: "Explore the project", + prompt: "Look around", + run_in_background: true, + }); + yield taskStarted(options, "agent-1", { prompt: "Look around", is_backgrounded: true }); + yield childMessage( + options, + toolUseMessage(options, "toolu_ls", "Bash", { command: "ls" }), + "toolu_agent-1", + ); + yield toolResultMessage(options, "toolu_agent-1", { content: "Launched." }); + yield result(options); + yield idleState(options); + }, + async function* (options) { + yield { + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + timestamp: new Date().toISOString(), + session_id: options.sessionId, + }; + // The reset forgot the child: its later end changes nothing. + yield taskNotification(options, "agent-1", {}); + yield result(options, { num_turns: 0 }); + yield idleState(options); + }, + ); + const client = v2Client(); + let root = ""; + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + root = sessionId; + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("go") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + client.sessionUpdates.length = 0; + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("/clear") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + }); + + const trace = client.sessionUpdates.flatMap(({ sessionId, update }) => { + const on = sessionId === root ? "root" : sessionId; + const state = (value: unknown) => { + const { state, stopReason } = value as { state: string; stopReason?: string }; + return `${state}${stopReason ? ` ${stopReason}` : ""}`; + }; + switch (update.sessionUpdate) { + case "subagent_update": + return update.state + ? [`${on} subagent ${update.sessionId} ${state(update.state)}`] + : []; + case "state_update": { + const listed = ( + (update as { background?: Array<{ sessionId?: string }> }).background ?? [] + ) + .map((item) => item.sessionId) + .join(","); + return [`${on} ${state(update)} [${listed}]`]; + } + case "tool_call_update": { + const { toolCallId, status } = update as { toolCallId: string; status?: string }; + return status ? [`${on} ${toolCallId} ${status}`] : []; + } + default: + return []; + } + }); + // The child's run ends with no stop reason: nothing says how. Its tool + // call ends first, as v2 asks. + expect(trace).toEqual([ + "root running []", + "agent-1 toolu_ls failed", + "root subagent agent-1 idle", + "agent-1 idle []", + "root idle end_turn []", + ]); + }); + + it("sends a SendMessage call's message and a SubagentHandback call's report as session messages, instead of the calls", async () => { + scriptTurns(async function* (options) { + yield toolUseMessage(options, "toolu_agent-1", "Agent", { + description: "Explore the project", + prompt: "Look around", + }); + yield taskStarted(options, "agent-1", { prompt: "Look around" }); + yield toolUseMessage( + options, + "toolu_handback", + "SubagentHandback", + { message: "Found it." }, + "toolu_agent-1", + ); + yield childMessage( + options, + toolResultMessage(options, "toolu_handback", { content: "Delivered" }), + "toolu_agent-1", + ); + yield taskNotification(options, "agent-1", {}); + // The run handed back its report: its result is no reply. + yield toolResultMessage(options, "toolu_agent-1", { + content: [{ type: "text", text: "The report was delivered as a message." }], + }); + yield toolUseMessage(options, "toolu_send", "SendMessage", { + to: "agent-1", + message: "Look again", + }); + yield taskStarted(options, "agent-1", { + tool_use_id: "toolu_send", + prompt: "Look again", + is_backgrounded: true, + }); + yield toolResultMessage(options, "toolu_send", { content: "Message sent" }); + yield taskNotification(options, "agent-1", { + tool_use_id: "toolu_send", + }); + yield result(options); + yield idleState(options); + }); + const client = await promptToIdle(v2Client()); + const root = client.sessionUpdates.find( + ({ update }) => update.sessionUpdate === "user_message", + )!.sessionId; + + expect(sessionMessages(client, root)).toEqual([ + "root toolu_agent-1 root -> agent-1 Look around", + "agent-1 toolu_agent-1 root -> agent-1 Look around", + "agent-1 toolu_handback agent-1 -> root Found it.", + "root toolu_handback agent-1 -> root Found it.", + "root toolu_send root -> agent-1 Look again", + "agent-1 toolu_send root -> agent-1 Look again", + ]); + expect(shownToolCalls(client)).toEqual([]); + }); + + it("shows a SendMessage call whose message reached no child session as a tool call", async () => { + scriptTurns(async function* (options) { + yield toolUseMessage(options, "toolu_send", "SendMessage", { + to: "team-lead", + message: "Status?", + }); + yield toolResultMessage(options, "toolu_send", { content: "Message sent" }); + yield result(options); + yield idleState(options); + }); + const client = await promptToIdle(v2Client()); + + expect(client.updates("tool_call_update")).toContainEqual( + expect.objectContaining({ toolCallId: "toolu_send", status: "completed" }), + ); + expect(client.updates("session_message")).toEqual([]); + }); + + it.each([ + // The client holds the call from the request: it keeps getting it, and + // the message goes to the child only, and names its sender. + ["before", 1, ["agent-1 toolu_send root -> agent-1 Look again"]], + [ + "after", + 0, + [ + "root toolu_send root -> agent-1 Look again", + "agent-1 toolu_send root -> agent-1 Look again", + ], + ], + ])( + "shows a SendMessage call only when its permission request came %s its tool use streamed", + async (order, completedUpdates, delivery) => { + scriptTurns(async function* (options) { + yield toolUseMessage(options, "toolu_agent-1", "Agent", { + description: "Explore the project", + prompt: "Look around", + }); + yield taskStarted(options, "agent-1", { prompt: "Look around" }); + yield taskNotification(options, "agent-1", {}); + yield toolResultMessage(options, "toolu_agent-1", { + content: [{ type: "text", text: "Found it." }], + }); + const ask = () => + options.canUseTool!("SendMessage", { to: "agent-1", message: "Look again" }, { + signal: new AbortController().signal, + suggestions: [], + toolUseID: "toolu_send", + requestId: randomUUID(), + } as unknown as Parameters>[2]); + if (order === "before") await ask(); + yield toolUseMessage(options, "toolu_send", "SendMessage", { + to: "agent-1", + message: "Look again", + }); + if (order === "after") await ask(); + yield taskStarted(options, "agent-1", { + tool_use_id: "toolu_send", + prompt: "Look again", + is_backgrounded: true, + }); + yield toolResultMessage(options, "toolu_send", { content: "Message sent" }); + yield taskNotification(options, "agent-1", { tool_use_id: "toolu_send" }); + yield result(options); + yield idleState(options); + }); + const client = await promptToIdle( + v2Client({ + answerPermission: (request) => ({ + outcome: { + outcome: "selected", + optionId: request.options.find((option) => option.kind === "allow_once")!.optionId, + }, + }), + }), + ); + const root = client.sessionUpdates.find( + ({ update }) => update.sessionUpdate === "user_message", + )!.sessionId; + + expect(client.permissionRequests).toHaveLength(1); + expect( + client.sessionUpdates.filter( + ({ sessionId, update }) => + sessionId === root && + update.sessionUpdate === "tool_call_update" && + (update as { toolCallId: string }).toolCallId === "toolu_send" && + (update as { status?: string }).status === "completed", + ), + ).toHaveLength(completedUpdates); + expect(sessionMessages(client, root).slice(3)).toEqual(delivery); + }, + ); + + it.each(["its task_started", "the resumedAgentId of its result alone"])( + "restores a subagent that a SendMessage call resumes from the session's subagent history, at %s", + async (signal) => { + const started = signal === "its task_started"; + scriptTurns(async function* (options) { + // Claude Code no longer tracks the agent, from before a restart: + // its history names the call that launched it from the root. + const sessionId = options.sessionId!; + await writeSubagentHistory( + sessionId, + [ + transcriptRecord(sessionId, "r1", null, { + role: "assistant", + content: [ + { + type: "tool_use", + id: "toolu_old", + name: "Agent", + input: { name: "scout", prompt: "Look around" }, + }, + ], + }), + ], + "abc123", + { agentType: "Explore", name: "scout", toolUseId: "toolu_old" }, + [transcriptRecord(sessionId, "c1", null, { role: "user", content: "Look around" })], + ); + yield toolUseMessage(options, "toolu_send", "SendMessage", { + to: "scout", + message: "Look again", + }); + if (started) { + yield taskStarted(options, "abc123", { + tool_use_id: "toolu_send", + prompt: "Look again", + subagent_type: "Explore", + is_backgrounded: true, + }); + } else { + // Claude Code can resume an agent without a `task_started`. + yield { + ...toolResultMessage(options, "toolu_send", { content: "Message sent" }), + tool_use_result: { success: true, message: "Resumed", resumedAgentId: "abc123" }, + }; + } + // The resumed run's messages carry the call that launched it. + yield childMessage( + options, + assistantText(options, "Still fine.", "msg_child"), + "toolu_old", + ); + if (started) yield toolResultMessage(options, "toolu_send", { content: "Message sent" }); + yield taskNotification(options, "abc123", { + tool_use_id: "toolu_send", + }); + yield result(options); + yield idleState(options); + }); + const client = await promptToIdle(v2Client()); + const sessionId = client.sessionUpdates.find( + ({ update }) => update.sessionUpdate === "user_message", + )!.sessionId; + + expect( + client.sessionUpdates.flatMap(({ sessionId: on, update }) => { + const where = on === sessionId ? "root" : on; + if (update.sessionUpdate === "subagent_update") { + const { title, state } = update as { title?: string; state?: { state: string } }; + return [`${where} subagent_update ${title ?? ""} ${state?.state ?? ""}`]; + } + if (on === sessionId) { + return update.sessionUpdate === "session_message" ? [`${where} session_message`] : []; + } + return update.sessionUpdate === "state_update" + ? [] + : [`${where} ${update.sessionUpdate}`]; + }), + ).toEqual([ + "root subagent_update scout running", + "root session_message", + "abc123 session_message", + "abc123 config_option_update", + "abc123 agent_message_chunk", + "root subagent_update idle", + ]); + expect(sessionMessages(client, sessionId)).toEqual([ + "root toolu_send root -> abc123 Look again", + "abc123 toolu_send root -> abc123 Look again", + ]); + expect(shownToolCalls(client)).toEqual([]); + }, + ); + }); + it("shows a subagent's checklist in the subagent's session, apart from the root's", async () => { const todoWrite = ( options: Options, @@ -5586,7 +7476,7 @@ describe("ACP v2 requests that a cancel withdraws", () => { await wait(client); // The first is still open, so the answer changes nothing. answer.resolve(allowOnce); - await vi.waitFor(() => expect(agents[0].sessions[sessionId]?.openUserInputs?.size).toBe(1)); + await vi.waitFor(() => expect(v2Session(sessionId).requests.size).toBe(1)); await agent.notify(v2.methods.agent.session.cancel, { sessionId }); }); @@ -6734,13 +8624,6 @@ describe("ACP v2 session translation", () => { expect(v1ForkSessionRequest({ sessionId: "s", cwd, systemPrompt }).systemPrompt).toEqual( v1SystemPrompt, ); - expect( - v1RestoreSessionRequest({ sessionId: "s", cwd, systemPrompt }).request.systemPrompt, - ).toEqual(v1SystemPrompt); - expect( - v1RestoreSessionRequest({ sessionId: "s", cwd, systemPrompt, replayFrom: { type: "start" } }) - .request.systemPrompt, - ).toEqual(v1SystemPrompt); }); it("rejects a config option value type that v1 cannot express", () => { @@ -6792,21 +8675,6 @@ describe("ACP v2 session translation", () => { expect(v2SessionUpdate(chunk)).toEqual(chunk); }); - it("reports a subagent's work state on the child's stream as well", () => { - const failed = { - state: "idle" as const, - stopReason: "error" as const, - error: { code: -32603, message: "Overloaded" }, - }; - expect( - v2ChildStateUpdate({ sessionUpdate: "subagent_update", sessionId: "agent-1", state: failed }), - ).toEqual({ sessionId: "agent-1", update: { sessionUpdate: "state_update", ...failed } }); - // An update of the association that reports no state has no copy. - expect( - v2ChildStateUpdate({ sessionUpdate: "subagent_update", sessionId: "agent-1", title: "X" }), - ).toBeUndefined(); - }); - it("fails on a subagent state that the subagents RFD does not define", () => { expect(() => v2SessionUpdate({ @@ -7337,6 +9205,137 @@ describe("ACP v2 providers", () => { await authUpdate(1); }); }); + + const GATEWAY = "https://gateway.example.com"; + const setGateway = (agent: v2.ClientContext) => + agent.request(v2.methods.agent.providers.set, { + providerId: "main", + apiType: "anthropic", + baseUrl: GATEWAY, + }); + + /** + * Claude Code echoes each prompt and answers it. A turn of a query without + * the gateway's route waits for `release` first. + */ + function answerEachPrompt(release: Promise): Array<{ text: string; routed: boolean }> { + const answered: Array<{ text: string; routed: boolean }> = []; + sdk.run = async function* (prompts, options) { + const routed = options.env?.ANTHROPIC_BASE_URL === GATEWAY; + for await (const prompt of prompts) { + yield { + type: "user", + message: prompt.message, + parent_tool_use_id: null, + uuid: prompt.uuid, + session_id: options.sessionId, + isReplay: true, + }; + if (!routed) await release; + const content = prompt.message.content; + answered.push({ + text: typeof content === "string" ? content : (content[0] as { text: string }).text, + routed, + }); + yield assistantText(options, "done"); + yield result(options); + } + }; + return answered; + } + + it("answers a provider set while a session works, and routes the session at its turn boundary", async () => { + const release = Promise.withResolvers(); + const answered = answerEachPrompt(release.promise); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "mode", + type: "id", + value: "acceptEdits", + }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("first") }); + await setGateway(agent); + // The running turn keeps its query, and so does a prompt sent meanwhile. + expect(sdk.queryOptions).toHaveLength(1); + const second = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: text("second"), + }); + // The second prompt reaches the session before the first turn ends. + await new Promise((resolve) => setTimeout(resolve, 20)); + release.resolve(); + await second; + await vi.waitFor(() => expect(sdk.queryOptions).toHaveLength(2)); + expect(sdk.queryOptions[1]).toMatchObject({ + resume: sessionId, + env: { ANTHROPIC_BASE_URL: GATEWAY }, + }); + // A prompt during the restart goes to the new query. + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("third") }); + // The new query keeps the mode the client chose. + expect( + client + .updates("config_option_update") + .at(-1) + ?.configOptions.find((option) => option.configId === "mode"), + ).toMatchObject({ currentValue: "acceptEdits" }); + await vi.waitFor(() => expect(answered).toHaveLength(3)); + await client.authUpdate(1); + }); + + expect(answered).toEqual([ + { text: "first", routed: false }, + { text: "second", routed: false }, + { text: "third", routed: true }, + ]); + }); + + it.each([ + [ + "providers/disable", + (agent: v2.ClientContext) => + agent.request(v2.methods.agent.providers.disable, { providerId: "main" }), + ], + ["auth/logout", (agent: v2.ClientContext) => agent.request(v2.methods.agent.auth.logout, {})], + ])("routes a live session off the provider after %s", async (_method, leave) => { + answerEachPrompt(Promise.resolve()); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + await setGateway(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await leave(agent); + expect(sdk.queryOptions.map((options) => options.env?.ANTHROPIC_BASE_URL)).toEqual([ + GATEWAY, + undefined, + ]); + expect(sdk.queryOptions[1]).toMatchObject({ resume: sessionId }); + await client.authUpdate(1); + }); + }); + + it("routes only the sessions started after a gateway login", async () => { + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent, { auth: { _meta: { gateway: true } } }); + await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.auth.login, { + methodId: "gateway", + _meta: { gateway: { baseUrl: GATEWAY, headers: {} } }, + }); + await agent.request(v2.methods.agent.session.new, { cwd }); + await client.authUpdate(2); + }); + + expect(sdk.queryOptions.map((options) => options.env?.ANTHROPIC_BASE_URL)).toEqual([ + undefined, + GATEWAY, + ]); + }); }); describe("ACP v2 auth", () => { @@ -7393,6 +9392,369 @@ describe("ACP v2 auth", () => { }); }); +describe("ACP v2 subscription guard (--hide-claude-auth)", () => { + const SUBSCRIPTION = { + email: "user@example.com", + subscriptionType: "max", + apiProvider: "firstParty", + }; + const API_KEY = { apiKeySource: "ANTHROPIC_API_KEY", apiProvider: "firstParty" }; + const NOT_SUPPORTED = { code: -32000, data: { reason: "claude_subscription_not_supported" } }; + + beforeEach(() => { + const argv = process.argv; + process.argv = [...argv, "--hide-claude-auth"]; + // The guard judges the account that `claude auth status` reports. A + // real-CLI run (`npm run test:integration`) would otherwise probe the + // machine's own login, so these tests read only the scripted accounts. + vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false"); + return () => { + process.argv = argv; + vi.unstubAllEnvs(); + }; + }); + + /** Each query caches the next of `accounts` at its start, and the last one after them. */ + function accountsAtStart(...accounts: Array>) { + let started = 0; + sdk.initializationResult = async () => ({ + ...(await sdkDefaults.initializationResult()), + account: accounts[Math.min(started++, accounts.length - 1)], + }); + } + + it.each([ + ["a subscription would pay", SUBSCRIPTION, NOT_SUPPORTED], + ["the account holds no credential", {}, { code: -32000, data: undefined }], + ])("refuses session/new when %s", async (_case, account, refusal) => { + accountsAtStart(account); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + await expect(agent.request(v2.methods.agent.session.new, { cwd })).rejects.toMatchObject( + refusal, + ); + }); + }); + + it("refuses a prompt before insertion when the cached account bills a subscription", async () => { + accountsAtStart(API_KEY); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + sdk.accountInfo = async () => SUBSCRIPTION; + await expect( + agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }), + ).rejects.toMatchObject(NOT_SUPPORTED); + await client.authUpdate(1); + }); + + expect(client.updates("user_message")).toEqual([]); + }); + + it.each([ + [ + "session/cancel", + (agent: v2.ClientContext, sessionId: string) => + agent.notify(v2.methods.agent.session.cancel, { sessionId }), + ], + [ + "session/close", + (agent: v2.ClientContext, sessionId: string) => + agent.request(v2.methods.agent.session.close, { sessionId }), + ], + ])("answers a prompt that the guard still judges with -32800 on %s", async (_method, stop) => { + accountsAtStart(API_KEY); + scriptTurns(async function* (options) { + yield result(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + const reading = Promise.withResolvers(); + const account = Promise.withResolvers>(); + sdk.accountInfo = () => { + reading.resolve(); + return account.promise; + }; + const prompt = agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }) + .catch((error: unknown) => error); + await reading.promise; + const stopped = stop(agent, sessionId); + await new Promise((resolve) => setTimeout(resolve, 20)); + account.resolve(API_KEY); + expect(await prompt).toMatchObject({ code: -32800 }); + await stopped; + await client.authUpdate(1); + }); + + expect(client.updates("user_message")).toEqual([]); + }); + + it("starts the query again after a sign-out, and refuses the next prompt on the account it finds", async () => { + accountsAtStart(API_KEY, SUBSCRIPTION); + sdk.accountInfo = async () => API_KEY; + scriptTurns(async function* (options) { + yield result(options, { is_error: true, result: "Not logged in · Please run /login" }); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle error")); + await expect( + agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("again") }), + ).rejects.toMatchObject(NOT_SUPPORTED); + expect(sdk.queryOptions).toHaveLength(2); + expect(sdk.queryOptions[1]).toMatchObject({ resume: sessionId }); + await client.authUpdate(1); + }); + + expect(client.updates("user_message")).toHaveLength(1); + }); + + it("restarts the query for a provider change only after the account restart that runs", async () => { + accountsAtStart(API_KEY); + sdk.accountInfo = async () => API_KEY; + scriptTurns( + async function* (options) { + yield result(options, { is_error: true, result: "Not logged in · Please run /login" }); + }, + async function* (options) { + yield result(options); + }, + ); + // Whether the prompt input of each query is still open: a session ends its query's input. + const inputOpen: boolean[] = []; + const run = sdk.run; + sdk.run = (prompts, options) => { + const index = inputOpen.push(true) - 1; + return run( + (async function* () { + yield* prompts; + inputOpen[index] = false; + })(), + options, + ); + }; + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle error")); + // The account restart's query takes a while to start. + const started = Promise.withResolvers(); + const initialization = sdk.initializationResult; + sdk.initializationResult = async () => { + await started.promise; + return initialization(); + }; + const again = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: text("again"), + }); + await vi.waitFor(() => expect(sdk.queryOptions).toHaveLength(2)); + // The session's account restart runs: the route restart is due after it. + await agent.request(v2.methods.agent.providers.set, { + providerId: "main", + apiType: "anthropic", + baseUrl: "https://gateway.example.com", + }); + started.resolve(); + await again; + // One query of the session is left: none of the others was left running. + await vi.waitFor(() => expect(inputOpen).toHaveLength(3)); + await vi.waitFor(() => expect(inputOpen.filter((open) => open)).toHaveLength(1)); + await client.authUpdate(1); + }); + }); +}); + +describe("ACP v2 Remote Control", () => { + const SESSION_URL = "https://claude.ai/code/session_123"; + + beforeEach(() => { + sdk.enableRemoteControl = vi.fn(async () => ({ session_url: SESSION_URL })); + }); + + it("lists /remote-control when the query serves Remote Control", async () => { + const client = v2Client(); + const response = await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + return agent.request(v2.methods.agent.session.new, { cwd }); + }); + + expect(response.availableCommands).toContainEqual( + expect.objectContaining({ type: "command", name: "remote-control" }), + ); + }); + + it("connects with /remote-control and answers with the session URL", async () => { + const client = v2Client(); + const response = await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + const response = await agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: text("/remote-control laptop"), + }); + await vi.waitFor(() => expect(sdk.enableRemoteControl).toHaveBeenCalled()); + await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn")); + await client.authUpdate(1); + return response; + }); + + expect(sdk.enableRemoteControl).toHaveBeenCalledExactlyOnceWith(true, "laptop"); + expect(client.updates("user_message")).toEqual([ + { + sessionUpdate: "user_message", + messageId: response.messageId, + content: text("/remote-control laptop"), + }, + ]); + expect(client.updates("agent_message_chunk")[0].content).toMatchObject({ + text: expect.stringContaining(SESSION_URL), + }); + // v2 asks a local command to report `running`, then `idle`. + expect(turnTrace(client.sessionUpdates)).toEqual([ + "user_message", + "running", + "agent_message_chunk", + "idle end_turn", + ]); + }); + + // v2 asks that held prompts be inserted in the order received. + it("inserts a prompt that the subscription guard judges before a /remote-control sent after it", async () => { + const argv = process.argv; + process.argv = [...argv, "--hide-claude-auth"]; + const apiKey = { apiKeySource: "ANTHROPIC_API_KEY", apiProvider: "firstParty" }; + sdk.initializationResult = async () => ({ + ...(await sdkDefaults.initializationResult()), + account: apiKey, + }); + const account = Promise.withResolvers>(); + sdk.accountInfo = () => account.promise; + scriptTurns(async function* (options) { + yield result(options); + }); + const client = v2Client(); + try { + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + const first = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: text("fix the bug"), + }); + const command = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: text("/remote-control"), + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + account.resolve(apiKey); + await Promise.all([first, command]); + await vi.waitFor(() => expect(sdk.enableRemoteControl).toHaveBeenCalled()); + await client.authUpdate(1); + }); + } finally { + process.argv = argv; + } + + expect(client.updates("user_message").map((message) => message.content)).toEqual([ + text("fix the bug"), + text("/remote-control"), + ]); + }); + + it("runs /remote-control sent while Claude works once the turn ends", async () => { + const release = Promise.withResolvers(); + scriptTurns(async function* (options) { + await release.promise; + yield result(options); + }); + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("work") }); + const command = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: text("/remote-control"), + }); + // v2 asks to hold a prompt rather than reject it while Claude works. + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(sdk.enableRemoteControl).not.toHaveBeenCalled(); + release.resolve(); + await command; + await vi.waitFor(() => + expect(turnTrace(client.sessionUpdates)).toEqual([ + "user_message", + "running", + "idle end_turn", + "user_message", + "running", + "agent_message_chunk", + "idle end_turn", + ]), + ); + await client.authUpdate(1); + }); + }); + + it("shows a message typed in another client of the session as a user message", async () => { + sdk.run = async function* (prompts, options) { + const input = prompts[Symbol.asyncIterator](); + const { value } = await input.next(); + yield { + type: "user", + message: value.message, + parent_tool_use_id: null, + uuid: value.uuid, + session_id: options.sessionId, + isReplay: true, + origin: { kind: "human" }, + }; + yield result(options); + // Claude Code echoes a prompt that a person typed in claude.ai, and runs it. + yield { + type: "user", + message: { role: "user", content: [{ type: "text", text: "typed on my phone" }] }, + parent_tool_use_id: null, + uuid: "remote-message", + session_id: options.sessionId, + isReplay: true, + origin: { kind: "human" }, + }; + yield assistantText(options, "On it.", "msg_remote"); + yield result(options); + while (!(await input.next()).done); + }; + const client = v2Client(); + await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") }); + await vi.waitFor(() => expect(client.updates("agent_message_chunk")).toHaveLength(1)); + await client.authUpdate(1); + }); + + // Only the other client's message: the echo of the session's own prompt is its user_message. + expect(client.updates("user_message_chunk")).toEqual([ + { + sessionUpdate: "user_message_chunk", + messageId: "remote-message", + content: { type: "text", text: "typed on my phone" }, + }, + ]); + }); +}); + async function previewToolUse( options: Options, tool: { id: string; name: string; input: unknown; agentId?: string }, diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index eac6fe5f..a453d92a 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -176,25 +176,19 @@ describe("createSession options merging", () => { }); }); - it("registers the PreToolUse hook of the change previews only on a v2 connection", async () => { + it("registers no PreToolUse hook of change previews", async () => { await agent.newSession({ cwd: process.cwd(), mcpServers: [] }); // A v1 client gets the input's diff, so Claude Code waits on no hook. expect(capturedOptions!.hooks?.PreToolUse).toBeUndefined(); - - const v2Agent = new ClaudeAcpAgent(createMockClient(), undefined, { v2: true }); - await v2Agent.newSession({ cwd: process.cwd(), mcpServers: [] }); - expect(capturedOptions!.hooks?.PreToolUse).toEqual([ - { matcher: "Edit|Write", hooks: [expect.any(Function)] }, - ]); }); - it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => { + it("reports child settings through registered hooks", async () => { const updates: AcpSessionNotification[] = []; const client = createMockClient(); client.sessionUpdate = async (notification) => { updates.push(notification); }; - const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 }); + const observedAgent = new ClaudeAcpAgent(client); await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } }); const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] }); const session = observedAgent.sessions[sessionId]!; diff --git a/src/tests/helpers.ts b/src/tests/helpers.ts index e72e050f..df3a7f5e 100644 --- a/src/tests/helpers.ts +++ b/src/tests/helpers.ts @@ -34,6 +34,7 @@ export function makeMockQuery(overrides: Record = {}) { setPermissionMode: async () => {}, supportedCommands: async () => [], mcpServerStatus: async () => [], + setMcpServers: async () => ({ added: [], removed: [], errors: {} }), getContextUsage: async () => DEFAULT_CONTEXT_USAGE, // No ultracode fields: session creation falls back to settings and the // model's effort levels, as with a CLI that does not report them. diff --git a/src/tests/model-resolution.test.ts b/src/tests/model-resolution.test.ts index b66f8c2a..4c70432f 100644 --- a/src/tests/model-resolution.test.ts +++ b/src/tests/model-resolution.test.ts @@ -74,10 +74,72 @@ describe("resolveModelPreference - resolvedModel matching", () => { expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]"); }); - it("returns null for a preference that matches nothing", () => { - expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull(); + // Words match whole: "a" is inside "Default", "son" inside "sonnet". + it.each(["claude-gpt-99", "not-a-model", "a", "son", "foo-sonnet", "not-default-model"])( + "returns null for %s, which names no model", + (preference) => { + expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)).toBeNull(); + }, + ); + + it.each([ + ["us.anthropic.claude-sonnet-5-v1:0", "sonnet"], + ["global.anthropic.claude-haiku-4-5-20251001-v1:0", "haiku"], + ["anthropic/claude-sonnet-5:batch", "sonnet"], + ["claude-opus-4-8@20260101", "opus[1m]"], + ["claude-sonnet-5-fast", "sonnet"], + ["claude-haiku-4-5", "haiku"], + ["opusplan", "opus[1m]"], + ["opus", "opus[1m]"], + ["Sonnet 5", "sonnet"], + // The CLI's `best` alias runs Opus and ignores a `[1m]` hint. + ["best", "opus[1m]"], + ["BEST", "opus[1m]"], + ["best[1m]", "opus[1m]"], + ])("resolves the spelling %s to %s", (preference, value) => { + expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)?.value).toBe(value); }); + it("resolves best to the Opus entry rather than the default when the default runs Sonnet", () => { + const models: ModelInfo[] = [ + { + value: "default", + resolvedModel: "claude-sonnet-5", + displayName: "Default (recommended)", + description: "Use the default model (currently Sonnet 5)", + }, + { value: "opus", resolvedModel: "claude-opus-4-8", displayName: "Opus", description: "" }, + { value: "sonnet", resolvedModel: "claude-sonnet-5", displayName: "Sonnet", description: "" }, + ]; + expect(resolveModelPreference(models, "best")?.value).toBe("opus"); + }); + + // A gateway listing of dated ids, as `ANTHROPIC_MODEL`, `settings.model` or + // `set_config_option` may spell them. + const DATED_MODELS: ModelInfo[] = [ + { value: "claude-3-7-sonnet-20250219", displayName: "Claude 3.7 Sonnet", description: "" }, + { value: "claude-3-5-haiku-20241022", displayName: "Claude 3.5 Haiku", description: "" }, + { value: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", description: "" }, + ]; + + it.each([ + ["claude-3-7-sonnet-latest", "claude-3-7-sonnet-20250219"], + ["claude-3-5-haiku-latest", "claude-3-5-haiku-20241022"], + ["openrouter/anthropic/claude-sonnet-4.5", "claude-sonnet-4-5-20250929"], + ["vertex_ai/claude-sonnet-4-5@20250929", "claude-sonnet-4-5-20250929"], + ["litellm/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"], + ["publishers/anthropic/models/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"], + ])("resolves the gateway spelling %s to %s", (preference, value) => { + expect(resolveModelPreference(DATED_MODELS, preference)?.value).toBe(value); + }); + + it.each(["openai/gpt-5", "openrouter/foo-sonnet", "claude-3-5-opus-latest"])( + "returns null for the gateway spelling %s, which names no listed model", + (preference) => { + expect(resolveModelPreference(DATED_MODELS, preference)).toBeNull(); + }, + ); + it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => { // Without hint canonicalization the exact tier misses and the substring // tier lands on the bare 200k sibling, silently downgrading a 1M pin. diff --git a/src/tests/module-exports.test.ts b/src/tests/module-exports.test.ts index ca5f9232..4f7f5171 100644 --- a/src/tests/module-exports.test.ts +++ b/src/tests/module-exports.test.ts @@ -32,6 +32,7 @@ describe("module runtime exports", () => { it("acp-agent.ts exports the expected names", async () => { expect(Object.keys(await import("../acp-agent.js")).sort()).toEqual([ "AGENT_CONFIG_ID", + "AUTONOMOUS_RESULT_ORIGINS", "BUILTIN_AGENT_NAMES", "ClaudeAcpAgent", "DEFAULT_AGENT_ID", @@ -49,6 +50,8 @@ describe("module runtime exports", () => { "createFastModeConfigOption", "discoverCustomAgents", "fastModeStateEnabled", + "getAvailableSlashCommands", + "isAbortedResult", "isLocalCommandMetadata", "isLoginRequiredText", "isReplayHiddenMetaMessage", diff --git a/src/tests/provider-session-configuration.test.ts b/src/tests/provider-session-configuration.test.ts index 1bb92339..e5b5e43a 100644 --- a/src/tests/provider-session-configuration.test.ts +++ b/src/tests/provider-session-configuration.test.ts @@ -3,10 +3,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk"; -import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.js"; import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js"; -import { acpProtocolRouter } from "../serve.js"; import { initializeClient, makeMockQuery } from "./helpers.js"; import { successfulResultMessage, userEcho } from "./session-doubles.js"; @@ -747,50 +745,4 @@ describe("provider updates preserve session configuration", { timeout: 10_000 }, expect(lastQuery().state).toMatchObject({ model: "opus", effort: "high" }); expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe("https://recovered.example"); }); - - it("publishes the restored configuration through the v2 protocol router", async () => { - await agent.dispose(); - const toAgent = new TransformStream(); - const toClient = new TransformStream(); - acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({ - readable: toAgent.readable, - writable: toClient.writable, - }); - const received: v2.UpdateSessionNotification[] = []; - await v2 - .client({ name: "provider-test" }) - .onNotification(v2.methods.client.session.update, ({ params }) => void received.push(params)) - .connectWith({ readable: toClient.readable, writable: toAgent.writable }, async (client) => { - await client.request(v2.methods.agent.initialize, { - protocolVersion: v2.PROTOCOL_VERSION, - info: { name: "provider-test", version: "1" }, - capabilities: AIR_CAPABILITIES, - }); - await client.request(v2.methods.agent.providers.set, PROVIDER); - const { sessionId } = await client.request(v2.methods.agent.session.new, { cwd }); - for (const [configId, value] of [ - ["model", "opus"], - ["effort", "high"], - ]) { - await client.request(v2.methods.agent.session.setConfigOption, { - sessionId, - configId, - value, - type: "id", - }); - } - - await client.request(v2.methods.agent.providers.set, PROVIDER); - - const config = received.findLast( - ({ update }) => update.sessionUpdate === "config_option_update", - ); - expect(config?.update).toMatchObject({ - configOptions: expect.arrayContaining([ - expect.objectContaining({ configId: "model", currentValue: "opus" }), - expect.objectContaining({ configId: "effort", currentValue: "high" }), - ]), - }); - }); - }); }); diff --git a/src/tests/providers.test.ts b/src/tests/providers.test.ts index a7cecf0b..eec526a6 100644 --- a/src/tests/providers.test.ts +++ b/src/tests/providers.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest"; import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js"; +import { loggableBaseUrl } from "../providers.js"; const mockQuery = vi.hoisted(() => vi.fn(() => ({ @@ -922,3 +923,14 @@ describe("providers", () => { }); }); }); + +describe("loggableBaseUrl", () => { + it.each([ + ["https://user:secret@gateway.example.com/v1?key=abc", "https://gateway.example.com"], + ["https://gateway.example.com:8443/proxy/sk-123", "https://gateway.example.com:8443"], + ["not a url", "invalid"], + [undefined, "native"], + ])("logs %s as %s", (baseUrl, logged) => { + expect(loggableBaseUrl(baseUrl)).toBe(logged); + }); +}); diff --git a/src/tests/result-end.test.ts b/src/tests/result-end.test.ts new file mode 100644 index 00000000..c67c26f6 --- /dev/null +++ b/src/tests/result-end.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { resultEnd, type ResultEnd } from "../result-end.js"; + +const LIMITS = ["error_max_turns", "error_max_budget_usd", "error_max_structured_output_retries"]; + +/** A result of `subtype`, with the error text `text` on `success` and `["a", "b"]` otherwise. */ +function result( + subtype: string, + stopReason: string | null, + isError: boolean, + text = "API Error: 500", +): SDKResultMessage { + return { + type: "result", + subtype, + stop_reason: stopReason, + is_error: isError, + ...(subtype === "success" ? { result: text } : { errors: ["a", "b"] }), + } as unknown as SDKResultMessage; +} + +const failure = (error: string, loginRequired = false): ResultEnd => ({ error, loginRequired }); + +/** The subtype, `stop_reason` and `is_error` of a result, and how it ends the work. */ +type Row = [string, string | null, boolean, ResultEnd]; + +describe("resultEnd", () => { + it.each([ + ...["success", "error_during_execution", ...LIMITS].flatMap((subtype) => + [true, false].map((isError): Row => [subtype, "refusal", isError, { stopReason: "refusal" }]), + ), + ["success", "max_tokens", true, { stopReason: "max_tokens" }], + ["success", "max_tokens", false, { stopReason: "max_tokens" }], + ["success", "end_turn", true, failure("API Error: 500")], + ["success", null, false, { stopReason: "end_turn" }], + ["error_during_execution", "max_tokens", true, { stopReason: "max_tokens" }], + ["error_during_execution", "max_tokens", false, { stopReason: "max_tokens" }], + ["error_during_execution", "end_turn", true, failure("a, b")], + ["error_during_execution", null, false, { stopReason: "end_turn" }], + // A limit ignores `max_tokens`. + ...LIMITS.flatMap((subtype) => + ["max_tokens", null].flatMap((stopReason): Row[] => [ + [subtype, stopReason, true, failure("a, b")], + [subtype, stopReason, false, { stopReason: "max_turn_requests" }], + ]), + ), + ])("reads %s with stop_reason %s and is_error %s", (subtype, stopReason, isError, expected) => { + expect(resultEnd(result(subtype, stopReason, isError))).toEqual(expected); + }); + + it.each([null, "max_tokens"])( + "fails a success error with the /login text, also with stop_reason %s", + (stopReason) => { + const text = "Not logged in · Please run /login"; + expect(resultEnd(result("success", stopReason, true, text))).toEqual(failure(text, true)); + }, + ); + + it("fails a limit error with its subtype when Claude Code lists no errors", () => { + const limit = { ...result("error_max_turns", null, true), errors: [] } as SDKResultMessage; + expect(resultEnd(limit)).toEqual(failure("error_max_turns")); + }); +}); diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 71795041..9c48623e 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -330,18 +330,28 @@ describe("session config options", () => { expect(modelOption?.currentValue).toBe("claude-sonnet-4-6"); }); - it("throws for completely invalid model value", async () => { - await expect( - agent.setSessionConfigOption({ - sessionId: SESSION_ID, - configId: "model", - value: "gpt-4", - }), - ).rejects.toMatchObject({ - code: -32602, - message: expect.stringContaining("Invalid value for config option model: gpt-4"), - }); - }); + // "a" is a substring of "Claude", "son" of "sonnet": only whole words match. + it.each(["gpt-4", "not-a-model", "son", "foo-sonnet"])( + "rejects the model %s and leaves the model as it was", + async (value) => { + await expect( + agent.setSessionConfigOption({ sessionId: SESSION_ID, configId: "model", value }), + ).rejects.toMatchObject({ + code: -32602, + message: expect.stringContaining(`Invalid value for config option model: ${value}`), + }); + + expect(setModelSpy).not.toHaveBeenCalled(); + const session = ( + agent as unknown as { + sessions: Record; + } + ).sessions[SESSION_ID]; + expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe( + "claude-opus-4-5", + ); + }, + ); it("rejects a non-string value for a select option with invalid params", async () => { await expect( diff --git a/src/tests/session-doubles.ts b/src/tests/session-doubles.ts index 62f8e3ea..50019d60 100644 --- a/src/tests/session-doubles.ts +++ b/src/tests/session-doubles.ts @@ -8,6 +8,7 @@ import { vi } from "vitest"; import { randomUUID } from "crypto"; import { SessionBackgroundWork } from "../background-work.js"; +import { PromptSuggestions } from "../prompt-suggestions.js"; import { SessionTitles } from "../session-titles.js"; /** Stand-in agent for a `SessionTitles` whose test doesn't care about titles: @@ -93,11 +94,15 @@ export function mockSessionState( toolUseCache: {}, emittedToolCalls: new Set(), liveBackgroundTasks: new Map(), - background: new SessionBackgroundWork(sessionId, false), + background: new SessionBackgroundWork(sessionId), emittedAssistantText: false, owedTrailingIdles: 0, messageIdToUuid: new Map(), sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() }, + // As `createSession` gives it to the session of an AIR client. + ...(agent?.toolCallCapabilities?.air?.client + ? { promptSuggestions: new PromptSuggestions() } + : {}), ...overrides, } as any; } diff --git a/src/tests/session-index.test.ts b/src/tests/session-index.test.ts index 5173bf93..6bc1e477 100644 --- a/src/tests/session-index.test.ts +++ b/src/tests/session-index.test.ts @@ -164,7 +164,7 @@ async function writeTranscript(options: TranscriptOptions): Promise<{ id: string return { id, file }; } -function createAgent(options: { v2?: boolean } = {}) { +function createAgent() { const notifications: { method: string; params: Record }[] = []; const updates: unknown[] = []; const client = { @@ -175,7 +175,7 @@ function createAgent(options: { v2?: boolean } = {}) { notifications.push({ method, params }); }, } as unknown as AcpClient; - const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }, options); + const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); return { agent, notifications, updates }; } @@ -317,16 +317,6 @@ describe("sessionIndex negotiation", () => { expect(nonAir._meta).toEqual({ steering: { supported: true } }); }); - it("is not advertised under ACP v2", async () => { - const response = await createAgent({ v2: true }).agent.initialize({ - protocolVersion: 1, - clientCapabilities: air("sessionIndex"), - }); - for (const capability of indexCapabilities) { - expect(airCapabilities(response)).not.toContain(capability); - } - }); - it("answers method-not-found to the new methods without the capability", async () => { // Declaring sessionArchive or sessionRename without sessionIndex enables // nothing either. @@ -745,15 +735,6 @@ describe("_session/batch_get", () => { } }, ); - - it("rejects under ACP v2 even when sessionIndex was declared", async () => { - const { agent } = createAgent({ v2: true }); - await initializeClient(agent, air("sessionIndex")); - await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({ - code: -32601, - }); - await agent.dispose(); - }); }); describe("session/list of a sessionIndex client", () => { diff --git a/src/tests/session-titles.test.ts b/src/tests/session-titles.test.ts index 8d810592..8ca22ddc 100644 --- a/src/tests/session-titles.test.ts +++ b/src/tests/session-titles.test.ts @@ -115,6 +115,12 @@ describe("session titles", () => { return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }); } + /** Sends the current title of `test-session` (see `SessionTitles.publishCurrent`). */ + async function publishCurrentSessionInfo(agent: ClaudeAcpAgent) { + const session = agent.sessions["test-session"]!; + await session.titles.publishCurrent(session); + } + const LONG_PROMPT = "Explain what the add function in hello.py does, in one sentence"; it("pushes a session_info_update when the SDK generates a title at turn-end", async () => { @@ -198,8 +204,8 @@ describe("session titles", () => { agent, ); - await agent.publishCurrentSessionInfo("test-session"); - await agent.publishCurrentSessionInfo("test-session"); + await publishCurrentSessionInfo(agent); + await publishCurrentSessionInfo(agent); const current = { sessionUpdate: "session_info_update", @@ -221,11 +227,11 @@ describe("session titles", () => { // No file and no title yet: nothing to send. vi.mocked(getSessionInfo).mockResolvedValue(undefined); - await agent.publishCurrentSessionInfo("test-session"); + await publishCurrentSessionInfo(agent); expect(titles()).toEqual([]); await agent.sessions["test-session"]!.titles.setExplicitTitle("Renamed", async () => undefined); - await agent.publishCurrentSessionInfo("test-session"); + await publishCurrentSessionInfo(agent); expect(titles()).toEqual(["Renamed", "Renamed"]); }); @@ -245,7 +251,7 @@ describe("session titles", () => { summary: "please fix the build", lastModified: 1_700_000_000_000, } as any); - await agent.publishCurrentSessionInfo("test-session"); + await publishCurrentSessionInfo(agent); expect(titles()).toEqual(["Renamed", "Renamed"]); }); diff --git a/src/tests/turn-events.test.ts b/src/tests/turn-events.test.ts index f5d51871..84ab2ae8 100644 --- a/src/tests/turn-events.test.ts +++ b/src/tests/turn-events.test.ts @@ -4,12 +4,9 @@ * Claude Code: it echoes each prompt, then runs the turn's script. */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { - RequestPermissionRequest, - SessionNotification, -} from "@agentclientprotocol/sdk/unstable"; +import type { SessionNotification } from "@agentclientprotocol/sdk/unstable"; import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js"; -import type { ForegroundEvents, TurnEvents } from "../turn-events.js"; +import type { TurnEvents } from "../turn-events.js"; import { DEFAULT_CONTEXT_USAGE } from "./helpers.js"; import { randomUUID } from "node:crypto"; @@ -195,41 +192,19 @@ function system(subtype: string, sessionId: string, fields: Record { let agent: ClaudeAcpAgent; - let permissionRequests: RequestPermissionRequest[]; - /** The user answers a request for this tool call only once it resolves. */ - let backgroundAnswer: PromiseWithResolvers; /** Sees each session update; the agent waits until it returns. */ let onSessionUpdate: (notification: SessionNotification) => Promise | void; - /** What the agent reported of the session's foreground work. */ - let foreground: string[]; beforeEach(async () => { vi.resetModules(); - permissionRequests = []; - backgroundAnswer = Promise.withResolvers(); onSessionUpdate = () => {}; - foreground = []; const { ClaudeAcpAgent } = await import("../acp-agent.js"); - const foregroundEvents: ForegroundEvents = { - running: () => foreground.push("running"), - awaitingUser: () => foreground.push("awaitingUser"), - ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`), - failed: () => foreground.push("failed"), - }; agent = new ClaudeAcpAgent( { sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification), extNotification: async () => {}, - requestPermission: async (request: RequestPermissionRequest) => { - permissionRequests.push(request); - await new Promise((resolve) => setTimeout(resolve, 1)); - if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise; - const allow = request.options.find((option) => option.kind === "allow_once"); - return { outcome: { outcome: "selected", optionId: allow!.optionId } }; - }, } as unknown as AcpClient, undefined, - { foreground: () => foregroundEvents }, ); }); @@ -333,172 +308,6 @@ describe("turn events", () => { ]); }); - it("reports waiting on a permission request, then resuming", async () => { - scriptTurns([ - async function* (options) { - await options.canUseTool( - "Bash", - { command: "ls" }, - { signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" }, - ); - yield { - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }], - }, - parent_tool_use_id: null, - uuid: randomUUID(), - session_id: options.sessionId!, - }; - yield result(options.sessionId!); - }, - ]); - const sessionId = await newSession(); - const turn = recordEvents(); - - await agent.startTurn(prompt(sessionId, "list files"), turn.events); - await turn.done; - - expect(permissionRequests).toHaveLength(1); - expect(turn.log.slice(1)).toEqual(["ended end_turn"]); - expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]); - }); - - it("reports waiting on a request that Claude Code opens for a started turn before its echo", async () => { - let whileOpen: string[] = []; - scriptQuery(async function* (input, options) { - const { value } = await input.next(); - yield lifecycle(value.uuid, "started", options.sessionId!); - // Claude Code asks for the prompt before the consumer reaches its echo. - // The user answers it only once the test resolves `backgroundAnswer`. - const decision = options.canUseTool!( - "Bash", - { command: "ls" }, - { signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" }, - ); - await vi.waitFor(() => expect(permissionRequests).toHaveLength(1)); - whileOpen = [...foreground]; - backgroundAnswer.resolve(); - await decision; - yield echo(value, options.sessionId!); - yield { - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_background", content: "README.md" }], - }, - parent_tool_use_id: null, - uuid: randomUUID(), - session_id: options.sessionId!, - }; - yield result(options.sessionId!); - await input.next(); - }); - const sessionId = await newSession(); - const turn = recordEvents(); - - await agent.startTurn(prompt(sessionId, "list files"), turn.events); - await turn.done; - - expect(whileOpen).toEqual(["running", "awaitingUser"]); - expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]); - }); - - it("reports work that starts while a request is open as waiting at once", async () => { - let atStart: string[] = []; - scriptQuery(async function* (input, options) { - const { value } = await input.next(); - // A request that no work opened, such as a background task's. The user - // answers it only once the test resolves `backgroundAnswer`. - const background = options.canUseTool!( - "Bash", - { command: "sleep 60" }, - { signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" }, - ); - await vi.waitFor(() => expect(permissionRequests).toHaveLength(1)); - yield lifecycle(value.uuid, "started", options.sessionId!); - // Before the prompt's echo, which would report the request too. - atStart = [...foreground]; - backgroundAnswer.resolve(); - await background; - yield echo(value, options.sessionId!); - yield result(options.sessionId!); - await input.next(); - }); - const sessionId = await newSession(); - const turn = recordEvents(); - - await agent.startTurn(prompt(sessionId, "list files"), turn.events); - await turn.done; - - expect(atStart).toEqual(["running", "awaitingUser"]); - }); - - it("awaits the user while any request in the session is open, also one opened before the turn", async () => { - const first = recordEvents(); - const second = recordEvents(); - let backgroundRequest: Promise | undefined; - let atStart: string[] = []; - let afterOwnAnswer: string[] | undefined; - scriptTurns([ - async function* (options) { - yield result(options.sessionId!); - await first.done; - // A request that no active turn opened, such as a background task's. - backgroundRequest = options.canUseTool( - "Bash", - { command: "sleep 60" }, - { signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" }, - ); - }, - async function* (options) { - atStart = [...foreground]; - await options.canUseTool( - "Bash", - { command: "ls" }, - { signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" }, - ); - afterOwnAnswer = [...foreground]; - await backgroundRequest; - yield { - type: "user", - message: { - role: "user", - content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }], - }, - parent_tool_use_id: null, - uuid: randomUUID(), - session_id: options.sessionId!, - }; - yield result(options.sessionId!); - }, - ]); - const sessionId = await newSession(); - - await agent.startTurn(prompt(sessionId, "start a background task"), first.events); - await vi.waitFor(() => expect(permissionRequests).toHaveLength(1)); - await agent.startTurn(prompt(sessionId, "list files"), second.events); - await vi.waitFor(() => expect(afterOwnAnswer).toBeDefined()); - backgroundAnswer.resolve(); - await second.done; - - expect(first.log.slice(1)).toEqual(["ended end_turn"]); - // The first turn's work ended before the background request opened, and - // the second turn waits from insertion, through its own answered request, - // until the earlier request is answered too. - const firstWork = ["running", "ended end_turn"]; - expect(atStart).toEqual([...firstWork, "running", "awaitingUser"]); - expect(afterOwnAnswer).toEqual([...firstWork, "running", "awaitingUser"]); - expect(foreground).toEqual([ - ...firstWork, - "running", - "awaitingUser", - "running", - "ended end_turn", - ]); - }); - it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => { const release = Promise.withResolvers(); scriptTurns([ diff --git a/src/tests/v2-integration.test.ts b/src/tests/v2-integration.test.ts new file mode 100644 index 00000000..efcdf171 --- /dev/null +++ b/src/tests/v2-integration.test.ts @@ -0,0 +1,179 @@ +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import { nodeToWebReadable, nodeToWebWritable } from "../utils.js"; + +/** + * The ACP v2 core (`src/v2/`) against the bundled Claude Code + * CLI, in the adapter's own process as a client starts it. The doubles of + * `v2.test.ts` send the frames the core builds its turn model on + * (`command_lifecycle`, `session_state_changed`, `interrupt({ cancelQueued })`); + * these cases check that the real CLI still sends them. They cost API usage. + */ +describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("v2 core with Claude Code", () => { + let child: ChildProcess; + let agent: v2.ClientContext; + let cwd: string; + const updates: v2.UpdateSessionNotification[] = []; + const waiters: Array<() => void> = []; + let permissionAnswer: v2.PermissionOptionKind = "allow_once"; + + beforeAll(() => { + if (spawnSync("npx", ["tsc"], { stdio: "inherit" }).status) { + throw new Error("failed to compile"); + } + }, 120_000); + + beforeEach(async () => { + cwd = await mkdtemp(path.join(os.tmpdir(), "v2-integration-")); + await writeFile(path.join(cwd, "file.txt"), "one\n"); + updates.length = 0; + permissionAnswer = "allow_once"; + child = spawn("node", [path.resolve("dist/index.js")], { + cwd, + env: { ...process.env, CLAUDE_AGENT_ACP_PROTOCOL: "v2" }, + stdio: ["pipe", "pipe", "inherit"], + }); + const connection = v2 + .client({ name: "v2-integration" }) + .onNotification(v2.methods.client.session.update, ({ params }) => { + updates.push(params); + for (const notify of waiters.splice(0)) notify(); + }) + .onRequest(v2.methods.client.session.requestPermission, ({ params }) => { + const option = params.options.find((candidate) => candidate.kind === permissionAnswer); + return option + ? { outcome: { outcome: "selected", optionId: option.optionId } } + : { outcome: { outcome: "cancelled" } }; + }) + .connect(v2.ndJsonStream(nodeToWebWritable(child.stdin!), nodeToWebReadable(child.stdout!))); + agent = connection.agent; + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-integration", version: "1" }, + capabilities: {}, + }); + }, 60_000); + + afterEach(async () => { + // Not `connection.close()`: it cancels the stdout stream, and the socket's + // later `end` then closes it again (`nodeToWebReadable`), which throws. + child.kill(); + await rm(cwd, { recursive: true, force: true, maxRetries: 10 }); + }); + + afterAll(() => child?.kill()); + + /** The states of the root session's `state_update`s, in order. */ + const states = (sessionId: string) => + updates.flatMap(({ sessionId: id, update }) => + id === sessionId && update.sessionUpdate === "state_update" ? [update] : [], + ); + + /** Resolves once `test` holds for the updates received so far. */ + async function until(test: () => boolean, ms = 120_000): Promise { + const deadline = Date.now() + ms; + while (!test()) { + if (Date.now() > deadline) throw new Error("timed out waiting for the session's updates"); + await new Promise((resolve) => { + waiters.push(resolve); + setTimeout(resolve, 1000); + }); + } + } + + /** A new session on its haiku model, the cheapest, in `mode`. */ + async function newSession(mode: string): Promise { + const { sessionId, configOptions } = await agent.request(v2.methods.agent.session.new, { + cwd, + }); + type Entry = { value: string; name: string; options?: Entry[] }; + const model = configOptions?.find((option) => option.configId === "model") as + { options?: Entry[] } | undefined; + const values = (model?.options ?? []).flatMap((entry) => entry.options ?? [entry]); + const haiku = values.find((entry) => /haiku/i.test(`${entry.value} ${entry.name}`)); + if (haiku) { + await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "model", + type: "id", + value: haiku.value, + }); + } + await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "mode", + type: "id", + value: mode, + }); + return sessionId; + } + + const prompt = (sessionId: string, text: string) => + agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text }], + }); + + it("answers a prompt at insertion and reports running, then idle", async () => { + const sessionId = await newSession("default"); + await prompt(sessionId, "Reply with exactly the single word: pong"); + // The prompt answers at insertion, before the turn's idle. + expect(states(sessionId).some((update) => update.state === "idle")).toBe(false); + await until(() => states(sessionId).some((update) => update.state === "idle")); + expect(states(sessionId).map((update) => update.state)).toEqual(["running", "idle"]); + expect(states(sessionId).at(-1)).toMatchObject({ stopReason: "end_turn" }); + }, 180_000); + + it("reports requires_action while a permission request waits", async () => { + const sessionId = await newSession("default"); + await prompt( + sessionId, + "Use the Edit tool on file.txt to replace the word one with the word two. Then reply with just: done", + ); + await until(() => states(sessionId).some((update) => update.state === "idle")); + expect(states(sessionId).map((update) => update.state)).toEqual([ + "running", + "requires_action", + "running", + "idle", + ]); + }, 180_000); + + it("fails a queued prompt with -32800 at a cancel and goes idle with cancelled", async () => { + const sessionId = await newSession("bypassPermissions"); + await prompt( + sessionId, + "Use the Bash tool in the foreground (not run_in_background) to run exactly: ping -c 30 127.0.0.1 > /dev/null . Then reply with just: woke", + ); + await until(() => + updates.some( + ({ update }) => + update.sessionUpdate === "terminal_update" && + /ping/.test((update as v2.TerminalUpdate).command ?? ""), + ), + ); + // Claude Code takes in a prompt sent while it works only at its next + // step, so this one is still queued at the cancel. + // It answers when Claude Code starts it, so the cancel fails it instead. + const queued = prompt(sessionId, "Reply with just: queued"); + await new Promise((resolve) => setTimeout(resolve, 500)); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + await expect(queued).rejects.toMatchObject({ code: -32800 }); + await until(() => states(sessionId).some((update) => update.state === "idle")); + expect(states(sessionId).at(-1)).toMatchObject({ state: "idle", stopReason: "cancelled" }); + + // The next prompt runs normally. + const before = states(sessionId).length; + await prompt(sessionId, "Reply with exactly the single word: ok"); + await until(() => + states(sessionId) + .slice(before) + .some((update) => update.state === "idle"), + ); + expect(states(sessionId).slice(before).at(-1)).toMatchObject({ stopReason: "end_turn" }); + }, 240_000); +}); diff --git a/src/tests/v2.test.ts b/src/tests/v2.test.ts new file mode 100644 index 00000000..d523f2d8 --- /dev/null +++ b/src/tests/v2.test.ts @@ -0,0 +1,3966 @@ +/** + * The ACP v2 core (`src/v2/`), driven through the protocol + * router with the SDK's v2 client app, which validates every response and + * session update against the v2 schema. + * + * Claude Code is a double that the test drives message by message (see + * {@link FakeClaudeCode}), shaped like what the bundled CLI sends: prompt + * echoes with `isReplay`, `command_lifecycle` frames, and + * `session_state_changed`. + */ +import { randomUUID } from "node:crypto"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import { + getSessionInfo, + getSessionMessages, + type Options, + type SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { acpProtocolRouter } from "../serve.js"; +import { SettingsManager } from "../settings.js"; +import type { AgentHandle } from "../v2/agent.js"; +import { StoppedWork } from "../v2/stopped-work.js"; + +/** One Claude Code process: the prompts it reads and the messages it sends. */ +type FakeClaudeCode = { + options: Options; + /** The prompts the session pushed, in order. */ + prompts: SDKUserMessage[]; + /** Resolves with the `index`th prompt once the session pushed it. */ + prompt(index: number): Promise; + /** Sends a message to the session. */ + send(message: Record): void; + /** Ends the stream, with `error` as a crash. */ + end(error?: Error): void; + interrupt: ReturnType; + setPermissionMode: ReturnType; + setModel: ReturnType; + stopTask: ReturnType; + close: ReturnType; + applyFlagSettings: ReturnType; + /** The dry-run rewind of a prompt's checkpoint: the files it changed. */ + rewindFiles: ReturnType; +}; + +const DEFAULT_MODELS: Array> = [ + { value: "default", displayName: "Default", description: "Recommended" }, + { value: "opus", displayName: "Opus", description: "Most capable" }, +]; + +const sdk = vi.hoisted(() => ({ + processes: [] as FakeClaudeCode[], + models: [] as Array>, + /** What `mcpServerStatus` reports. */ + mcpServers: [] as Array<{ name: string; status: string }>, + /** The subagents of the session's transcript, by the id of their launch call. */ + subagents: new Map>(), + /** When set, a permission request's patch preview waits for it. */ + previewGate: undefined as Promise | undefined, + /** When set, Claude Code fails to start with it. */ + startFailure: undefined as Error | undefined, + /** When set, Claude Code's start waits for it. */ + startGate: undefined as Promise | undefined, + /** When set, the query serves Remote Control with it (`enableRemoteControl`). */ + enableRemoteControl: undefined as ((...args: unknown[]) => Promise) | undefined, +})); + +vi.mock("@anthropic-ai/claude-agent-sdk", async () => { + const actual = await vi.importActual( + "@anthropic-ai/claude-agent-sdk", + ); + return { + ...actual, + query: ({ prompt, options }: { prompt: AsyncIterable; options: Options }) => { + const queue: Array> | Error> = []; + let wake = () => {}; + const prompts: SDKUserMessage[] = []; + let promptArrived = () => {}; + void (async () => { + for await (const message of prompt) { + prompts.push(message); + promptArrived(); + } + })(); + const push = (item: IteratorResult> | Error) => { + queue.push(item); + wake(); + }; + const fake: FakeClaudeCode = { + options, + prompts, + prompt: async (index) => { + while (prompts.length <= index) { + await new Promise((resolve) => (promptArrived = resolve)); + } + return prompts[index]; + }, + send: (message) => push({ value: message, done: false }), + end: (error) => push(error ?? { value: undefined, done: true }), + interrupt: vi.fn(async () => undefined), + setPermissionMode: vi.fn(async () => {}), + setModel: vi.fn(async () => {}), + stopTask: vi.fn(async () => {}), + close: vi.fn(() => push({ value: undefined, done: true })), + applyFlagSettings: vi.fn(async () => {}), + rewindFiles: vi.fn(async () => ({ canRewind: true, filesChanged: [] })), + }; + sdk.processes.push(fake); + const messages: AsyncGenerator> = { + async next() { + while (queue.length === 0) await new Promise((resolve) => (wake = resolve)); + const item = queue.shift()!; + if (item instanceof Error) throw item; + return item; + }, + async return() { + return { value: undefined, done: true }; + }, + async throw(error) { + throw error; + }, + [Symbol.asyncIterator]() { + return this; + }, + async [Symbol.asyncDispose]() {}, + }; + return Object.assign(messages, { + initializationResult: async () => { + await sdk.startGate; + if (sdk.startFailure) throw sdk.startFailure; + return { models: sdk.models }; + }, + supportedCommands: async () => [ + { name: "review", description: "Review a change", argumentHint: "" }, + ], + interrupt: fake.interrupt, + setPermissionMode: fake.setPermissionMode, + setModel: fake.setModel, + stopTask: fake.stopTask, + close: fake.close, + applyFlagSettings: fake.applyFlagSettings, + rewindFiles: fake.rewindFiles, + mcpServerStatus: async () => sdk.mcpServers, + ...(sdk.enableRemoteControl ? { enableRemoteControl: sdk.enableRemoteControl } : {}), + setMcpServers: async () => ({ added: [], removed: [], errors: {} }), + // A server that needs OAuth: Claude Code returns its authorization URL. + mcpAuthenticate: async () => ({ + authUrl: "https://mcp.example.com/authorize", + requiresUserAction: true, + callbackExpected: true, + }), + }); + }, + getSessionMessages: vi.fn(async () => []), + getSessionInfo: vi.fn(async () => undefined), + forkSession: vi.fn(async () => ({ sessionId: globalThis.crypto.randomUUID() })), + }; +}); + +vi.mock("../diff.js", async () => { + const actual = await vi.importActual("../diff.js"); + return { + ...actual, + previewPatchContent: async (...args: Parameters) => { + await sdk.previewGate; + return actual.previewPatchContent(...args); + }, + }; +}); + +// The subagents that a replayed transcript launched, as their metadata files name them. +vi.mock("../subagent-history.js", async () => ({ + ...(await vi.importActual("../subagent-history.js")), + subagentHistory: vi.fn(async () => ({ agents: sdk.subagents })), + readSubagentMessages: vi.fn(async () => []), +})); + +vi.mock("../tools.js", async () => ({ + ...(await vi.importActual("../tools.js")), + registerHookCallback: vi.fn(), +})); + +// `claude auth status --json`, which the auth status probe runs, reports no login. +vi.mock("node:child_process", async () => ({ + ...(await vi.importActual("node:child_process")), + execFile: vi.fn((...invocation: unknown[]) => { + const callback = invocation.at(-1) as (...args: unknown[]) => void; + callback(null, { stdout: JSON.stringify({ loggedIn: false }), stderr: "" }); + }), +})); + +const agents: AgentHandle[] = []; +let tempDir: string; +let cwd: string; + +beforeEach(async () => { + tempDir = await mkdtemp(path.join(os.tmpdir(), "acp-v2-")); + cwd = await mkdtemp(path.join(tempDir, "project-")); + vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(tempDir, "claude")); + sdk.processes = []; + sdk.mcpServers = []; + sdk.models = DEFAULT_MODELS; + sdk.subagents = new Map(); + sdk.previewGate = undefined; + sdk.startFailure = undefined; + sdk.startGate = undefined; + sdk.enableRemoteControl = undefined; +}); + +afterEach(async () => { + await Promise.all(agents.splice(0).map((agent) => agent.dispose())); + vi.unstubAllEnvs(); + await rm(tempDir, { recursive: true, force: true, maxRetries: 10 }); +}); + +/** A v2 client of the v2 core that records the session updates it receives. */ +function v2Client({ + answerPermission = () => ({ outcome: { outcome: "cancelled" } }), + answerElicitation = () => ({ action: "cancel" }), +}: { + answerPermission?: ( + request: v2.RequestPermissionRequest, + ) => v2.RequestPermissionResponse | Promise; + /** `signal` aborts when the agent cancels the request. */ + answerElicitation?: ( + request: v2.CreateElicitationRequest, + signal: AbortSignal, + ) => v2.CreateElicitationResponse | Promise; +} = {}) { + const sessionUpdates: v2.SessionUpdate[] = []; + const notifications: v2.UpdateSessionNotification[] = []; + const permissionRequests: v2.RequestPermissionRequest[] = []; + const elicitations: v2.CreateElicitationRequest[] = []; + /** The ids of the `elicitation/complete` notifications. */ + const completedElicitations: string[] = []; + const app = v2 + .client({ name: "v2-test-client" }) + .onNotification(v2.methods.client.session.update, ({ params }) => { + notifications.push(params); + sessionUpdates.push(params.update); + }) + .onRequest(v2.methods.client.session.requestPermission, ({ params }) => { + permissionRequests.push(params); + return answerPermission(params); + }) + .onRequest(v2.methods.client.elicitation.create, ({ params, signal }) => { + elicitations.push(params); + return answerElicitation(params, signal); + }) + .onNotification(v2.methods.client.elicitation.complete, ({ params }) => { + completedElicitations.push(params.elicitationId); + }); + const toAgent = new TransformStream(); + const toClient = new TransformStream(); + acpProtocolRouter(undefined, () => {}, "v2", { + onV2Agent: (agent) => agents.push(agent), + }).connect({ readable: toAgent.readable, writable: toClient.writable }); + /** The `state_update`s of root sessions, not subagents', as their states. */ + const states = () => { + const children = new Set( + notifications.flatMap(({ update }) => + update.sessionUpdate === "subagent_update" ? [update.sessionId] : [], + ), + ); + return notifications.flatMap(({ sessionId, update }) => + update.sessionUpdate === "state_update" && !children.has(sessionId) ? [update.state] : [], + ); + }; + return { + sessionUpdates, + notifications, + permissionRequests, + elicitations, + completedElicitations, + states, + connect: (run: (agent: v2.ClientContext) => Promise) => + app.connectWith({ readable: toClient.readable, writable: toAgent.writable }, run), + }; +} + +async function openSession(agent: v2.ClientContext, capabilities: v2.ClientCapabilities = {}) { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities, + }); + const response = await agent.request(v2.methods.agent.session.new, { cwd }); + return { response, claude: sdk.processes.at(-1)! }; +} + +function lifecycle(prompt: SDKUserMessage, state: string) { + return { type: "command_lifecycle", command_uuid: prompt.uuid, state, uuid: randomUUID() }; +} + +function echo(prompt: SDKUserMessage) { + return { + type: "user", + message: prompt.message, + parent_tool_use_id: null, + uuid: prompt.uuid, + session_id: "session", + isReplay: true, + }; +} + +function sessionState(state: "idle" | "running" | "requires_action") { + return { type: "system", subtype: "session_state_changed", state, uuid: randomUUID() }; +} + +/** + * The first output of a turn: the `message_start` of its model's stream. It + * opens a turn that no prompt started, such as after a background task ends. + */ +function cycleStart() { + return { + type: "stream_event", + event: { type: "message_start", message: { id: `msg_${randomUUID()}`, content: [] } }, + parent_tool_use_id: null, + uuid: randomUUID(), + }; +} + +function textDelta(id: string, text: string) { + return [ + { + type: "stream_event", + event: { + type: "message_start", + message: { + id, + role: "assistant", + content: [], + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + }, + { + type: "stream_event", + event: { type: "content_block_delta", index: 0, delta: { type: "text_delta", text } }, + parent_tool_use_id: null, + uuid: randomUUID(), + }, + ]; +} + +function assistant(id: string, content: unknown[]) { + return { + type: "assistant", + message: { id, type: "message", role: "assistant", content, usage: {} }, + parent_tool_use_id: null, + uuid: randomUUID(), + }; +} + +function result(overrides: Record = {}) { + return { + type: "result", + subtype: "success", + stop_reason: "end_turn", + is_error: false, + result: "", + errors: [], + usage: { + input_tokens: 3, + output_tokens: 2, + cache_read_input_tokens: 1, + cache_creation_input_tokens: 0, + }, + total_cost_usd: 0, + modelUsage: {}, + uuid: randomUUID(), + ...overrides, + }; +} + +const PROMPT = [{ type: "text" as const, text: "Hi" }]; + +/** Claude Code's permission check of a tool use (`canUseTool`), with `fields` of its options. */ +function askPermission( + claude: FakeClaudeCode, + toolName: string, + input: Record, + fields: Record = {}, +) { + return claude.options.canUseTool!(toolName, input, { + signal: new AbortController().signal, + toolUseID: "toolu_ask", + requestId: "r1", + suggestions: [], + ...fields, + } as Parameters>[2]); +} + +/** + * A subagent's task events and messages, shaped like the bundled CLI's: its + * messages carry the id of the Agent call that launched it. + */ +function subagent(agentId: string, launch: string, fields: Record = {}) { + const system = (subtype: string, more: Record) => ({ + type: "system", + subtype, + task_id: agentId, + uuid: randomUUID(), + ...more, + }); + return { + /** The Agent call that launches it, made by the root or by the agent of `parent`. */ + launch: (parent: string | null = null) => ({ + ...assistant(`msg_${launch}`, [ + { + type: "tool_use", + id: launch, + name: "Agent", + input: { + description: "Investigate the test", + prompt: "Find why the test fails", + subagent_type: "general-purpose", + ...(fields.is_backgrounded === true ? { run_in_background: true } : {}), + }, + }, + ]), + parent_tool_use_id: parent, + }), + /** + * The streamed start of its Agent call, which comes before the + * consolidated `launch`: after a cancel, only the stream goes out. + */ + launchStart: () => ({ + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: launch, name: "Agent", input: {} }, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + }), + started: (more: Record = {}) => + system("task_started", { + tool_use_id: launch, + task_type: "local_agent", + subagent_type: "general-purpose", + description: "Investigate the test", + prompt: "Find why the test fails", + is_backgrounded: false, + ...fields, + ...more, + }), + ended: (status = "completed") => + system("task_notification", { tool_use_id: launch, status, output_file: "", summary: "" }), + prompt: (text: string) => ({ + type: "user", + message: { role: "user", content: [{ type: "text", text }] }, + parent_tool_use_id: launch, + uuid: randomUUID(), + }), + says: (text: string) => ({ + ...assistant(`msg_${agentId}`, [{ type: "text", text }]), + parent_tool_use_id: launch, + }), + }; +} + +/** The updates of the session `sessionId` that the client received, in order. */ +function updatesOf( + notifications: v2.UpdateSessionNotification[], + sessionId: string, +): v2.SessionUpdate[] { + return notifications.flatMap((n) => (n.sessionId === sessionId ? [n.update] : [])); +} + +describe("ACP v2 core", () => { + it("advertises the v2 session baseline with fork, and terminal logins", async () => { + const client = v2Client(); + const response = await client.connect((agent) => + agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: { auth: { terminal: {} } }, + }), + ); + + expect(response.protocolVersion).toBe(2); + expect(response.capabilities?.session?.fork).toEqual({}); + expect(response.capabilities?.session?.prompt).toEqual({ image: {}, embeddedContext: {} }); + expect((response.authMethods ?? []).map((method) => method.methodId)).toContain( + "console-login", + ); + }); + + it("routes the sessions started after a gateway login through the gateway", async () => { + const client = v2Client(); + const baseUrl = "https://gateway.example.com"; + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: { auth: { _meta: { gateway: true } } }, + }); + await agent.request(v2.methods.agent.auth.login, { + methodId: "gateway", + _meta: { gateway: { baseUrl, headers: { "x-team": "acp" } } }, + }); + await agent.request(v2.methods.agent.session.new, { cwd }); + }); + + const { env, settings } = sdk.processes[0].options; + expect(env).toMatchObject({ + ANTHROPIC_BASE_URL: baseUrl, + ANTHROPIC_CUSTOM_HEADERS: "x-team: acp", + }); + // In the settings tier too, so a settings.json env cannot restore another route. + expect(settings).toMatchObject({ apiKeyHelper: "", env: { ANTHROPIC_BASE_URL: baseUrl } }); + }); + + describe("a provider change of live sessions", () => { + const setProvider = (agent: v2.ClientContext) => + agent.request(v2.methods.agent.providers.set, { + providerId: "main", + apiType: "anthropic", + baseUrl: "https://gateway.example.com", + }); + + it("answers at once while a background subagent runs, and restarts the session once it ends", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + void agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }); + claude.send(lifecycle(await claude.prompt(0), "started")); + const helper = subagent("agent-1", "toolu_agent", { is_backgrounded: true }); + claude.send(helper.launch()); + claude.send(helper.started()); + claude.send(result()); + await vi.waitFor(() => expect(client.states().at(-1)).toBe("idle")); + await setProvider(agent); + expect(sdk.processes).toHaveLength(1); + + // A prompt meanwhile goes to the current query. + void agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text: "next" }], + }); + claude.send(lifecycle(await claude.prompt(1), "started")); + claude.send(result()); + await vi.waitFor(() => expect(client.states().at(-1)).toBe("idle")); + expect(sdk.processes).toHaveLength(1); + + claude.send(helper.ended()); + await vi.waitFor(() => expect(claude.close).toHaveBeenCalled()); + expect(sdk.processes).toHaveLength(2); + expect(sdk.processes[1].options).toMatchObject({ resume: sessionId }); + }); + }); + + it("sends a prompt of an idle session right after the response to the new query", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + await setProvider(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + sdk.processes[1].send(lifecycle(await sdk.processes[1].prompt(0), "started")); + await answer; + expect(claude.prompts).toHaveLength(0); + }); + }); + + it("sends a prompt that waited for the restart to the new query, and forgets it", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const set = setProvider(agent); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + started.resolve(); + await set; + sdk.processes[1].send(lifecycle(await sdk.processes[1].prompt(0), "started")); + await answer; + expect(claude.prompts).toHaveLength(0); + const restarts = (agents[0] as unknown as { restarts: { held: Map } }) + .restarts; + expect(restarts.held.size).toBe(0); + }); + }); + + it("answers a prompt that waits for the restart with -32800 on session/cancel", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const set = setProvider(agent); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + const waiting = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text: "next" }], + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + await expect(waiting).rejects.toMatchObject({ code: -32800 }); + + started.resolve(); + await set; + // The cancelled prompt never reached either query. + expect(claude.prompts).toHaveLength(0); + expect(sdk.processes[1].prompts).toHaveLength(0); + }); + }); + + it("ends the old query only after a turn that Claude Code started on it while the new one started", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const set = setProvider(agent); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + // A background task ended, say: Claude Code starts a turn on its own. + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + started.resolve(); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(claude.close).not.toHaveBeenCalled(); + + claude.send(result()); + await set; + expect(claude.close).toHaveBeenCalled(); + }); + + expect(client.sessionUpdates).toContainEqual( + expect.objectContaining({ + sessionUpdate: "state_update", + state: "idle", + stopReason: "end_turn", + }), + ); + }); + + it("restarts a session whose start read the route before the change, once its setup answered", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: {}, + }); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const created = agent.request(v2.methods.agent.session.new, { cwd }); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(1)); + await setProvider(agent); + started.resolve(); + const { sessionId } = await created; + expect(sdk.processes).toHaveLength(1); + + await vi.waitFor(() => expect(sdk.processes[0].close).toHaveBeenCalled()); + expect(sdk.processes).toHaveLength(2); + expect(sdk.processes[1].options).toMatchObject({ + resume: sessionId, + env: { ANTHROPIC_BASE_URL: "https://gateway.example.com" }, + }); + }); + }); + + it("forgets a session that session/close closed while its restart ran", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response } = await openSession(agent); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const set = setProvider(agent); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + const close = agent.request(v2.methods.agent.session.close, { + sessionId: response.sessionId, + }); + // The close waits for the restart. + await new Promise((resolve) => setTimeout(resolve, 20)); + started.resolve(); + await close; + await set; + expect(sdk.processes[1].close).toHaveBeenCalled(); + // Its setup is gone, so no later route change restarts it. + const restarts = (agents[0] as unknown as { restarts: { setups: Map } }) + .restarts; + expect(restarts.setups.has(response.sessionId)).toBe(false); + }); + }); + + describe("MCP OAuth", () => { + const linear = { type: "http" as const, name: "linear", url: "https://mcp.example.com/mcp" }; + /** A client that leaves each OAuth elicitation open until the agent withdraws it. */ + const oauthClient = () => { + const withdrawn: number[] = []; + const client = v2Client({ + answerElicitation: (_, signal) => + new Promise((resolve) => + signal.addEventListener("abort", () => { + withdrawn.push(client.elicitations.length); + resolve({ action: "cancel" }); + }), + ), + }); + return { client, withdrawn }; + }; + const initialize = (agent: v2.ClientContext) => + agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: { elicitation: { url: {} } }, + }); + + it("starts OAuth again on the new query of a restart that withdrew it", async () => { + // The server stays `needs-auth`: the user has not signed in yet. + sdk.mcpServers = [{ name: "linear", status: "needs-auth" }]; + const { client, withdrawn } = oauthClient(); + await client.connect(async (agent) => { + await initialize(agent); + await agent.request(v2.methods.agent.session.new, { cwd, mcpServers: [linear] }); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(1)); + await setProvider(agent); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(2)); + // The old query's elicitation was withdrawn before the new one went out. + expect(withdrawn).toEqual([1]); + }); + }); + + it("starts OAuth only on the new query of a resume with other additional directories", async () => { + sdk.mcpServers = [{ name: "linear", status: "needs-auth" }]; + const extra = await mkdtemp(path.join(tempDir, "extra-")); + const { client, withdrawn } = oauthClient(); + await client.connect(async (agent) => { + await initialize(agent); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + mcpServers: [linear], + additionalDirectories: [extra], + }); + await vi.waitFor(() => expect(sdk.processes[0].close).toHaveBeenCalled()); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(1)); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(client.elicitations).toHaveLength(1); + expect(withdrawn).toEqual([]); + }); + }); + }); + + it("stops a session whose query does not start again under the new route", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response } = await openSession(agent); + sdk.startFailure = new Error("Claude Code could not start"); + await setProvider(agent); + // The session is still active: v2 ends that only at close. + await expect( + agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }), + ).rejects.toMatchObject({ + code: -32603, + message: expect.stringContaining("could not start again. Resume the session"), + }); + // A close ends it: it is no longer active then. + await agent.request(v2.methods.agent.session.close, { sessionId: response.sessionId }); + await expect( + agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }), + ).rejects.toMatchObject({ code: -32002 }); + }); + }); + }); + + it("answers session/new with the mode and model options and the commands", async () => { + const client = v2Client(); + const { response, claude } = await client.connect((agent) => openSession(agent)); + + expect(response.configOptions?.map((option) => option.configId)).toEqual(["mode", "model"]); + expect(response.availableCommands).toEqual([ + { + type: "command", + name: "review", + description: "Review a change", + input: { type: "text", hint: "" }, + }, + // The adapter's own `/mcp`. + expect.objectContaining({ type: "command", name: "mcp" }), + ]); + // Each prompt is echoed, and the session's state is reported. + expect(claude.options.extraArgs).toMatchObject({ "replay-user-messages": "" }); + expect(claude.options.env?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS).toBe("1"); + }); + + it("answers a prompt when Claude Code starts it, and reports the work until the turn's result", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + const prompt = await claude.prompt(0); + claude.send(sessionState("running")); + claude.send(lifecycle(prompt, "started")); + expect(await answer).toEqual({ messageId: prompt.uuid }); + // The echo of a prompt that already started inserts nothing more. + claude.send(echo(prompt)); + for (const event of textDelta("msg_1", "Hello")) claude.send(event); + claude.send(assistant("msg_1", [{ type: "text", text: "Hello" }])); + claude.send(result({ result: "Hello" })); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + const kinds = client.sessionUpdates + .map((update) => update.sessionUpdate) + .filter((kind) => kind !== "available_commands_update"); + expect(kinds).toEqual([ + "user_message", + "state_update", + // The context window at the response's `message_start`, then at the result. + "usage_update", + "agent_message_chunk", + "usage_update", + "state_update", + ]); + expect(client.sessionUpdates.at(-1)).toEqual({ + sessionUpdate: "state_update", + state: "idle", + stopReason: "end_turn", + // The session reports its background work, on every state. + background: [], + usage: { + inputTokens: 3, + outputTokens: 2, + cachedReadTokens: 1, + cachedWriteTokens: 0, + totalTokens: 6, + }, + // The turn's tokens, and its rows by model. + _meta: { + quota: { + token_count: { + totalTokens: 6, + inputTokens: 3, + cachedInputTokens: 1, + cachedWriteTokens: 0, + outputTokens: 2, + reasoningOutputTokens: 0, + }, + model_usage: [], + }, + }, + }); + }); + + it("inserts a prompt that Claude Code folds into the running work at its echo", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + const prompt = await claude.prompt(0); + claude.send(echo(prompt)); + expect(await answer).toEqual({ messageId: prompt.uuid }); + }); + + expect(client.sessionUpdates).toContainEqual({ + sessionUpdate: "user_message", + messageId: expect.any(String), + content: PROMPT, + }); + }); + + it.each([ + ["discarded", -32603], + ["refused", -32603], + ])("fails a prompt that Claude Code %s", async (state, code) => { + const client = v2Client(); + const error = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + claude.send(lifecycle(await claude.prompt(0), state)); + return answer.catch((error: unknown) => error); + }); + + expect(error).toMatchObject({ code }); + }); + + it("cancels the work and the prompts not inserted yet with one interrupt", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const first = agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }); + const running = await claude.prompt(0); + claude.send(sessionState("running")); + claude.send(lifecycle(running, "started")); + await first; + const queued = agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }) + .catch((error: unknown) => error); + const waiting = await claude.prompt(1); + + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + // The cancel answers the queued prompt at once. + expect(await queued).toMatchObject({ code: -32800 }); + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalledWith({ cancelQueued: true })); + // Claude Code ends the queued prompt, then the interrupted work. + claude.send(lifecycle(waiting, "cancelled")); + claude.send(result({ subtype: "error_during_execution", is_error: true })); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "cancelled" }); + }); + + it("reports requires_action while a permission request is open, and running once answered", async () => { + const client = v2Client({ + answerPermission: (request) => ({ + outcome: { outcome: "selected", optionId: request.options[0].optionId }, + }), + }); + const decision = await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + return claude.options.canUseTool!( + "Bash", + { command: "ls" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_1", + requestId: "r1", + suggestions: [], + }, + ); + }); + + expect(decision?.behavior).toBe("allow"); + expect(client.states()).toEqual(["running", "requires_action", "running"]); + expect(client.permissionRequests[0].subject).toMatchObject({ + type: "tool_call", + toolCall: { toolCallId: "toolu_1" }, + }); + }); + + it("reads a dismissed permission request as the user refusing the tool", async () => { + const client = v2Client(); + const decision = await client.connect(async (agent) => { + const { claude } = await openSession(agent); + return claude.options.canUseTool!( + "Bash", + { command: "rm -rf build" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_1", + requestId: "r1", + suggestions: [], + }, + ); + }); + + expect(decision).toMatchObject({ behavior: "deny", decisionClassification: "user_reject" }); + }); + + it("sets a config option on the query and sends it as an update too", async () => { + const client = v2Client(); + const { claude, configOptions } = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const { configOptions } = await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId: response.sessionId, + configId: "mode", + type: "id", + value: "plan", + } as v2.SetSessionConfigOptionRequest); + return { claude, configOptions }; + }); + + expect(claude.setPermissionMode).toHaveBeenCalledWith("plan"); + const mode = configOptions.find((option) => option.configId === "mode"); + expect(mode).toMatchObject({ currentValue: "plan" }); + expect(client.sessionUpdates).toContainEqual({ + sessionUpdate: "config_option_update", + configOptions, + }); + }); + + it("forwards the result of a local command, which streamed no text", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: [{ type: "text", text: "/context" }], + }); + const prompt = await claude.prompt(0); + claude.send(sessionState("running")); + claude.send(lifecycle(prompt, "started")); + await answer; + claude.send(result({ result: "Context: 12k tokens" })); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates).toContainEqual( + expect.objectContaining({ + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "Context: 12k tokens" }, + }), + ); + }); + + it("sends the title that Claude Code stored, once its session state is idle", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + vi.mocked(getSessionInfo).mockResolvedValueOnce({ + sessionId: response.sessionId, + summary: "Hi", + customTitle: "Greet the user", + lastModified: Date.UTC(2026, 9, 2), + }); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + const prompt = await claude.prompt(0); + claude.send(lifecycle(prompt, "started")); + await answer; + claude.send(result()); + claude.send(sessionState("idle")); + await vi.waitFor(() => + expect(client.sessionUpdates.at(-1)).toEqual({ + sessionUpdate: "session_info_update", + title: "Greet the user", + updatedAt: "2026-10-02T00:00:00.000Z", + }), + ); + }); + }); + + it("reports a client MCP server that failed to start as a notice, once", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: {}, + }); + await agent.request(v2.methods.agent.session.new, { + cwd, + mcpServers: [{ type: "stdio", name: "files", command: "/usr/local/bin/mcp-files" }], + }); + const claude = sdk.processes.at(-1)!; + // Each turn's init lists the servers; one that is not the client's is not reported. + for (let turn = 0; turn < 2; turn++) { + claude.send({ + type: "system", + subtype: "init", + uuid: randomUUID(), + mcp_servers: [ + { name: "files", status: "failed" }, + { name: "plugin", status: "failed" }, + ], + }); + claude.send(result()); + } + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "idle", "running", "idle"]), + ); + }); + + expect(client.sessionUpdates.filter((update) => update.sessionUpdate === "notice")).toEqual([ + { + sessionUpdate: "notice", + severity: "warning", + title: 'MCP server "files" failed to start', + description: "Its tools are unavailable in this session. Run /mcp for the error.", + }, + ]); + }); + + it("replaces Claude Code's text of /mcp with the server list, before the turn's idle", async () => { + const client = v2Client(); + sdk.mcpServers = [{ name: "linear", status: "connected" }]; + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: [{ type: "text", text: "/mcp" }], + }); + const prompt = await claude.prompt(0); + claude.send(lifecycle(prompt, "started")); + await answer; + claude.send(result({ result: "1 MCP server connected. Run /mcp in the terminal." })); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + const output = client.sessionUpdates.flatMap((update) => + update.sessionUpdate === "agent_message_chunk" + ? [(update.content as { text: string }).text] + : update.sessionUpdate === "state_update" + ? [update.state] + : [], + ); + expect(output).toEqual(["running", expect.stringContaining("linear"), "idle"]); + expect(output.join()).not.toContain("in the terminal"); + }); + + it("fails the pending prompts and ends the work when Claude Code exits", async () => { + const client = v2Client(); + const error = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send( + assistant("msg_1", [ + { type: "tool_use", id: "toolu_1", name: "Bash", input: { command: "make" } }, + ]), + ); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + await claude.prompt(0); + claude.end(new Error("Claude Code process exited with code 1")); + const error = await answer.catch((error: unknown) => error); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + return error; + }); + + expect(error).toMatchObject({ code: -32603 }); + // The unfinished tool call fails before the idle. + expect(client.sessionUpdates.slice(-2)).toMatchObject([ + { sessionUpdate: "tool_call_update", toolCallId: "toolu_1", status: "failed" }, + { state: "idle", stopReason: "error" }, + ]); + }); + + it("ends an open compaction before the idle when Claude Code exits during it", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: [{ type: "text", text: "/compact" }], + }); + await claude.prompt(0); + claude.send(cycleStart()); + claude.send({ + type: "system", + subtype: "status", + status: "compacting", + session_id: response.sessionId, + uuid: "compact-1", + }); + claude.end(new Error("Claude Code process exited with code 1")); + await answer.catch(() => {}); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect( + client.sessionUpdates.filter( + (update) => + update.sessionUpdate === "compaction_update" || update.sessionUpdate === "state_update", + ), + ).toMatchObject([ + { sessionUpdate: "state_update", state: "running" }, + { sessionUpdate: "compaction_update", compactionId: "compact-1", status: "in_progress" }, + { sessionUpdate: "compaction_update", compactionId: "compact-1", status: "cancelled" }, + { sessionUpdate: "state_update", state: "idle", stopReason: "error" }, + ]); + }); + + it("starts a session whose query ended again on resume", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + claude.end(new Error("Claude Code process exited with code 1")); + // The session is still active, as v2 asks until its close: its + // requests say what happened instead of -32002. + for (const request of [ + agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }), + agent.request(v2.methods.agent.session.setConfigOption, { + sessionId: response.sessionId, + configId: "mode", + type: "id", + value: "plan", + }), + ]) { + await expect(request).rejects.toMatchObject({ + code: -32603, + message: expect.stringContaining("Claude Code stopped. Resume the session to continue."), + data: { sessionId: response.sessionId }, + }); + } + await agent.request(v2.methods.agent.session.resume, { sessionId: response.sessionId, cwd }); + }); + + expect(sdk.processes).toHaveLength(2); + expect(sdk.processes[1].options.resume).toBe(sdk.processes[0].options.sessionId); + }); + + it("starts an inactive session once for concurrent resumes", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + await openSession(agent); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const sessionId = randomUUID(); + const resumes = [1, 2].map(() => + agent.request(v2.methods.agent.session.resume, { sessionId, cwd }), + ); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + started.resolve(); + await Promise.all(resumes); + }); + + expect(sdk.processes).toHaveLength(2); + expect(sdk.processes[1].close).not.toHaveBeenCalled(); + }); + + it("starts a session again for a resume that waited for a start that failed", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + await openSession(agent); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + sdk.startFailure = new Error("Claude Code process exited with code 1"); + const sessionId = randomUUID(); + const failed = agent + .request(v2.methods.agent.session.resume, { sessionId, cwd }) + .catch((error: unknown) => error); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + const resumed = agent.request(v2.methods.agent.session.resume, { sessionId, cwd }); + // The second resume reaches the agent while the first start waits. + await new Promise((resolve) => setTimeout(resolve, 20)); + started.resolve(); + expect(await failed).toMatchObject({ code: -32603 }); + sdk.startFailure = undefined; + await resumed; + }); + + expect(sdk.processes).toHaveLength(3); + }); + + it("closes a session that session/close found still starting", async () => { + const client = v2Client(); + const error = await client.connect(async (agent) => { + await openSession(agent); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const sessionId = randomUUID(); + const resume = agent.request(v2.methods.agent.session.resume, { sessionId, cwd }); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + const close = agent.request(v2.methods.agent.session.close, { sessionId }); + // The close reaches the agent while the start waits. + await new Promise((resolve) => setTimeout(resolve, 20)); + started.resolve(); + await resume; + await close; + expect(sdk.processes[1].close).toHaveBeenCalled(); + return agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }) + .catch((error: unknown) => error); + }); + + expect(error).toMatchObject({ code: -32002 }); + }); + + it("closes a session that session/delete found still starting", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + await openSession(agent); + const started = Promise.withResolvers(); + sdk.startGate = started.promise; + const sessionId = randomUUID(); + const resume = agent.request(v2.methods.agent.session.resume, { sessionId, cwd }); + await vi.waitFor(() => expect(sdk.processes).toHaveLength(2)); + const deleted = agent.request(v2.methods.agent.session.delete, { sessionId }); + // The delete reaches the agent while the start waits. + await new Promise((resolve) => setTimeout(resolve, 20)); + started.resolve(); + await resume; + await deleted; + expect(sdk.processes[1].close).toHaveBeenCalled(); + }); + }); + + it("answers a session/close that comes during a resume's replay after the resume, and sends nothing after it", async () => { + const client = v2Client(); + const read = Promise.withResolvers(); + await client.connect(async (agent) => { + const { response } = await openSession(agent); + const sessionId = response.sessionId; + vi.mocked(getSessionMessages).mockReturnValueOnce(read.promise); + const order: string[] = []; + const resume = agent + .request(v2.methods.agent.session.resume, { sessionId, cwd, replayFrom: { type: "start" } }) + .then(() => order.push("resume")); + await vi.waitFor(() => expect(getSessionMessages).toHaveBeenCalled()); + let sentBeforeClose = -1; + const close = agent.request(v2.methods.agent.session.close, { sessionId }).then(() => { + order.push("close"); + sentBeforeClose = client.notifications.length; + }); + // The close reaches the agent while the replay reads the transcript. + await new Promise((resolve) => setTimeout(resolve, 20)); + read.resolve([]); + await Promise.all([resume, close]); + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(order).toEqual(["resume", "close"]); + expect(client.notifications.slice(sentBeforeClose)).toEqual([]); + const restarts = (agents[0] as unknown as { restarts: { setups: Map } }) + .restarts; + expect(restarts.setups.has(sessionId)).toBe(false); + }); + }); + + it("ends the work at the turn's result while background subagents keep Claude Code running", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + const prompt = await claude.prompt(0); + // As the bundled CLI sends it: `running` stays until the background + // subagent ends and the turn that Claude Code starts on its own ends. + claude.send(sessionState("running")); + claude.send(lifecycle(prompt, "started")); + claude.send(cycleStart()); + await answer; + claude.send(result({ result: "launched" })); + claude.send(lifecycle(prompt, "completed")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + claude.send(cycleStart()); + claude.send(result({ result: "The subagent finished." })); + claude.send(sessionState("idle")); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "idle", "running", "idle"]), + ); + }); + }); + + it("does not report a later turn as cancelled after a cancel that only ended queued prompts", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const first = agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }) + .catch((error: unknown) => error); + const queued = await claude.prompt(0); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + expect(await first).toMatchObject({ code: -32800 }); + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled()); + claude.send(lifecycle(queued, "cancelled")); + + const second = agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }); + const prompt = await claude.prompt(1); + claude.send(lifecycle(prompt, "started")); + await second; + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "end_turn" }); + }); + + it("drops the run of a prompt that a cancel answered after Claude Code took it in, and denies its tools", async () => { + const client = v2Client(); + const decision = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const queued = agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }) + .catch((error: unknown) => error); + const prompt = await claude.prompt(0); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + expect(await queued).toMatchObject({ code: -32800 }); + // Claude Code had taken the prompt in before the interrupt arrived. + claude.send(lifecycle(prompt, "started")); + claude.send( + assistant("msg_late", [ + { type: "tool_use", id: "toolu_late", name: "Bash", input: { command: "make" } }, + ]), + ); + const decision = await claude.options.canUseTool!( + "Bash", + { command: "make" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_late", + requestId: "r1", + suggestions: [], + }, + ); + claude.send(result()); + claude.send(sessionState("idle")); + // A later cycle shows that the session goes on. + claude.send(cycleStart()); + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + return decision; + }); + + expect(decision).toEqual({ behavior: "deny", message: "The user cancelled this prompt." }); + expect(client.permissionRequests).toEqual([]); + expect( + client.sessionUpdates.filter( + (update) => update.sessionUpdate === "user_message" || "toolCallId" in update, + ), + ).toEqual([]); + }); + + it("ends a turn cancelled when a cancel comes while an approved plan waits for its fresh context", async () => { + const client = v2Client({ + answerPermission: () => ({ + outcome: { outcome: "selected", optionId: "exit-plan-clear-auto" }, + }), + }); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const prompted = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: PROMPT, + }); + claude.send(echo(await claude.prompt(0))); + await prompted; + claude.send( + assistant("msg_plan", [ + { type: "tool_use", id: "toolu_plan", name: "ExitPlanMode", input: { plan: "Do it" } }, + ]), + ); + const decision = await askPermission( + claude, + "ExitPlanMode", + { plan: "Do it" }, + { toolUseID: "toolu_plan" }, + ); + expect(decision).toMatchObject({ behavior: "deny", interrupt: true }); + claude.send({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_plan", + content: "User accepted the plan and requested a fresh context", + is_error: true, + }, + ], + }, + tool_result_meta: [{ id: "toolu_plan", non_execution_kind: "user-rejected" }], + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + stop_reason: null, + errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], + }), + ); + claude.send(sessionState("idle")); + // The turn goes on: the session sends `/clear` for the fresh context. + const clear = await claude.prompt(1); + expect(clear.message.content).toEqual([{ type: "text", text: "/clear" }]); + expect(client.states()).toEqual(["running", "requires_action", "running"]); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "requires_action", "running", "idle"]), + ); + // Claude Code had taken `/clear` in before the interrupt: its run is dropped. + claude.send(lifecycle(clear, "started")); + claude.send(echo(clear)); + claude.send(result({ num_turns: 0, stop_reason: null })); + claude.send(lifecycle(clear, "completed")); + claude.send(sessionState("idle")); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect( + client.sessionUpdates.filter((update) => update.sessionUpdate === "state_update").at(-1), + ).toMatchObject({ state: "idle", stopReason: "cancelled" }); + expect(client.states()).toEqual(["running", "requires_action", "running", "idle"]); + // No plan's prompt went out after the cancel. + expect(sdk.processes[0].prompts).toHaveLength(2); + }); + + it("continues an approved plan in its fresh context while a /remote-control waits for the turn", async () => { + sdk.enableRemoteControl = vi.fn(async () => ({ + session_url: "https://claude.ai/code/session_123", + })); + const client = v2Client({ + answerPermission: () => ({ + outcome: { outcome: "selected", optionId: "exit-plan-clear-auto" }, + }), + }); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const prompted = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: PROMPT, + }); + claude.send(echo(await claude.prompt(0))); + await prompted; + claude.send( + assistant("msg_plan", [ + { type: "tool_use", id: "toolu_plan", name: "ExitPlanMode", input: { plan: "Do it" } }, + ]), + ); + await askPermission(claude, "ExitPlanMode", { plan: "Do it" }, { toolUseID: "toolu_plan" }); + // Held until the turn ends. + const command = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: [{ type: "text", text: "/remote-control" }], + }); + claude.send({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_plan", + content: "User accepted the plan and requested a fresh context", + is_error: true, + }, + ], + }, + tool_result_meta: [{ id: "toolu_plan", non_execution_kind: "user-rejected" }], + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + stop_reason: null, + errors: ["[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use"], + }), + ); + claude.send(sessionState("idle")); + const clear = await claude.prompt(1); + claude.send(lifecycle(clear, "started")); + claude.send(echo(clear)); + claude.send({ + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + timestamp: new Date().toISOString(), + session_id: sessionId, + }); + claude.send(result({ num_turns: 0, stop_reason: null })); + claude.send(lifecycle(clear, "completed")); + const plan = await claude.prompt(2); + claude.send(lifecycle(plan, "started")); + claude.send(echo(plan)); + claude.send(result()); + claude.send(lifecycle(plan, "completed")); + claude.send(sessionState("idle")); + await command; + await vi.waitFor(() => expect(sdk.enableRemoteControl).toHaveBeenCalled()); + }); + + expect(sdk.processes[0].prompts.map((prompt) => prompt.message.content)).toEqual([ + PROMPT, + [{ type: "text", text: "/clear" }], + [{ type: "text", text: "Implement the following plan:\n\nDo it" }], + ]); + }); + + it("ends a cycle at its own result, not at a placeholder or at the idle of the turn before", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(result()); + // The turn's idle comes after the next cycle started. + claude.send(cycleStart()); + claude.send(sessionState("idle")); + const followup = { origin: { kind: "task-notification" } }; + claude.send(result({ ...followup, num_turns: 0 })); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(client.states()).toEqual(["running", "idle", "running"]); + claude.send(result({ ...followup, num_turns: 1 })); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "idle", "running", "idle"]), + ); + }); + }); + + it("reports a tool call that Claude dropped before it ran as cancelled at the turn's end", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send({ + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_dropped", name: "Bash", input: {} }, + }, + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.slice(-2)).toMatchObject([ + { + sessionUpdate: "tool_call_update", + toolCallId: "toolu_dropped", + status: "cancelled", + content: [ + { + type: "content", + content: { type: "text", text: "Claude stopped this tool call before it ran." }, + }, + ], + }, + { state: "idle", stopReason: "end_turn" }, + ]); + }); + + it("pushes a prompt sent after session/cancel only once the interrupt is done", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + let finishInterrupt = () => {}; + claude.interrupt.mockImplementationOnce( + () => new Promise((resolve) => (finishInterrupt = resolve)), + ); + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled()); + // Never answered: the test ends before Claude Code starts it. + agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(claude.prompts).toHaveLength(0); + finishInterrupt(); + await claude.prompt(0); + }); + }); + + it("closes active work with its cancelled idle before the response, and nothing after", async () => { + const client = v2Client(); + let updatesAtResponse = 0; + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + claude.interrupt.mockImplementationOnce(async () => { + claude.send(result({ subtype: "error_during_execution", is_error: true })); + }); + await agent.request(v2.methods.agent.session.close, { sessionId }); + updatesAtResponse = client.sessionUpdates.length; + claude.send(assistant("msg_late", [{ type: "text", text: "late" }])); + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "cancelled" }); + expect(client.sessionUpdates).toHaveLength(updatesAtResponse); + }); + + it.each([ + ["session/close", v2.methods.agent.session.close], + ["session/delete", v2.methods.agent.session.delete], + ])("answers %s of a session that does not exist with an empty result", async (_, method) => { + const client = v2Client(); + const response = await client.connect(async (agent) => { + await openSession(agent); + return agent.request(method, { sessionId: randomUUID() }); + }); + + expect(response).toEqual({}); + }); + + it("rejects a config value whose type does not match the option", async () => { + const client = v2Client(); + const error = await client.connect(async (agent) => { + const { response } = await openSession(agent); + return agent + .request(v2.methods.agent.session.setConfigOption, { + sessionId: response.sessionId, + configId: "mode", + type: "boolean", + value: true, + } as v2.SetSessionConfigOptionRequest) + .catch((error: unknown) => error); + }); + + expect(error).toMatchObject({ code: -32602 }); + }); + + it("rejects additional directories that are not absolute", async () => { + const client = v2Client(); + const error = await client.connect(async (agent) => { + await openSession(agent); + return agent + .request(v2.methods.agent.session.new, { cwd, additionalDirectories: ["src"] }) + .catch((error: unknown) => error); + }); + + expect(error).toMatchObject({ code: -32602 }); + }); + + it("replays the stored history, then sends the state, before the resume response", async () => { + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + { + type: "user", + uuid: "u1", + session_id: "s", + parent_tool_use_id: null, + parent_agent_id: null, + message: { role: "user", content: "Fix the build" }, + }, + { + type: "assistant", + uuid: "a1", + session_id: "s", + parent_tool_use_id: null, + parent_agent_id: null, + message: { id: "msg_a", role: "assistant", content: [{ type: "text", text: "Done" }] }, + }, + ]); + const client = v2Client(); + const sessionId = randomUUID(); + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: {}, + }); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: { type: "start" }, + }); + }); + + expect(sdk.processes[0].options.resume).toBe(sessionId); + expect(client.sessionUpdates).toEqual([ + { sessionUpdate: "user_message", messageId: "u1", content: [] }, + { + sessionUpdate: "user_message_chunk", + messageId: "u1", + content: { type: "text", text: "Fix the build" }, + }, + { sessionUpdate: "agent_message", messageId: "msg_a", content: [] }, + { + sessionUpdate: "agent_message_chunk", + messageId: "msg_a", + content: { type: "text", text: "Done" }, + }, + // A snapshot, not a completion: no stop reason. + { sessionUpdate: "state_update", state: "idle", background: [] }, + ]); + }); + + it("gives a replayed tool call that has no result a final status, before the state", async () => { + const entry = (type: string, uuid: string, message: unknown) => ({ + type, + uuid, + session_id: "s", + parent_tool_use_id: null, + parent_agent_id: null, + message, + }); + // Claude Code was killed while `make` ran: the transcript has no result for it. + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + entry("user", "u1", { role: "user", content: "Build it" }), + entry("assistant", "a1", { + id: "msg_a", + role: "assistant", + content: [{ type: "tool_use", id: "toolu_make", name: "Bash", input: { command: "make" } }], + }), + ] as never); + const client = v2Client(); + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: {}, + }); + await agent.request(v2.methods.agent.session.resume, { + sessionId: randomUUID(), + cwd, + replayFrom: { type: "start" }, + }); + }); + + expect(client.sessionUpdates.slice(-2)).toEqual([ + { sessionUpdate: "tool_call_update", toolCallId: "toolu_make", status: "failed" }, + { sessionUpdate: "state_update", state: "idle", background: [] }, + ]); + }); + + it("replays a compaction's summary message as the completed compaction, at its place", async () => { + const entry = (uuid: string, message: unknown, fields: Record = {}) => ({ + type: "user", + uuid, + session_id: "s", + parent_tool_use_id: null, + parent_agent_id: null, + message, + ...fields, + }); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + entry("u1", { role: "user", content: "Count" }), + // Claude Code's framing of the summary that it keeps. + entry( + "summary", + { + role: "user", + content: + "This session is being continued from a previous conversation that ran out of context.\n\n" + + "Summary:\nCount upward.\n\n" + + "Continue the conversation from where it left off without asking the user any further questions.", + }, + { isCompactSummary: true }, + ), + entry("u2", { role: "user", content: "Go on" }), + ] as never); + const client = v2Client(); + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: {}, + }); + await agent.request(v2.methods.agent.session.resume, { + sessionId: randomUUID(), + cwd, + replayFrom: { type: "start" }, + }); + }); + + expect( + client.sessionUpdates + .filter((update) => !update.sessionUpdate.endsWith("_chunk")) + .slice(0, 3), + ).toEqual([ + { sessionUpdate: "user_message", messageId: "u1", content: [] }, + { + sessionUpdate: "compaction_update", + compactionId: "summary", + status: "completed", + summary: [{ type: "text", text: "Count upward." }], + }, + { sessionUpdate: "user_message", messageId: "u2", content: [] }, + ]); + }); + + it.each([ + [ + "a prompt that Claude Code started after the cancel answered it", + ["result", "started", "cancelled", "late"], + ], + ["the turn that a cancelled frame closed", ["cancelled", "late"]], + ])("skips the late result of a dead turn: %s", async (_, events) => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const first = agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }); + const p1 = await claude.prompt(0); + claude.send(lifecycle(p1, "started")); + await first; + const second = agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }) + .catch((error: unknown) => error); + const p2 = await claude.prompt(1); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + expect(await second).toMatchObject({ code: -32800 }); + for (const event of events) { + if (event === "result") { + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(sessionState("idle")); + } else if (event === "started") { + claude.send(lifecycle(p2, "started")); + } else if (event === "cancelled") { + claude.send(lifecycle(events.includes("started") ? p2 : p1, "cancelled")); + } else { + // The dead turn's late result. + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(sessionState("idle")); + } + } + // A later cycle shows that the session went on. + claude.send(cycleStart()); + claude.send(result()); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "idle", "running", "idle"]), + ); + }); + + const idles = client.sessionUpdates.filter( + (update) => update.sessionUpdate === "state_update" && update.state === "idle", + ); + expect(idles.map((idle) => (idle as { stopReason?: string }).stopReason)).toEqual([ + "cancelled", + "end_turn", + ]); + }); + + it("keeps dropping a prompt's run when another abandoned prompt's cancelled frame comes", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const ask = () => + agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }) + .catch((error: unknown) => error); + const [b, c] = [ask(), ask()]; + const [promptB, promptC] = [await claude.prompt(0), await claude.prompt(1)]; + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + expect(await b).toMatchObject({ code: -32800 }); + expect(await c).toMatchObject({ code: -32800 }); + // Claude Code had taken B in. The interrupt's `cancelQueued` ends C. + claude.send(lifecycle(promptB, "started")); + claude.send(lifecycle(promptC, "cancelled")); + for (const event of textDelta("msg_b", "B runs")) claude.send(event); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(lifecycle(promptB, "completed")); + claude.send(sessionState("idle")); + claude.send(cycleStart()); + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect( + client.sessionUpdates.filter((update) => update.sessionUpdate === "agent_message_chunk"), + ).toEqual([]); + expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "end_turn" }); + }); + + it.each([ + ["a cycle of Claude Code's own ended", "cycle"], + ["a prompt started", "prompt"], + ])( + "does not read a later aborted result as cancelled by a cancel of an idle session once %s", + async (_, after) => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + // Claude Code runs nothing: the interrupt stops nothing and sends nothing. + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled()); + if (after === "cycle") { + // Say, a background task ended: Claude Code starts a cycle after the interrupt. + claude.send(cycleStart()); + claude.send(result()); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + claude.send(cycleStart()); + } else { + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId, + prompt: PROMPT, + }); + claude.send(lifecycle(await claude.prompt(0), "started")); + await answer; + } + // Then an interrupt of Claude Code's remote control stops the work. + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(sessionState("idle")); + await vi.waitFor(() => + expect(client.states()).toEqual( + after === "cycle" ? ["running", "idle", "running", "idle"] : ["running", "idle"], + ), + ); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ state: "idle", stopReason: "error" }); + }, + ); + + it("sends no permission request of a session after session/close answered", async () => { + const client = v2Client(); + const decision = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + await agent.request(v2.methods.agent.session.close, { sessionId: response.sessionId }); + // Claude Code asks while its process ends. + return askPermission(claude, "Bash", { command: "ls" }); + }); + + expect(client.permissionRequests).toEqual([]); + expect(decision).toMatchObject({ behavior: "deny" }); + }); + + it("does not show Claude Code's sign-in message, and fails the turn with authRequired", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send({ + ...assistant("msg_login", [{ type: "text", text: "Not logged in · Please run /login" }]), + message: { + id: "msg_login", + type: "message", + role: "assistant", + model: "", + content: [{ type: "text", text: "Not logged in · Please run /login" }], + usage: {}, + }, + error: "authentication_failed", + }); + claude.send(result({ is_error: true, result: "Not logged in · Please run /login" })); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect( + client.sessionUpdates.filter((update) => update.sessionUpdate === "agent_message_chunk"), + ).toEqual([]); + expect(client.sessionUpdates.at(-1)).toMatchObject({ + state: "idle", + stopReason: "error", + error: { code: -32000 }, + }); + }); + + it.each([ + [ + "an authentication failure as authRequired", + "authentication_failed", + "Failed to authenticate. API Error: 401", + { code: -32000 }, + ], + [ + "another failure with its error kind", + "rate_limit", + "API Error: 429", + { code: -32603, data: { errorKind: "rate_limit" } }, + ], + ])("reports %s, from the assistant message's error", async (_, kind, text, error) => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send({ ...assistant("msg_failed", [{ type: "text", text }]), error: kind }); + claude.send(result({ is_error: true, result: text })); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject({ + state: "idle", + stopReason: "error", + error, + }); + }); + + it.each([ + [ + "an error at the turn limit", + { subtype: "error_max_turns", is_error: true, errors: ["a", "b"] }, + { stopReason: "error", error: { message: "Internal error: a, b" } }, + ], + [ + "a run that is no error", + { subtype: "error_during_execution", is_error: false }, + { stopReason: "end_turn" }, + ], + [ + "the budget limit", + { subtype: "error_max_budget_usd", is_error: false }, + { stopReason: "max_turn_requests" }, + ], + [ + "the turn limit at the token limit", + { subtype: "error_max_turns", is_error: false, stop_reason: "max_tokens" }, + { stopReason: "max_turn_requests" }, + ], + ])("reads %s from the result as the v1 core does", async (_, fields, expected) => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(result(fields)); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.at(-1)).toMatchObject(expected); + }); + + it("answers a refusal with the explanation of model_refusal_no_fallback when the message has none", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send({ + ...assistant("msg_refused", []), + message: { + id: "msg_refused", + type: "message", + role: "assistant", + content: [], + stop_reason: "refusal", + usage: {}, + }, + }); + claude.send({ + type: "system", + subtype: "model_refusal_no_fallback", + original_model: "opus", + request_id: null, + api_refusal_explanation: "This request is declined.", + refused_user_message_uuid: "u1", + content: "Claude declined", + uuid: randomUUID(), + session_id: "session", + }); + claude.send(result({ stop_reason: "refusal" })); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(client.sessionUpdates.slice(-2)).toMatchObject([ + { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "This request is declined." }, + }, + { state: "idle", stopReason: "refusal" }, + ]); + }); + + it("denies the AskUserQuestion of a prompt that a cancel answered, without asking", async () => { + const client = v2Client(); + const decision = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent, { elicitation: { form: {} } }); + const sessionId = response.sessionId; + const queued = agent + .request(v2.methods.agent.session.prompt, { sessionId, prompt: PROMPT }) + .catch((error: unknown) => error); + const prompt = await claude.prompt(0); + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + expect(await queued).toMatchObject({ code: -32800 }); + claude.send(lifecycle(prompt, "started")); + claude.send( + assistant("msg_late", [ + { type: "tool_use", id: "toolu_ask", name: "AskUserQuestion", input: {} }, + ]), + ); + return askPermission(claude, "AskUserQuestion", { + questions: [ + { + question: "Which?", + header: "Which", + options: [{ label: "a" }, { label: "b" }], + multiSelect: false, + }, + ], + }); + }); + + expect(decision).toEqual({ behavior: "deny", message: "The user cancelled this prompt." }); + expect(client.elicitations).toEqual([]); + }); + + it("runs auto as acceptEdits, with a notice, for a model that does not support it", async () => { + sdk.models = [ + { + value: "default", + displayName: "Default", + description: "Recommended", + supportsAutoMode: true, + }, + { value: "haiku", displayName: "Haiku", description: "Fast", supportsAutoMode: false }, + ]; + const client = v2Client(); + const modes = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const set = (configId: string, value: string) => + agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId, + type: "id", + value, + } as v2.SetSessionConfigOptionRequest); + await set("mode", "auto"); + // A switch to a model without Auto mode leaves it. + const switched = await set("model", "haiku"); + const picked = await set("mode", "auto"); + return { claude, switched, picked }; + }); + + const mode = (options: v2.SessionConfigOption[]) => + options.find((option) => option.configId === "mode")?.currentValue; + expect(mode(modes.switched.configOptions)).toBe("acceptEdits"); + expect(mode(modes.picked.configOptions)).toBe("acceptEdits"); + expect(modes.claude.setPermissionMode).toHaveBeenLastCalledWith("acceptEdits"); + // Once per session. + expect(client.sessionUpdates.filter((update) => update.sessionUpdate === "notice")).toEqual([ + expect.objectContaining({ title: "Auto mode unavailable" }), + ]); + }); + + it("follows a model switch that Claude Code made without the adapter", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + const hook = claude.options.hooks?.PostModelSwitch?.at(-1)?.hooks[0]; + await hook!( + { + hook_event_name: "PostModelSwitch", + source: "command", + from_model: "default", + to_model: "opus", + } as never, + undefined, + { signal: new AbortController().signal }, + ); + await vi.waitFor(() => + expect(client.sessionUpdates.at(-1)).toMatchObject({ + sessionUpdate: "config_option_update", + }), + ); + }); + + const update = client.sessionUpdates.at(-1) as { configOptions: v2.SessionConfigOption[] }; + expect(update.configOptions.find((option) => option.configId === "model")?.currentValue).toBe( + "opus", + ); + }); + + it("names the MCP server of a tool in the permission request", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + await askPermission( + claude, + "mcp__docs__search", + { query: "x" }, + { + mcpServer: { name: "docs", source: "sdk" }, + }, + ); + }); + + const subject = client.permissionRequests[0].subject as { toolCall: { _meta?: unknown } }; + expect(subject.toolCall._meta).toMatchObject({ + claudeCode: { mcpServer: { name: "docs", source: "sdk" } }, + }); + }); + + it("offers clearing the context at 0% used after Claude Code mounted a fresh conversation", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const prompted = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + claude.send(echo(await claude.prompt(0))); + await prompted; + const planned = assistant("msg_1", [{ type: "text", text: "Planned." }]); + planned.message.usage = { input_tokens: 100_000, output_tokens: 0 }; + claude.send(planned); + await vi.waitFor(() => + expect(client.sessionUpdates.at(-1)?.sessionUpdate).toBe("agent_message_chunk"), + ); + await askPermission(claude, "ExitPlanMode", { plan: "Fix it" }); + claude.send({ + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + timestamp: new Date().toISOString(), + session_id: "session", + }); + await askPermission(claude, "ExitPlanMode", { plan: "Fix it" }); + }); + + // After the reset, the context in use is 0, not the old context's figure. + expect( + client.permissionRequests.map( + (request) => + request.options.find((option) => option.optionId === "exit-plan-clear-auto")?.name, + ), + ).toEqual([ + "Yes, clear context (50% used) and use auto mode", + "Yes, clear context (0% used) and use auto mode", + ]); + }); + + it("lists bypass before auto in ExitPlanMode's options after plan mode left bypass", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + for (const value of ["bypassPermissions", "plan"]) { + await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "mode", + type: "id", + value, + } as v2.SetSessionConfigOptionRequest); + } + await askPermission(claude, "ExitPlanMode", { plan: "Fix it" }); + }); + + expect( + client.permissionRequests[0].options + .map((option) => option.optionId) + .filter((id) => id === "exit-plan-bypass" || id === "exit-plan-auto"), + ).toEqual(["exit-plan-bypass", "exit-plan-auto"]); + }); + + it("disposes the settings of a session that fails to start", async () => { + const initialized = vi.spyOn(SettingsManager.prototype, "initialize"); + const disposed = vi.spyOn(SettingsManager.prototype, "dispose"); + const client = v2Client(); + const error = await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: {}, + }); + return agent + .request(v2.methods.agent.session.new, { + cwd, + _meta: { claudeCode: { options: { settings: "missing.json" } } }, + }) + .catch((error: unknown) => error); + }); + + expect(error).toBeDefined(); + expect(disposed).toHaveBeenCalledTimes(initialized.mock.calls.length); + initialized.mockRestore(); + disposed.mockRestore(); + }); + + describe("subagents and background work", () => { + it("announces a subagent before its traffic, and reports its runs on its own stream", async () => { + const client = v2Client(); + const agentId = "a7f0c1"; + const sessionId = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const child = subagent(agentId, "toolu_launch"); + claude.send(cycleStart()); + claude.send(child.launch()); + // Its first message can come before its `task_started`: it waits for the announcement. + claude.send(child.prompt("Find why the test fails")); + claude.send(child.started()); + claude.send(child.says("It is a race.")); + claude.send(child.ended()); + await vi.waitFor(() => + expect(updatesOf(client.notifications, agentId).at(-1)).toMatchObject({ + sessionUpdate: "state_update", + state: "idle", + }), + ); + return response.sessionId; + }); + + // The parent is not shown the Agent call: the announcement, then the + // call's prompt as the parent's message to the child. + const parent = updatesOf(client.notifications, sessionId).filter( + (update) => + update.sessionUpdate === "subagent_update" || + update.sessionUpdate === "session_message" || + update.sessionUpdate === "tool_call_update", + ); + expect(parent).toEqual([ + { + sessionUpdate: "subagent_update", + sessionId: agentId, + title: "Investigate the test", + description: "general-purpose", + capabilities: { cancel: {} }, + state: { state: "running", background: [] }, + }, + { + sessionUpdate: "session_message", + messageId: "toolu_launch", + senderSessionId: sessionId, + recipientSessionId: agentId, + recipientMessageId: "toolu_launch", + content: [{ type: "text", text: "Find why the test fails" }], + }, + { + sessionUpdate: "subagent_update", + sessionId: agentId, + state: { state: "idle", stopReason: "end_turn", background: [] }, + }, + ]); + // The announcement comes before the child's first update. + const announced = client.notifications.findIndex( + (n) => n.update.sessionUpdate === "subagent_update", + ); + const firstChild = client.notifications.findIndex((n) => n.sessionId === agentId); + expect(announced).toBeLessThan(firstChild); + expect( + updatesOf(client.notifications, agentId).map((update) => update.sessionUpdate), + ).toEqual(["state_update", "session_message", "agent_message_chunk", "state_update"]); + expect(updatesOf(client.notifications, agentId)[1]).toMatchObject({ + senderSessionId: sessionId, + recipientSessionId: agentId, + senderMessageId: "toolu_launch", + content: [{ type: "text", text: "Find why the test fails" }], + }); + }); + + it("lists a background subagent while it works, after the turn's idle", async () => { + const client = v2Client(); + const agentId = "a5966d"; + const sessionId = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const child = subagent(agentId, "toolu_bg_agent", { is_backgrounded: true }); + claude.send(cycleStart()); + claude.send(child.launch()); + claude.send({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: agentId, task_type: "local_agent", description: "sleeper" }], + uuid: randomUUID(), + }); + claude.send(child.started()); + claude.send(result({ result: "launched" })); + // The list changes while the turn runs: the state goes out again with it. + await vi.waitFor(() => expect(client.states()).toEqual(["running", "running", "idle"])); + claude.send(child.ended()); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "running", "idle", "idle"]), + ); + return response.sessionId; + }); + + const states = updatesOf(client.notifications, sessionId).filter( + (update) => update.sessionUpdate === "state_update", + ); + // The turn ends at its result, with the subagent still working. + expect(states[2]).toMatchObject({ + state: "idle", + stopReason: "end_turn", + background: [{ type: "subagent", sessionId: agentId }], + }); + // Its end drops it: a repeated idle without a stop reason, after the child's own idle. + expect(states[3]).toEqual({ sessionUpdate: "state_update", state: "idle", background: [] }); + const childIdle = client.notifications.findIndex( + (n) => + n.sessionId === agentId && + n.update.sessionUpdate === "state_update" && + n.update.state === "idle", + ); + expect(childIdle).toBeLessThan( + client.notifications.lastIndexOf(client.notifications.find((n) => n.update === states[3])!), + ); + }); + + it("announces a nested subagent on its parent subagent's stream", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + const inner = subagent("inner", "toolu_inner", { parent_task_id: "outer" }); + claude.send(subagent("outer", "toolu_outer").launch()); + claude.send(subagent("outer", "toolu_outer").started()); + claude.send(inner.launch("toolu_outer")); + claude.send(inner.started()); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "outer")).toContainEqual( + expect.objectContaining({ sessionUpdate: "subagent_update", sessionId: "inner" }), + ), + ); + }); + }); + + it("asks a subagent's permission on its session, and blocks the work that waits on it", async () => { + let answer = (_: v2.RequestPermissionResponse) => {}; + const client = v2Client({ + answerPermission: () => new Promise((resolve) => (answer = resolve)), + }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(subagent("child", "toolu_launch").launch()); + claude.send(subagent("child", "toolu_launch").started()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + const decision = claude.options.canUseTool!( + "Bash", + { command: "rm -rf build" }, + { + signal: new AbortController().signal, + toolUseID: "toolu_rm", + requestId: "r1", + suggestions: [], + agentID: "child", + }, + ); + await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(1)); + expect(client.permissionRequests[0].sessionId).toBe("child"); + // The parent's turn waits on the foreground subagent, which waits on the user. + await vi.waitFor(() => expect(client.states()).toEqual(["running", "requires_action"])); + expect(updatesOf(client.notifications, "child").at(-1)).toMatchObject({ + state: "requires_action", + }); + answer({ + outcome: { + outcome: "selected", + optionId: client.permissionRequests[0].options[0].optionId, + }, + }); + await decision; + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "requires_action", "running"]), + ); + }); + }); + + it("stops a subagent's run on session/cancel of its session, and leaves the parent's work alone", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(subagent("child", "toolu_launch", { is_backgrounded: true }).launch()); + claude.send(subagent("child", "toolu_launch", { is_backgrounded: true }).started()); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "child")).toContainEqual( + expect.objectContaining({ state: "running" }), + ), + ); + claude.send({ + ...assistant("msg_child_bash", [ + { type: "tool_use", id: "toolu_child_bash", name: "Bash", input: { command: "make" } }, + ]), + parent_tool_use_id: "toolu_launch", + }); + await agent.notify(v2.methods.agent.session.cancel, { sessionId: "child" }); + await vi.waitFor(() => expect(claude.stopTask).toHaveBeenCalledWith("child")); + expect(claude.interrupt).not.toHaveBeenCalled(); + claude.send(subagent("child", "toolu_launch").ended("stopped")); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "child").at(-1)).toMatchObject({ + state: "idle", + stopReason: "cancelled", + }), + ); + }); + + // Its command ends before the idle that confirms the cancel, as v2 asks. + const child = updatesOf(client.notifications, "child"); + const ended = child.findIndex( + (update) => + update.sessionUpdate === "tool_call_update" && + update.toolCallId === "toolu_child_bash" && + update.status === "cancelled", + ); + expect(ended).toBeGreaterThanOrEqual(0); + expect(ended).toBeLessThan(child.length - 1); + }); + + it.each([ + ["failed", "failed", "error"], + ["completed", "cancelled", "end_turn"], + ])( + "gives a %s run's tool call that never ran its final status before the child's idle", + async (status, toolStatus, stopReason) => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(subagent("child", "toolu_launch").launch()); + claude.send(subagent("child", "toolu_launch").started()); + claude.send({ + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { type: "tool_use", id: "toolu_streamed", name: "Bash", input: {} }, + }, + parent_tool_use_id: "toolu_launch", + uuid: randomUUID(), + }); + claude.send(subagent("child", "toolu_launch").ended(status)); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "child").at(-1)).toMatchObject({ + state: "idle", + }), + ); + }); + + expect(updatesOf(client.notifications, "child").slice(-2)).toMatchObject([ + { sessionUpdate: "tool_call_update", toolCallId: "toolu_streamed", status: toolStatus }, + { sessionUpdate: "state_update", state: "idle", stopReason }, + ]); + }, + ); + + it("reports a child running again when Claude Code refuses to stop it", async () => { + let answer = (_: v2.RequestPermissionResponse) => {}; + const client = v2Client({ + answerPermission: () => new Promise((resolve) => (answer = resolve)), + }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + const child = subagent("child", "toolu_launch", { is_backgrounded: true }); + claude.send(cycleStart()); + claude.send(child.launch()); + claude.send(child.started()); + await vi.waitFor(() => expect(updatesOf(client.notifications, "child")).not.toEqual([])); + void askPermission(claude, "Bash", { command: "make" }, { agentID: "child" }).catch( + () => {}, + ); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "child").at(-1)).toMatchObject({ + state: "requires_action", + }), + ); + claude.stopTask.mockImplementationOnce( + () => new Promise((_, reject) => setTimeout(() => reject(new Error("refused")), 20)), + ); + await agent.notify(v2.methods.agent.session.cancel, { sessionId: "child" }); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "child").at(-1)).toMatchObject({ + state: "running", + }), + ); + answer({ outcome: { outcome: "cancelled" } }); + }); + }); + + it("lets the subagent that a child waits on ask again when Claude Code refuses to stop the child", async () => { + const client = v2Client({ answerPermission: () => new Promise(() => {}) }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + const child = subagent("child", "toolu_launch", { is_backgrounded: true }); + const inner = subagent("inner", "toolu_inner", { parent_task_id: "child" }); + claude.send(cycleStart()); + claude.send(child.launch()); + claude.send(child.started()); + claude.send(inner.launch("toolu_launch")); + claude.send(inner.started()); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "inner")).toContainEqual( + expect.objectContaining({ state: "running" }), + ), + ); + claude.stopTask.mockRejectedValueOnce(new Error("refused")); + await agent.notify(v2.methods.agent.session.cancel, { sessionId: "child" }); + await vi.waitFor(() => expect(claude.stopTask).toHaveBeenCalledWith("child")); + await new Promise((resolve) => setTimeout(resolve, 20)); + // The inner subagent's run goes on: its next request is its own. + void askPermission(claude, "Bash", { command: "make" }, { agentID: "inner" }).catch( + () => {}, + ); + await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(1)); + expect(client.permissionRequests[0].sessionId).toBe("inner"); + }); + }); + + it("reports the root running again when the interrupt fails, and lets its subagent ask again", async () => { + let answer = (_: v2.RequestPermissionResponse) => {}; + const client = v2Client({ + answerPermission: () => new Promise((resolve) => (answer = resolve)), + }); + const decision = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(subagent("child", "toolu_launch").launch()); + claude.send(subagent("child", "toolu_launch").started()); + void askPermission(claude, "Bash", { command: "make" }, { agentID: "child" }).catch( + () => {}, + ); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "requires_action"])); + claude.interrupt.mockImplementationOnce( + () => new Promise((_, reject) => setTimeout(() => reject(new Error("refused")), 20)), + ); + await agent.notify(v2.methods.agent.session.cancel, { sessionId: response.sessionId }); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "requires_action", "running"]), + ); + // The subagent's next request is its own again: a dismissal is a refusal. + const next = askPermission(claude, "Bash", { command: "ls" }, { agentID: "child" }); + await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(2)); + answer({ outcome: { outcome: "cancelled" } }); + return next; + }); + + expect(decision).toMatchObject({ behavior: "deny", decisionClassification: "user_reject" }); + }); + + it("stops a foreground subagent that Claude Code reports after the root's cancel, until the cancelled work's result", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + await agent.notify(v2.methods.agent.session.cancel, { sessionId: response.sessionId }); + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled()); + // The interrupt stopped a subagent whose launch the session reads only now. + const late = subagent("late", "toolu_late"); + claude.send(late.launchStart()); + claude.send(late.launch()); + claude.send(late.started()); + claude.send(late.says("Half an answer")); + // A background subagent goes on after the root's cancel. + const background = subagent("bg", "toolu_bg", { is_backgrounded: true }); + claude.send(background.launchStart()); + claude.send(background.launch()); + claude.send(background.started()); + await vi.waitFor(() => expect(updatesOf(client.notifications, "bg")).not.toEqual([])); + await expect( + askPermission(claude, "Bash", { command: "make" }, { agentID: "late" }), + ).rejects.toThrow("Tool use aborted"); + await askPermission(claude, "Bash", { command: "make" }, { agentID: "bg" }); + claude.send(late.ended("stopped")); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + // The cancel ended with its work: a later turn's subagent asks the client. + claude.send(cycleStart()); + const next = subagent("next", "toolu_next"); + claude.send(next.launch()); + claude.send(next.started()); + await askPermission(claude, "Bash", { command: "make" }, { agentID: "next" }); + }); + + expect(client.permissionRequests.map((request) => request.sessionId)).toEqual(["bg", "next"]); + // Announced, without the message of its stopped run. + expect(updatesOf(client.notifications, "late").map((update) => update.sessionUpdate)).toEqual( + ["state_update", "session_message", "state_update"], + ); + }); + + it("sends a subagent's request on its session when it is announced while the request is built", async () => { + const client = v2Client({ answerPermission: () => new Promise(() => {}) }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + const child = subagent("child", "toolu_launch"); + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + const preview = Promise.withResolvers(); + sdk.previewGate = preview.promise; + // The request comes before the subagent's task_started, and the + // subagent is announced while its patch preview is read. + void askPermission( + claude, + "Write", + { file_path: path.join(cwd, "notes.md"), content: "Notes" }, + { agentID: "child" }, + ).catch(() => {}); + await new Promise((resolve) => setTimeout(resolve, 10)); + claude.send(child.launch()); + claude.send(child.started()); + await vi.waitFor(() => expect(updatesOf(client.notifications, "child")).not.toEqual([])); + preview.resolve(); + await vi.waitFor(() => expect(client.permissionRequests).toHaveLength(1)); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "child").at(-1)).toMatchObject({ + state: "requires_action", + }), + ); + }); + + expect(client.permissionRequests[0].sessionId).toBe("child"); + }); + + it("announces a nested subagent that no launch call names only after its parent", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + const outer = subagent("outer", "toolu_outer"); + claude.send(cycleStart()); + // The outer agent's launch call has not been read yet. + claude.send(outer.started()); + claude.send({ + ...subagent("inner", "toolu_unused").started({ parent_task_id: "outer" }), + tool_use_id: undefined, + }); + claude.send(outer.launch()); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "outer")).toContainEqual( + expect.objectContaining({ sessionUpdate: "subagent_update", sessionId: "inner" }), + ), + ); + }); + + const announced = client.notifications.findIndex( + (n) => n.update.sessionUpdate === "subagent_update" && n.update.sessionId === "outer", + ); + const firstOfOuter = client.notifications.findIndex((n) => n.sessionId === "outer"); + expect(announced).toBeGreaterThanOrEqual(0); + expect(announced).toBeLessThan(firstOfOuter); + }); + + it("keeps the root running while another foreground subagent makes progress", async () => { + const client = v2Client({ answerPermission: () => new Promise(() => {}) }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + for (const [agentId, launch] of [ + ["a", "toolu_a"], + ["b", "toolu_b"], + ]) { + claude.send(subagent(agentId, launch).launch()); + claude.send(subagent(agentId, launch).started()); + } + await vi.waitFor(() => expect(updatesOf(client.notifications, "b")).not.toEqual([])); + void askPermission(claude, "Bash", { command: "make" }, { agentID: "a" }).catch(() => {}); + await vi.waitFor(() => + expect(updatesOf(client.notifications, "a").at(-1)).toMatchObject({ + state: "requires_action", + }), + ); + // B still works. + expect(client.states()).toEqual(["running"]); + claude.send(subagent("b", "toolu_b").ended()); + // With B done, the work waits on A alone. + await vi.waitFor(() => expect(client.states()).toEqual(["running", "requires_action"])); + }); + }); + + it("sends a background child's listed terminal before the child's state on resume", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const child = subagent("child", "toolu_launch", { is_backgrounded: true }); + claude.send(cycleStart()); + claude.send(child.launch()); + claude.send(child.started()); + claude.send({ + ...assistant("msg_child_bg", [ + { + type: "tool_use", + id: "toolu_child_bg", + name: "Bash", + input: { command: "make test", run_in_background: true }, + }, + ]), + parent_tool_use_id: "toolu_launch", + }); + claude.send({ + type: "system", + subtype: "task_started", + task_id: "bg1", + task_type: "local_bash", + description: "make test", + tool_use_id: "toolu_child_bg", + parent_task_id: "child", + uuid: randomUUID(), + }); + claude.send({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "child", task_type: "local_agent", description: "child" }, + { task_id: "bg1", task_type: "local_bash", description: "make test" }, + ], + uuid: randomUUID(), + }); + claude.send({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_child_bg", + content: "Command running in background with ID: bg1.", + }, + ], + }, + tool_use_result: { stdout: "", stderr: "", interrupted: false, backgroundTaskId: "bg1" }, + parent_tool_use_id: "toolu_launch", + uuid: randomUUID(), + }); + claude.send(result()); + await vi.waitFor(() => expect(client.states().at(-1)).toBe("idle")); + client.notifications.length = 0; + await agent.request(v2.methods.agent.session.resume, { sessionId, cwd }); + }); + + const snapshot = client.notifications.map( + ({ sessionId, update }) => + `${sessionId} ${update.sessionUpdate}${"state" in update ? ` ${String(update.state)}` : ""}`, + ); + // The child's announcement, its terminal, then its state that lists the terminal. + const announced = snapshot.findIndex((entry) => entry.includes("subagent_update")); + const terminal = snapshot.indexOf("child terminal_update"); + expect(announced).toBeGreaterThanOrEqual(0); + expect(terminal).toBeGreaterThan(announced); + expect(terminal).toBeLessThan(snapshot.indexOf("child state_update running")); + }); + + it("keeps a live background subagent running after a resume of the active session replays it", async () => { + sdk.subagents = new Map([ + [ + "toolu_bg_agent", + { + agentId: "a5966d", + background: true, + stoppedByUser: false, + agentType: "general-purpose", + }, + ], + ]); + const client = v2Client(); + await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + const sessionId = response.sessionId; + const child = subagent("a5966d", "toolu_bg_agent", { is_backgrounded: true }); + claude.send(cycleStart()); + claude.send(child.launch()); + claude.send({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "a5966d", task_type: "local_agent", description: "sleeper" }], + uuid: randomUUID(), + }); + claude.send(child.started()); + claude.send(result({ result: "launched" })); + await vi.waitFor(() => expect(client.states().at(-1)).toBe("idle")); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + { + type: "assistant", + uuid: "a1", + session_id: sessionId, + parent_tool_use_id: null, + parent_agent_id: null, + message: child.launch().message, + }, + ]); + await agent.request(v2.methods.agent.session.resume, { + sessionId, + cwd, + replayFrom: { type: "start" }, + }); + // After the response. + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + + expect(updatesOf(client.notifications, "a5966d").at(-1)).toMatchObject({ + sessionUpdate: "state_update", + state: "running", + }); + }); + + it.each([ + ["session/prompt", v2.methods.agent.session.prompt, { prompt: PROMPT }], + ["session/close", v2.methods.agent.session.close, {}], + ])("rejects %s on a subagent's session", async (_, method, params) => { + const client = v2Client(); + const error = await client.connect(async (agent) => { + const { claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(subagent("child", "toolu_launch").launch()); + claude.send(subagent("child", "toolu_launch").started()); + await vi.waitFor(() => + expect(client.notifications.some((n) => n.sessionId === "child")).toBe(true), + ); + return agent + .request( + method as typeof v2.methods.agent.session.close, + { + sessionId: "child", + ...params, + } as v2.CloseSessionRequest, + ) + .catch((error: unknown) => error); + }); + + expect(error).toMatchObject({ code: -32602 }); + }); + + it("keeps a background command's terminal running and listed until its task ends", async () => { + const client = v2Client(); + const sessionId = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send( + assistant("msg_bash", [ + { + type: "tool_use", + id: "toolu_bg", + name: "Bash", + input: { command: "make test", run_in_background: true }, + }, + ]), + ); + claude.send({ + type: "system", + subtype: "task_started", + task_id: "bg1", + task_type: "local_bash", + description: "make test", + tool_use_id: "toolu_bg", + is_backgrounded: true, + uuid: randomUUID(), + }); + claude.send({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "bg1", task_type: "local_bash", description: "make test" }], + uuid: randomUUID(), + }); + claude.send({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_bg", + content: "Command running in background with ID: bg1.", + }, + ], + }, + tool_use_result: { stdout: "", stderr: "", interrupted: false, backgroundTaskId: "bg1" }, + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "running", "idle"])); + claude.send({ + type: "system", + subtype: "task_notification", + task_id: "bg1", + tool_use_id: "toolu_bg", + status: "completed", + output_file: "", + summary: "", + uuid: randomUUID(), + }); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "running", "idle", "idle"]), + ); + return response.sessionId; + }); + + const trace = updatesOf(client.notifications, sessionId).flatMap((update) => + update.sessionUpdate === "terminal_update" + ? [update.exitStatus ? "exit" : "terminal"] + : update.sessionUpdate === "state_update" + ? [`${update.state} ${JSON.stringify(update.background)}`] + : [], + ); + expect(trace.filter((entry) => entry !== "terminal")).toEqual([ + "running []", + 'running [{"terminalId":"toolu_bg","type":"terminal"}]', + 'idle [{"terminalId":"toolu_bg","type":"terminal"}]', + "exit", + "idle []", + ]); + }); + + it("keeps a background command's terminal running when a resume replays its result", async () => { + const toolUse = assistant("msg_bash", [ + { + type: "tool_use", + id: "toolu_bg", + name: "Bash", + input: { command: "make test", run_in_background: true }, + }, + ]); + const toolResult = { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_bg", + content: "Command running in background with ID: bg1.", + }, + ], + }; + const client = v2Client(); + let resumedAt = 0; + const sessionId = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent); + claude.send(cycleStart()); + claude.send(toolUse); + claude.send({ + type: "system", + subtype: "task_started", + task_id: "bg1", + task_type: "local_bash", + description: "make test", + tool_use_id: "toolu_bg", + is_backgrounded: true, + uuid: randomUUID(), + }); + claude.send({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "bg1", task_type: "local_bash", description: "make test" }], + uuid: randomUUID(), + }); + claude.send({ + type: "user", + message: toolResult, + tool_use_result: { stdout: "", stderr: "", interrupted: false, backgroundTaskId: "bg1" }, + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "running", "idle"])); + const entry = (type: string, uuid: string, message: unknown) => ({ + type, + uuid, + session_id: response.sessionId, + parent_tool_use_id: null, + parent_agent_id: null, + message, + }); + vi.mocked(getSessionMessages).mockResolvedValueOnce([ + entry("assistant", "a1", toolUse.message), + entry("user", "u1", toolResult), + ] as never); + resumedAt = client.notifications.length; + await agent.request(v2.methods.agent.session.resume, { + sessionId: response.sessionId, + cwd, + replayFrom: { type: "start" }, + }); + claude.send({ + type: "system", + subtype: "task_notification", + task_id: "bg1", + tool_use_id: "toolu_bg", + status: "completed", + output_file: "", + summary: "", + uuid: randomUUID(), + }); + await vi.waitFor(() => expect(client.states()).toHaveLength(5)); + return response.sessionId; + }); + + const trace = updatesOf(client.notifications.slice(resumedAt), sessionId).flatMap((update) => + update.sessionUpdate === "terminal_update" + ? [update.exitStatus ? "exit" : update.capabilities ? "cancellable" : "terminal"] + : update.sessionUpdate === "state_update" + ? [`${update.state} ${JSON.stringify(update.background)}`] + : [], + ); + // The snapshot's current state of the listed terminal, then its exit with its task. + expect(trace.filter((entry) => entry !== "terminal")).toEqual([ + "cancellable", + 'idle [{"terminalId":"toolu_bg","type":"terminal"}]', + "exit", + "idle []", + ]); + }); + }); + + describe("elicitations", () => { + it.each([ + ["no elicitation", {}, { ask: false, mcp: false, dialog: false }], + ["forms", { form: {} }, { ask: true, mcp: true, dialog: true }], + ["URLs only", { url: {} }, { ask: false, mcp: true, dialog: false }], + ])( + "asks Claude Code for the requests that a client with %s renders", + async (_, elicitation, expected) => { + const client = v2Client(); + const { claude } = await client.connect((agent) => openSession(agent, { elicitation })); + + const { options } = claude; + expect(!(options.disallowedTools ?? []).includes("AskUserQuestion")).toBe(expected.ask); + expect(options.onElicitation !== undefined).toBe(expected.mcp); + expect(options.onUserDialog !== undefined).toBe(expected.dialog); + expect(options.supportedDialogKinds).toEqual( + expected.dialog ? ["refusal_fallback_prompt"] : undefined, + ); + }, + ); + + it.each([ + [ + "lists", + ["ask_user_question_unanswered_v1"], + { "Which package?": { unanswered: "skipped" } }, + ], + ["does not list", [], undefined], + ])( + "marks a skipped question as unanswered only when the turn's system/init %s the capability", + async (_, capabilities, annotations) => { + const client = v2Client({ + answerElicitation: () => ({ action: "accept", content: {} }), + }); + const decision = await client.connect(async (agent) => { + const { claude } = await openSession(agent, { elicitation: { form: {} } }); + claude.send({ type: "system", subtype: "init", uuid: randomUUID(), capabilities }); + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + return claude.options.canUseTool!( + "AskUserQuestion", + { + questions: [ + { + question: "Which package?", + header: "Package", + options: [{ label: "core" }, { label: "cli" }], + multiSelect: false, + }, + ], + }, + { + signal: new AbortController().signal, + toolUseID: "toolu_ask", + requestId: "r1", + suggestions: [], + }, + ); + }); + + expect(client.elicitations[0]).toMatchObject({ mode: "form", toolCallId: "toolu_ask" }); + expect(decision).toMatchObject({ behavior: "allow", updatedInput: { answers: {} } }); + expect( + (decision as { updatedInput?: { annotations?: unknown } }).updatedInput?.annotations, + ).toEqual(annotations); + }, + ); + + it("completes an MCP server's URL elicitation when Claude Code reports it complete", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent, { elicitation: { url: {} } }); + claude.send({ + type: "system", + subtype: "elicitation_complete", + mcp_server_name: "linear", + elicitation_id: "e1", + uuid: randomUUID(), + session_id: "session", + }); + await vi.waitFor(() => expect(client.completedElicitations).toEqual(["e1"])); + }); + }); + + it("keeps the work requires_action after the user accepts an MCP server's URL until Claude Code reports it complete", async () => { + const client = v2Client({ answerElicitation: () => ({ action: "accept" }) }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent, { elicitation: { url: {} } }); + claude.send(cycleStart()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + const answer = await claude.options.onElicitation!( + { + serverName: "linear", + message: "Sign in to Linear", + mode: "url", + url: "https://linear.example.com/authorize", + elicitationId: "e1", + }, + { signal: new AbortController().signal } as Parameters< + NonNullable + >[1], + ); + // Claude Code gets the answer at once; the user still works in the browser. + expect(answer).toMatchObject({ action: "accept" }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(client.states()).toEqual(["running", "requires_action"]); + claude.send({ + type: "system", + subtype: "elicitation_complete", + mcp_server_name: "linear", + elicitation_id: "e1", + uuid: randomUUID(), + session_id: "session", + }); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "requires_action", "running"]), + ); + }); + }); + + it("starts OAuth for the MCP servers of a forked session", async () => { + sdk.mcpServers = [{ name: "linear", status: "needs-auth" }]; + const client = v2Client(); + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: { elicitation: { url: {} } }, + }); + await agent.request(v2.methods.agent.session.fork, { + sessionId: randomUUID(), + cwd, + mcpServers: [{ type: "http", name: "linear", url: "https://mcp.example.com/mcp" }], + }); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(1)); + }); + + expect(client.elicitations[0]).toMatchObject({ mode: "url" }); + }); + + it("cancels an open MCP OAuth elicitation before it answers session/close", async () => { + // The server stays `needs-auth`: the user has not signed in yet. + sdk.mcpServers = [{ name: "linear", status: "needs-auth" }]; + let cancelled = false; + const client = v2Client({ + answerElicitation: (_, signal) => + new Promise((resolve) => + signal.addEventListener("abort", () => { + cancelled = true; + resolve({ action: "cancel" }); + }), + ), + }); + const cancelledAtClose = await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: { elicitation: { url: {} } }, + }); + const { sessionId } = await agent.request(v2.methods.agent.session.new, { + cwd, + mcpServers: [{ type: "http", name: "linear", url: "https://mcp.example.com/mcp" }], + }); + await vi.waitFor(() => expect(client.elicitations).toHaveLength(1)); + await agent.request(v2.methods.agent.session.close, { sessionId }); + return cancelled; + }); + + expect(client.elicitations[0]).toMatchObject({ + mode: "url", + url: "https://mcp.example.com/authorize", + }); + expect(cancelledAtClose).toBe(true); + }); + }); + + describe("a JetBrains AIR client", () => { + /** The capabilities of an AIR client that declares `capabilities`. */ + const air = (...capabilities: string[]) => + ({ _meta: { jetbrains: { air: { version: 1, capabilities } } } }) as v2.ClientCapabilities; + const airMeta = (payload: Record) => ({ + jetbrains: { air: { version: 1, ...payload } }, + }); + + it.each([ + [ + "an AIR client the AIR capabilities that v2 does not cover", + air("loadReplayEnd"), + airMeta({ capabilities: ["agentFileChangeReport", "recommendedValue"] }), + ], + ["any other client no AIR key", {}, undefined], + ])("advertises to %s", async (_, capabilities, expected) => { + const client = v2Client(); + const response = await client.connect((agent) => + agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities, + }), + ); + expect(response._meta).toEqual(expected); + }); + + it.each([ + ["declares recommendedValue", air("recommendedValue"), true], + ["is AIR without recommendedValue", air(), false], + ["is not AIR", {}, false], + ])( + "names the recommended model and effort only for a client that %s, and applies the effort", + async (_, capabilities, recommended) => { + const levels = ["low", "medium", "high"]; + sdk.models = [ + { + value: "default", + displayName: "Default", + description: "Recommended", + resolvedModel: "claude-opus-5", + supportsEffort: true, + supportedEffortLevels: levels, + }, + { + value: "opus", + displayName: "Opus", + description: "Most capable", + resolvedModel: "claude-opus-5", + supportsEffort: true, + supportedEffortLevels: levels, + }, + ]; + const client = v2Client(); + const { response, claude } = await client.connect((agent) => + openSession(agent, capabilities), + ); + + const option = (configId: string) => + response.configOptions?.find((entry) => entry.configId === configId) as { + currentValue: unknown; + options: Array<{ value: string }>; + _meta?: unknown; + }; + const values = (configId: string) => option(configId).options.map((entry) => entry.value); + if (recommended) { + expect(values("model")).toEqual(["opus"]); + expect(option("model")).toMatchObject({ + currentValue: "opus", + _meta: airMeta({ recommendedValue: "opus" }), + }); + expect(values("effort")).toEqual(levels); + expect(option("effort")).toMatchObject({ + currentValue: "medium", + _meta: airMeta({ recommendedValue: "medium" }), + }); + // The shown effort is the one Claude uses. + expect(claude.applyFlagSettings).toHaveBeenCalledWith({ effortLevel: "medium" }); + } else { + expect(values("model")).toContain("default"); + expect(option("model")._meta).toBeUndefined(); + expect(values("effort")).toEqual(["default", ...levels]); + expect(option("effort")._meta).toBeUndefined(); + expect(claude.applyFlagSettings).not.toHaveBeenCalled(); + } + }, + ); + + it.each([ + ["an AIR client the AIR presentation", air(), true], + ["any other client none", {}, false], + ])("gives a permission request of %s", async (_, capabilities, airClient) => { + const client = v2Client(); + await client.connect(async (agent) => { + const { claude } = await openSession(agent, capabilities); + // A subagent's request, on its session, of a tool that the stream did not show yet. + claude.send(cycleStart()); + claude.send(subagent("child", "toolu_launch").launch()); + claude.send(subagent("child", "toolu_launch").started()); + await vi.waitFor(() => expect(client.states()).toEqual(["running"])); + await askPermission( + claude, + "mcp__docs__search", + { query: "x" }, + { + agentID: "child", + mcpServer: { name: "docs", source: "sdk" }, + decisionReason: "Asks every time", + }, + ); + }); + + const request = client.permissionRequests[0]; + expect(request.sessionId).toBe("child"); + const subject = request.subject as { toolCall: { _meta?: unknown } }; + const mcpServer = { name: "docs", source: "sdk" }; + if (airClient) { + expect(request._meta).toEqual( + airMeta({ + permission: { + version: 1, + title: request.title, + description: "Reason: Asks every time", + }, + }), + ); + // AIR's rule (`permissionToolCallMeta`): no Agent call of the subagent. + expect(subject.toolCall._meta).toEqual({ claudeCode: { mcpServer } }); + } else { + expect(request._meta).toBeUndefined(); + expect(subject.toolCall._meta).toEqual({ + claudeCode: { parentToolUseId: "toolu_launch", mcpServer }, + }); + } + }); + + it.each([ + ["an AIR client", air(), true], + ["any other client", {}, false], + ])( + "marks AskUserQuestion's custom answer field for %s only", + async (_, capabilities, marked) => { + const client = v2Client({ + answerElicitation: () => ({ action: "accept", content: {} }), + }); + await client.connect(async (agent) => { + const { claude } = await openSession(agent, { + ...capabilities, + elicitation: { form: {} }, + }); + await claude.options.canUseTool!( + "AskUserQuestion", + { + questions: [ + { + question: "Which package?", + header: "Package", + options: [{ label: "core" }, { label: "cli" }], + multiSelect: false, + }, + ], + }, + { + signal: new AbortController().signal, + toolUseID: "toolu_ask", + requestId: "r1", + suggestions: [], + }, + ); + }); + + const properties = Object.values( + (client.elicitations[0] as { requestedSchema: { properties: Record } }) + .requestedSchema.properties, + ) as Array<{ _meta?: unknown }>; + const markers = properties.flatMap((property) => (property._meta ? [property._meta] : [])); + expect(markers).toEqual( + marked + ? [ + { + _askUserQuestionCustomAnswer: { questionId: "question_0", isCustomAnswer: true }, + ...airMeta({ + customAnswer: { questionId: "question_0", isCustomAnswer: true }, + }), + }, + ] + : [], + ); + }, + ); + + it.each([ + ["an AIR client", air(), true], + ["any other client", {}, false], + ])( + "sends %s the predicted follow-up prompt, cleared at the next prompt and at a reset", + async (_, capabilities, predicted) => { + const client = v2Client(); + const suggestion = (text: string) => ({ + type: "prompt_suggestion", + suggestion: text, + uuid: randomUUID(), + session_id: "session", + }); + const { claude, sessionId } = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent, capabilities); + const prompt = async (index: number) => { + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + }); + claude.send(echo(await claude.prompt(index))); + await answer; + }; + await prompt(0); + claude.send(result()); + claude.send(suggestion("Run the tests")); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + await prompt(1); + // A prediction that the next prompt outran: no result opened its window. + claude.send(suggestion("Stale")); + claude.send(result()); + claude.send(suggestion("Commit")); + claude.send({ + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + timestamp: new Date().toISOString(), + session_id: "session", + }); + claude.send(sessionState("idle")); + await vi.waitFor(() => + expect(client.states()).toEqual(["running", "idle", "running", "idle"]), + ); + return { claude, sessionId: response.sessionId }; + }); + + const sent = updatesOf(client.notifications, sessionId).flatMap((update) => { + const meta = update._meta as { promptSuggestions?: { suggestions: unknown } } | null; + return update.sessionUpdate === "session_info_update" && meta?.promptSuggestions + ? [meta.promptSuggestions.suggestions] + : []; + }); + expect(sent).toEqual( + predicted ? [[{ text: "Run the tests" }], [], [{ text: "Commit" }], []] : [], + ); + expect(claude.options.promptSuggestions).toBe(predicted ? true : undefined); + }, + ); + + it("leaves the predicted prompt off when the client's options turn it off", async () => { + const client = v2Client(); + await client.connect(async (agent) => { + await agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "v2-test-client", version: "1.0.0" }, + capabilities: air(), + }); + await agent.request(v2.methods.agent.session.new, { + cwd, + _meta: { claudeCode: { options: { promptSuggestions: false } } }, + }); + }); + expect(sdk.processes[0].options.promptSuggestions).toBe(false); + }); + + describe("the agent file-change report", () => { + const requestMeta = (requestId: string) => ({ + jetbrains: { air: { agentFileChangeReportRequest: { version: 1, requestId } } }, + }); + const reports = (updates: v2.SessionUpdate[]) => + updates.flatMap((update) => { + const report = (update._meta as any)?.jetbrains?.air?.agentFileChangeReport; + return update.sessionUpdate === "session_info_update" && report ? [report] : []; + }); + + it("lists the files of the prompt's checkpoint at its turn's end, before the idle", async () => { + const client = v2Client(); + const changed = path.join(await realpath(cwd), "src", "App.ts"); + const { claude, sessionId, uuid } = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent, air("agentFileChangeReport")); + claude.rewindFiles.mockResolvedValue({ canRewind: true, filesChanged: [changed] }); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + _meta: requestMeta("r-1"), + }); + const prompt = await claude.prompt(0); + claude.send(echo(prompt)); + await answer; + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + return { claude, sessionId: response.sessionId, uuid: prompt.uuid }; + }); + + expect(claude.options.enableFileCheckpointing).toBe(true); + expect(claude.rewindFiles).toHaveBeenCalledWith(uuid, { dryRun: true }); + const updates = updatesOf(client.notifications, sessionId); + expect(reports(updates)).toEqual([ + { + version: 1, + requestId: "r-1", + status: "reported", + paths: [changed], + declaredComplete: false, + truncated: false, + }, + ]); + const reportAt = updates.findIndex((update) => reports([update]).length > 0); + const idleAt = updates.findIndex( + (update) => + update.sessionUpdate === "state_update" && + (update as { state: string }).state === "idle", + ); + expect(reportAt).toBeLessThan(idleAt); + }); + + it("reads the report of a turn whose plan ran in a fresh context from the plan's prompt", async () => { + const client = v2Client({ + answerPermission: () => ({ + outcome: { outcome: "selected", optionId: "exit-plan-clear-auto" }, + }), + }); + const changed = path.join(await realpath(cwd), "src", "App.ts"); + const { claude, sessionId, plan } = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent, air("agentFileChangeReport")); + claude.rewindFiles.mockResolvedValue({ canRewind: true, filesChanged: [changed] }); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + _meta: requestMeta("r-1"), + }); + claude.send(echo(await claude.prompt(0))); + await answer; + claude.send( + assistant("msg_plan", [ + { + type: "tool_use", + id: "toolu_plan", + name: "ExitPlanMode", + input: { plan: "Do it" }, + }, + ]), + ); + await askPermission( + claude, + "ExitPlanMode", + { plan: "Do it" }, + { toolUseID: "toolu_plan" }, + ); + claude.send({ + type: "user", + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "toolu_plan", + content: "User accepted the plan and requested a fresh context", + is_error: true, + }, + ], + }, + tool_result_meta: [{ id: "toolu_plan", non_execution_kind: "user-rejected" }], + parent_tool_use_id: null, + uuid: randomUUID(), + }); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + stop_reason: null, + errors: [ + "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=tool_use", + ], + }), + ); + claude.send(sessionState("idle")); + const clear = await claude.prompt(1); + claude.send(echo(clear)); + claude.send({ + type: "conversation_reset", + new_conversation_id: randomUUID(), + uuid: randomUUID(), + trigger: "clear", + timestamp: new Date().toISOString(), + session_id: "session", + }); + claude.send(result({ num_turns: 0, stop_reason: null })); + // `/clear` reset Claude Code's file history: only the plan's prompt has a checkpoint. + const plan = await claude.prompt(2); + claude.send(echo(plan)); + claude.send(result()); + claude.send(sessionState("idle")); + await vi.waitFor(() => expect(client.states().at(-1)).toBe("idle")); + return { claude, sessionId: response.sessionId, plan }; + }); + + expect(claude.rewindFiles).toHaveBeenCalledExactlyOnceWith(plan.uuid, { dryRun: true }); + expect(reports(updatesOf(client.notifications, sessionId))).toEqual([ + { + version: 1, + requestId: "r-1", + status: "reported", + paths: [changed], + declaredComplete: false, + truncated: false, + }, + ]); + }); + + it.each([ + ["a cancel answered with -32800 before its insertion", false, "cancel", "cancelled"], + ["a cancel stopped after its insertion", true, "cancel", "cancelled"], + ["ended with an error result", true, "error", "providerError"], + ["ended without a result, as the query ended", true, "end", "providerError"], + [ + "failed otherwise before its insertion, as the query ended", + false, + "end", + "providerError", + ], + ] as const)( + "reports a prompt that %s unavailable, without reading a checkpoint", + async (_, insert, end, reason) => { + const client = v2Client(); + const { claude, sessionId } = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent, air("agentFileChangeReport")); + const sessionId = response.sessionId; + const answer = agent + .request(v2.methods.agent.session.prompt, { + sessionId, + prompt: PROMPT, + _meta: requestMeta("r-1"), + }) + .catch((error: unknown) => error); + const prompt = await claude.prompt(0); + if (insert) { + claude.send(echo(prompt)); + expect(await answer).toEqual({ messageId: prompt.uuid }); + } + if (end === "cancel") { + await agent.notify(v2.methods.agent.session.cancel, { sessionId }); + if (insert) { + await vi.waitFor(() => expect(claude.interrupt).toHaveBeenCalled()); + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + terminal_reason: "aborted_streaming", + }), + ); + claude.send(sessionState("idle")); + } + } else if (end === "error") { + claude.send( + result({ + subtype: "error_during_execution", + is_error: true, + errors: ["Overloaded"], + }), + ); + claude.send(sessionState("idle")); + } else { + claude.end(new Error("Claude Code exited")); + } + if (!insert) { + expect(await answer).toMatchObject({ code: end === "cancel" ? -32800 : -32603 }); + } + await vi.waitFor(() => + expect(reports(updatesOf(client.notifications, sessionId))).toHaveLength(1), + ); + return { claude, sessionId }; + }); + + expect(claude.rewindFiles).not.toHaveBeenCalled(); + expect(reports(updatesOf(client.notifications, sessionId))).toEqual([ + { version: 1, requestId: "r-1", status: "unavailable", reason }, + ]); + }, + ); + + it.each([ + ["an AIR client without the capability", air()], + ["any other client", {}], + ])("sends %s no report and keeps checkpoints off", async (_, capabilities) => { + const client = v2Client(); + const claude = await client.connect(async (agent) => { + const { response, claude } = await openSession(agent, capabilities); + const answer = agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: PROMPT, + _meta: requestMeta("r-1"), + }); + claude.send(echo(await claude.prompt(0))); + await answer; + claude.send(result()); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + return claude; + }); + + expect(claude.options.enableFileCheckpointing).toBeUndefined(); + expect(claude.rewindFiles).not.toHaveBeenCalled(); + expect(reports(client.sessionUpdates)).toEqual([]); + }); + + it("sends no report for the adapter's own /remote-control, as v1", async () => { + sdk.enableRemoteControl = vi.fn(async () => ({ + session_url: "https://claude.ai/code/session_123", + })); + const client = v2Client(); + await client.connect(async (agent) => { + const { response } = await openSession(agent, air("agentFileChangeReport")); + await agent.request(v2.methods.agent.session.prompt, { + sessionId: response.sessionId, + prompt: [{ type: "text", text: "/remote-control" }], + _meta: requestMeta("r-1"), + }); + await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"])); + }); + + expect(sdk.enableRemoteControl).toHaveBeenCalled(); + expect(reports(client.sessionUpdates)).toEqual([]); + }); + }); + }); + + const AIR_CAPABILITIES = { _meta: { jetbrains: { air: { version: 1, capabilities: [] } } } }; + it.each([ + { client: "a plain", capabilities: {} }, + { client: "an AIR", capabilities: AIR_CAPABILITIES }, + ] as const)("serves $client v2 client with the v2 core", async ({ capabilities }) => { + const v1Agents: { dispose(): Promise }[] = []; + const v2Agents: AgentHandle[] = []; + const toAgent = new TransformStream(); + const toClient = new TransformStream(); + acpProtocolRouter(undefined, (agent) => v1Agents.push(agent), "v2", { + onV2Agent: (agent) => v2Agents.push(agent), + }).connect({ readable: toAgent.readable, writable: toClient.writable }); + try { + await v2 + .client({ name: "test-client" }) + .connectWith({ readable: toClient.readable, writable: toAgent.writable }, (agent) => + agent.request(v2.methods.agent.initialize, { + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: "test-client", version: "1" }, + capabilities, + }), + ); + expect({ v2: v2Agents.length, v1: v1Agents.length }).toEqual({ v2: 1, v1: 0 }); + } finally { + await Promise.all([...v1Agents, ...v2Agents].map((agent) => agent.dispose())); + } + }); +}); + +describe("StoppedWork", () => { + it("forgets a tool use that canUseTool waited for in vain", async () => { + const stopped = new StoppedWork(); + stopped.cancelled(["prompt"], false); + const abort = new AbortController(); + const dropped = stopped.dropsToolUse("toolu_never_streamed", undefined, abort.signal); + abort.abort(); + expect(await dropped).toBe(false); + expect( + (stopped as unknown as { toolUseWaiters: Map }).toolUseWaiters.size, + ).toBe(0); + }); +}); diff --git a/src/tool-calls/client-capabilities.ts b/src/tool-calls/client-capabilities.ts index 9199a04b..f684584c 100644 --- a/src/tool-calls/client-capabilities.ts +++ b/src/tool-calls/client-capabilities.ts @@ -27,10 +27,10 @@ export class ClientCapabilities { readonly air: AirCapabilities = NO_AIR, /** * The client speaks ACP v2. Its diffs carry structured `changes` and an - * optional git patch, which a v1 diff cannot express. Only the v2 surface - * sets this, never the capabilities that a client sends. A v2 client also - * takes the terminal extension (`terminalOutput`), which the v2 surface - * sends as display terminals. + * optional git patch, which a v1 diff cannot express. Only the v2 core + * (`src/v2/`) sets this, never the capabilities that a client + * sends. A v2 client also takes the terminal extension (`terminalOutput`), + * which the v2 core sends as display terminals. */ readonly v2: boolean = false, ) {} diff --git a/src/tool-calls/renderer.ts b/src/tool-calls/renderer.ts index 6001dfe7..d95c538c 100644 --- a/src/tool-calls/renderer.ts +++ b/src/tool-calls/renderer.ts @@ -29,13 +29,12 @@ export type ToolCallUpdate = SessionNotification["update"]; const PLAN_FILE_CLEAR_SUFFIX = ":plan-file-clear"; /** - * ACP v2's `cancelled` and `paused` tool call statuses. v1's statuses have no - * such values, and the agent builds its reports in v1 types, so they travel as - * v1 statuses. Only a v2 client gets them, and the v2 surface sends them as - * they are (`src/v2/tool-call.ts`). + * ACP v2's `cancelled` tool call status. v1's statuses have no such value, and + * the shared mapping builds its reports in v1 types, so it travels as a v1 + * status. Only a v2 client gets it, and the v2 core sends it as it is + * (`src/v2/tool-call.ts`). */ export const V2_CANCELLED = "cancelled" as unknown as ToolCallStatus; -export const V2_PAUSED = "paused" as unknown as ToolCallStatus; /** Whether `status` ends a tool call: `completed`, `failed`, or, on v2, `cancelled`. */ export function isFinalToolCallStatus(status: ToolCallStatus | null | undefined): boolean { diff --git a/src/turn-events.ts b/src/turn-events.ts index ffed0d7a..b2db3a4f 100644 --- a/src/turn-events.ts +++ b/src/turn-events.ts @@ -1,11 +1,7 @@ /** - * A prompt turn and the foreground work of a session, as the adapter - * understands them, independent of the ACP version. - * - * The agent reports how each prompt is answered through {@link TurnEvents}: - * ACP v1 answers `session/prompt` when the turn ends or fails, and ACP v2 when - * the prompt is inserted. ACP v2 also reports the session's work as state, - * which the agent reports through {@link ForegroundEvents}. + * A prompt turn of `ClaudeAcpAgent`. The agent reports how each prompt is + * answered through {@link TurnEvents}: its `session/prompt` answers when the + * turn ends or fails. * * The agent calls these synchronously while it updates its own state, so a * handler must not throw, nor call back into the agent before it returns. @@ -13,8 +9,9 @@ export interface TurnEvents { /** * Claude Code took the prompt in as the user message `messageId` of the - * conversation. Reported at most once per turn, and never after - * {@link ended} or {@link failed}. + * conversation, and the turn is the session's foreground work from then on. + * Reported at most once per turn, and never after {@link ended} or + * {@link failed}. * * When Claude Code reports command lifecycles (`msg_lifecycle_v1`), that is * when the prompt starts a turn, before the turn's output. Otherwise, and @@ -37,44 +34,6 @@ export interface TurnEvents { failed(error: unknown): void; } -/** - * The foreground work of a session: what a client would wait for before it - * sends the next prompt. One piece of work runs at a time, and the next starts - * with {@link running}. The work is either: - * - * - A prompt's turn, from {@link TurnEvents.inserted} until its result, or - * until it fails or is cancelled. A turn that v1 holds open for the - * background subagents it spawned (`Turn.deferredSettle`) ends its work at - * its result all the same: the hold only delays v1's answer. - * - A model cycle that Claude Code starts on its own while no work runs: for - * example, its follow-up after a background command or subagent finished, - * or its answer to a message from another session. It runs until its result. - * - * A prompt that Claude Code takes in while a cycle runs takes the cycle over: - * the cycle reports nothing more, and the turn reports from there. - */ -export interface ForegroundEvents { - /** - * The work started, or works again after it waited for the user. Not after - * a wait that a cancel ended: Claude Code's interrupt withdrew the last - * request, so the work is being stopped, and {@link ended} follows at once. - */ - running(): void; - /** - * The work waits for the user: a permission request or a question is open in - * the session. Requests do not say which work they are for, so this includes - * one opened before the work started. - */ - awaitingUser(): void; - /** The work ended. */ - ended(outcome: TurnOutcome): void; - /** - * The work failed with `error`, the JSON-RPC error that v1 answers the - * prompt with. - */ - failed(error: unknown): void; -} - /** Why a turn ended. Every ACP version has these reasons. */ export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled"; diff --git a/src/v2/agent.ts b/src/v2/agent.ts index 91ab61e8..f30b8bc3 100644 --- a/src/v2/agent.ts +++ b/src/v2/agent.ts @@ -1,458 +1,219 @@ /** - * The experimental draft ACP v2 surface of the adapter. + * The ACP v2 core: serves ACP v2 straight from Claude Code, without + * `ClaudeAcpAgent`. * - * `ClaudeAcpAgent` speaks ACP v1 types. This surface translates each v2 - * request into the v1 request that the agent serves, and each v1 message that - * the agent sends into its v2 form, so v1 and v2 share one implementation. - * Where the versions differ, it maps the agent's own types instead: a prompt - * is served through the agent's turn events (see `prompt.ts`). + * The adapter's ACP v2 implementation. Unlike the v1 core, it holds no turn + * open and matches no echoes: each session mirrors its Claude Code query (see + * `session.ts`). It shares the protocol-neutral pieces with v1: the SDK + * message mapping, tool call rendering, permission options, and the mode + * catalog. It serves a JetBrains AIR client the AIR extensions that v2 does + * not cover (see `air.ts`). */ -import type { - CompleteElicitationNotification, - CreateElicitationRequest, - CreateElicitationResponse, - McpServer, - RequestPermissionResponse, +import { randomUUID } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { + RequestError, + type CompleteElicitationNotification, + type CreateElicitationRequest, + type CreateElicitationResponse, } from "@agentclientprotocol/sdk/unstable"; import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; -import { ClaudeAcpAgent, type AcpClient, type Logger } from "../acp-agent.js"; -import type { AcpSessionNotification } from "../acp-subagents.js"; -import type { AcpPermissionRequest } from "../permissions/presentation.js"; -import { stateUpdate, V2Background } from "./background.js"; -import { v1InitializeRequest, v2InitializeResponse } from "./initialize.js"; -import { v1PermissionResponse, v2PermissionRequest } from "./permission.js"; -import { v2Foreground, v2Prompt } from "./prompt.js"; import { - v1ForkSessionRequest, - v1NewSessionRequest, - v1RestoreSessionRequest, - v1SetSessionConfigOptionRequest, - v2AvailableCommands, - v2ConfigOptions, - v2NewSessionResponse, - v2ResumeSessionResponse, -} from "./session.js"; -import { v2ChildStateUpdate, v2SessionUpdate } from "./session-update.js"; -import { V2Terminals } from "./terminal.js"; -import { V2ToolCallMeta } from "./tool-call-meta.js"; -import { v2RestartedToolCall } from "./tool-call.js"; + deleteSession, + getSessionMessages, + listSessions, + query, + type Options, + type SDKUserMessage, + type SessionMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import packageJson from "../../package.json" with { type: "json" }; +import { messageIdForGrouping, type Logger } from "../acp-agent.js"; +import { customAgents } from "../session-config-options.js"; +import { claudeCliPath } from "../claude-cli.js"; +import { clientSystemPrompt, sessionSystemPrompt } from "../client-system-prompt.js"; +import type { ElicitationSupport } from "../elicitation.js"; +import { forkSession } from "../fork-session.js"; +import { supportsMcpOAuth } from "../mcp-oauth.js"; +import { sdkMcpServers } from "../mcp-servers.js"; +import { ALLOW_BYPASS, autoModeDisabled } from "../permissions/modes.js"; +import { readResumedTail, resumedModelFromTranscript } from "../resumed-session.js"; +import { mergeEffortSettings } from "../session-effort.js"; +import { configuredSettings, resolveThinkingConfig } from "../session-env.js"; +import { resolveInitialPermissionMode } from "../session-mode.js"; +import { sanitizeTitle } from "../session-titles.js"; +import { SettingsManager } from "../settings.js"; +import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "../startup-failure.js"; +import { createPostToolUseHook } from "../tools.js"; +import { Pushable } from "../utils.js"; +import { V2_SESSION_CAPABILITIES } from "./initialize.js"; +import { v1ForkSessionRequest, v1NewSessionRequest } from "./session-requests.js"; +import { V2UpdateTranslator } from "./update-translator.js"; +import { airInitializeMeta, airSupport, NO_AIR, type AirSupport } from "./air.js"; +import { V2Auth } from "./auth.js"; +import { subagentHooks } from "./child-config.js"; +import { modelSwitchHook, SessionConfig } from "./config.js"; +import { fileChangePreviewHook } from "./file-preview.js"; +import { canUseTool, denyingStoppedWork } from "./permissions.js"; +import { reportHooks } from "./reports.js"; +import { SessionRestarts, type SetupParams } from "./restarts.js"; +import { V2Session, type SessionConnection } from "./session.js"; +import { + afterSetupResponse, + clientOptions, + replayEndUpdate, + sessionModels, + withClientHooks, +} from "./setup.js"; +import type { SubagentReplay } from "./subagent-replay.js"; +import { SubscriptionGuard } from "./subscription-guard.js"; +import { restrictedChild } from "./tasks.js"; -/** - * The ACP v2 surface, for one connection. - * - * As in `v1AgentApp`, the agent of the connection is created when the - * connection opens, before the connection processes any inbound message. - * `onAgent` receives it for the owner of the process (shutdown). - * - * The provider methods pass through: their types are the same in v1 and v2. - */ +/** The sessions per `session/list` page. */ +const SESSION_LIST_PAGE_SIZE = 1000; + +/** What the owner of the process holds of the agent: its shutdown. */ +export interface AgentHandle { + dispose(): Promise; +} + +/** The ACP v2 surface, for one connection. */ export function v2AgentApp( logger: Logger | undefined, - onAgent: (agent: ClaudeAcpAgent) => void, + onAgent: (agent: AgentHandle) => void, ): v2.AgentApp { - let agent!: ClaudeAcpAgent; - let client!: V2ClientConnection; - return v2 - .agent({ name: "claude-code-acp" }) - .onConnect((connection) => { - client = new V2ClientConnection(connection.client, logger ?? console); - agent = new ClaudeAcpAgent(client, logger, { - v2: true, - foreground: (sessionId) => - v2Foreground((update) => { - void client.send({ sessionId, update }); - }), - background: { - changed: (report) => { - for (const notification of client.background.changed(report)) { - void client.send(notification); - } - }, - forget: (sessionId) => client.background.forget(sessionId), - }, - }); - onAgent(agent); - }) - .onRequest(v2.methods.agent.initialize, async ({ params }) => - v2InitializeResponse(await agent.initialize(v1InitializeRequest(params))), - ) - .onRequest(v2.methods.agent.auth.login, ({ params }) => agent.authenticate(params)) - .onRequest(v2.methods.agent.auth.logout, ({ params }) => agent.logout(params)) - .onRequest(v2.methods.agent.providers.list, ({ params }) => - agent.unstable_listProviders(params), - ) - .onRequest(v2.methods.agent.providers.set, ({ params }) => agent.unstable_setProvider(params)) - .onRequest(v2.methods.agent.providers.disable, ({ params }) => - agent.unstable_disableProvider(params), - ) - .onRequest(v2.methods.agent.session.new, async ({ params }) => { - const request = v1NewSessionRequest(params); - const response = await agent.newSession(request); - const commands = await setupCommands(agent, response.sessionId, logger); - afterSetupResponse(agent, response.sessionId, request.mcpServers, commands, logger); - return { ...v2NewSessionResponse(response), ...commands }; - }) - .onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params)) - .onRequest(v2.methods.agent.session.resume, async ({ params }) => { - const restore = v1RestoreSessionRequest(params); - const response = - restore.method === "resume" - ? await agent.resumeSession(restore.request) - : await client.replaying(params.sessionId, () => agent.loadSession(restore.request)); - // v2 asks for the current title and `updatedAt` after the replay and - // before the response, also without a replay, as the client may hold - // none or stale ones. - await agent.publishCurrentSessionInfo(params.sessionId); - // v2 requires a snapshot of the foreground state after the replay and - // before the response, also without a replay: a resumed live session can - // be running a turn, or waiting on a permission request. The current - // state of each subagent, terminal and tool call it lists comes first, - // as v2 requires. The subagents' go again after the response (see below). - await agent.publishLiveSubagents( - params.sessionId, - client.background.listedSubagents(params.sessionId), - ); - await agent.publishBackgroundToolCalls( - params.sessionId, - client.background.listedToolCalls(params.sessionId), - ); - for (const notification of client.background.currentItems(params.sessionId)) { - void client.send(notification); - } - void client.send({ - sessionId: params.sessionId, - update: stateUpdate(agent.foregroundState(params.sessionId)), - }); - const commands = await setupCommands(agent, params.sessionId, logger); - // The response ends a replay. The subagents RFD treats the replayed - // subagent states before it as history, so their current states follow it. - afterSetupResponse( - agent, - params.sessionId, - restore.request.mcpServers ?? [], - commands, - logger, - () => void agent.publishCurrentSubagentStates(params.sessionId), - ); - return { ...v2ResumeSessionResponse(response), ...commands }; - }) - .onRequest(v2.methods.agent.session.fork, ({ params }) => - agent.forking(params.sessionId, async () => { - // The agent starts the forked session, so the client can prompt it. - const request = v1ForkSessionRequest(params); - const response = await agent.unstable_forkSession(request); - const commands = await setupCommands(agent, response.sessionId, logger); - afterSetupResponse(agent, response.sessionId, request.mcpServers ?? [], commands, logger); - return { - sessionId: response.sessionId, - ...v2ResumeSessionResponse(response), - ...commands, - }; - }), - ) - .onRequest(v2.methods.agent.session.close, async ({ params }) => { - const response = await agent.closeSession(params); - client.terminals.forget(params.sessionId); - client.toolCallMeta.forget(params.sessionId); - return response; - }) - .onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params)) - .onRequest(v2.methods.agent.session.setConfigOption, async ({ params }) => { - const { configOptions, ...response } = await agent.setSessionConfigOption( - v1SetSessionConfigOptionRequest(params), - ); - // v2 asks for a `config_option_update` on every config change, also one - // that a client requested, so other attached clients see it. It goes - // before the response, as v2 asks of this duplicate. The agent sends v1 - // clients none: they read the change from the response only. - const options = v2ConfigOptions(configOptions); - void client.send({ - sessionId: params.sessionId, - update: { sessionUpdate: "config_option_update", configOptions: options }, - }); - return { ...response, configOptions: options }; - }) - .onRequest(v2.methods.agent.session.prompt, ({ params }) => - v2Prompt(agent, params, (update) => { - void client.send({ sessionId: params.sessionId, update }); - }), - ) - .onRequest(v2.methods.agent.terminal.cancel, async ({ params }) => { - await agent.cancelBackgroundTerminal(params); - return {}; - }) - .onRequest(v2.methods.agent.toolCall.cancel, async ({ params }) => { - await agent.cancelBackgroundToolCall(params); - return {}; - }) - .onNotification(v2.methods.agent.session.cancel, ({ params }) => agent.cancel(params)); + let agent!: V2Agent; + return ( + v2 + .agent({ name: "claude-code-acp" }) + .onConnect((connection) => { + agent = new V2Agent(connection.client, logger ?? console); + onAgent(agent); + }) + .onRequest(v2.methods.agent.initialize, ({ params }) => agent.initialize(params)) + .onRequest(v2.methods.agent.auth.login, ({ params }) => agent.auth.login(params)) + .onRequest(v2.methods.agent.auth.logout, () => agent.auth.logout()) + .onRequest(v2.methods.agent.providers.list, () => agent.auth.listProviders()) + .onRequest(v2.methods.agent.providers.set, ({ params }) => agent.auth.setProvider(params)) + .onRequest(v2.methods.agent.providers.disable, ({ params }) => + agent.auth.disableProvider(params), + ) + .onRequest(v2.methods.agent.session.new, ({ params }) => agent.newSession(params)) + .onRequest(v2.methods.agent.session.resume, ({ params }) => agent.resumeSession(params)) + .onRequest(v2.methods.agent.session.fork, ({ params }) => agent.forkSession(params)) + .onRequest(v2.methods.agent.session.list, ({ params }) => agent.listSessions(params)) + .onRequest(v2.methods.agent.session.close, ({ params }) => agent.closeSession(params)) + .onRequest(v2.methods.agent.session.delete, ({ params }) => agent.deleteSession(params)) + .onRequest(v2.methods.agent.session.setConfigOption, ({ params }) => + agent.setConfigOption(params), + ) + .onRequest(v2.methods.agent.session.prompt, ({ params }) => agent.prompt(params)) + .onRequest(v2.methods.agent.terminal.cancel, ({ params }) => + agent.cancelItem(params.sessionId, { terminalId: params.terminalId }), + ) + .onRequest(v2.methods.agent.toolCall.cancel, ({ params }) => + agent.cancelItem(params.sessionId, { toolCallId: params.toolCallId }), + ) + // `session/cancel` of a session that is not active is ignored. A + // subagent's session advertises `cancel`: it stops the subagent's run. + .onNotification(v2.methods.agent.session.cancel, ({ params }) => + agent.cancel(params.sessionId), + ) + ); } -/** - * The `availableCommands` of a setup response, which v2 requires when the - * session has commands. The agent sends a v1 client the same list as an - * `available_commands_update` after the response, and a v2 client none - * (`ClaudeAcpAgent.afterSetupResponse`). Later changes still arrive as - * updates. An empty list is left out, as the draft asks. A failure to read - * the commands does not fail the setup: the result is `undefined`, and - * {@link afterSetupResponse} sends the commands in an update instead. - */ -async function setupCommands( - agent: ClaudeAcpAgent, - sessionId: string, - logger: Logger | undefined, -): Promise<{ availableCommands?: v2.AvailableCommand[] } | undefined> { - try { - const commands = await agent.availableCommands(sessionId); - return commands.length > 0 ? { availableCommands: v2AvailableCommands(commands) } : {}; - } catch (error) { - (logger ?? console).error(`Session ${sessionId}: failed to read the session's commands`, error); - return undefined; - } -} - -/** - * The work that follows a v2 setup response, run once the response is sent - * (`ClaudeAcpAgent.afterSetupResponse` does it on v1): - * - * - When `commands` is `undefined` (reading them for the response failed), - * an `available_commands_update` with the commands, read again. - * - `then`, such as sending the current states of replayed subagents. - * - OAuth for the MCP servers that need it. Its URL elicitation must not - * reach the client before the response, which v2 asks against. - */ -function afterSetupResponse( - agent: ClaudeAcpAgent, - sessionId: string, - mcpServers: McpServer[], - commands: { availableCommands?: v2.AvailableCommand[] } | undefined, - logger: Logger | undefined, - then?: () => void, -): void { - setTimeout(() => { - if (!commands) { - agent.sendAvailableCommandsUpdate(sessionId).catch((error) => { - (logger ?? console).error( - `Session ${sessionId}: failed to send the session's commands`, - error, - ); - }); - } - then?.(); - agent.authenticateMcpServers(sessionId, mcpServers); - }, 0); -} - -/** - * The {@link AcpClient} of an ACP v2 connection: it sends the v1 messages of - * the agent as v2 messages. - */ -class V2ClientConnection implements AcpClient { +class V2Agent implements AgentHandle, SessionConnection { + readonly sessions = new Map(); /** - * The sessions whose history replays, each with the messages that the - * replay has cleared so far. A subagent that a replay announces replays - * with it. + * The sessions whose query ended without `session/close`, with what to tell + * the client. v2 keeps a session active until it is closed or the + * connection ends, so its requests fail with what happened, not -32002, + * until a resume starts it again. */ - private readonly replays = new Map>(); - - /** The session whose replay announced each replaying subagent. */ - private readonly replayRoots = new Map(); - + private readonly stopped = new Map(); /** - * The last work state that a replay reported for each subagent, as the - * `state_update` of the child's own stream. It goes out once the replay - * ends: v2 forbids replaying a child's past `running` and `idle` as current - * activity. + * The query starts that run, by session id, until each settles (see + * {@link startSession}). A resume, close or dispose of a session waits for + * its start, so a second start never replaces a running query. */ - private readonly replayedChildStates = new Map(); - - /** The display terminals of the commands that the agent runs. */ - readonly terminals = new V2Terminals(); - - /** The `_meta` that the client stores for each tool call. */ - readonly toolCallMeta = new V2ToolCallMeta(); - - /** The `background` list of each session's `state_update`s. */ - readonly background = new V2Background(this.terminals); + private readonly starting = new Map>(); + /** + * The `session/resume` handlers that run, by session id. A close waits for + * them, so a resume's replay, snapshot and response go out before the close + * response, and nothing of the session after it. + */ + private readonly resuming = new Map>>(); + readonly translator = new V2UpdateTranslator(); + readonly auth: V2Auth; + /** The `--hide-claude-auth` subscription guard (see `subscription-guard.ts`). */ + private readonly guard: SubscriptionGuard; + /** The restarts of the sessions' queries, for a new route or account (see `restarts.ts`). */ + private readonly restarts: SessionRestarts; + /** What the client declared in `initialize`. */ + private clientCapabilities: v2.ClientCapabilities | null | undefined; + /** The elicitation modes that the client advertised in `initialize`. */ + elicitation: ElicitationSupport = { form: false, url: false }; + /** The AIR extensions that the client takes (see `air.ts`). */ + air: AirSupport = NO_AIR; constructor( private readonly ctx: v2.AgentContext, - private readonly logger: Logger, - ) {} - - /** - * Runs `restore`, which replays the history of `sessionId`. - * - * The agent replays messages as chunks. A v2 client may still hold a - * replayed message, and chunks append, so before the first replayed chunk of - * each message the client gets the message with no content, which clears it, - * as v2 requires of a message replayed as chunks. - * - * Each replayed subagent gets one `state_update` on its own stream, with its - * last state, after the replay. - */ - async replaying(sessionId: string, restore: () => Promise): Promise { - this.replays.set(sessionId, new Set()); - const children = () => - [...this.replayRoots].filter(([, root]) => root === sessionId).map(([child]) => child); - try { - const result = await restore(); - for (const child of children()) { - const state = this.replayedChildStates.get(child); - if (state) void this.send(state); - } - return result; - } finally { - this.replays.delete(sessionId); - for (const child of children()) { - this.replays.delete(child); - this.replayRoots.delete(child); - this.replayedChildStates.delete(child); - } - } - } - - /** - * Sends a v2 session update, and logs rather than rejects when that fails. - * It and {@link sessionUpdate} hand updates to the connection synchronously, - * so they go out in the order of the calls. The agent's consumer awaits each - * of its updates before it reports the next turn event, so a turn's state - * follows the output that came before it. (An update the consumer is still - * routing can be overtaken by an event from elsewhere, such as a permission - * request opening.) - */ - send(notification: v2.UpdateSessionNotification): Promise { - return this.ctx - .notify(v2.methods.client.session.update, this.background.stamp(notification)) - .catch((error) => { - this.logger.error(`Failed to send a ${notification.update.sessionUpdate} update:`, error); - }); - } - - extNotification(method: string, params: Record): Promise { - if (!isExtensionMethod(method)) { - return Promise.reject(new Error(`${method} is not an ACP extension method`)); - } - return this.ctx.notify(method, params); - } - - async sessionUpdate({ update, ...notification }: AcpSessionNotification): Promise { - // A subagent that a replay announces replays too, so its messages are - // cleared before their first replayed chunk, as the parent's are. - if ( - update.sessionUpdate === "subagent_update" && - this.replays.has(notification.sessionId) && - !this.replays.has(update.sessionId) - ) { - this.replays.set(update.sessionId, new Set()); - this.replayRoots.set( - update.sessionId, - this.replayRoots.get(notification.sessionId) ?? notification.sessionId, - ); - } - const updates = this.v2Updates(notification.sessionId, update).flatMap((v2Update) => { - const clear = this.replayClear(notification.sessionId, v2Update); - return clear ? [clear, v2Update] : [v2Update]; + readonly logger: Logger, + ) { + this.auth = new V2Auth(ctx, logger, { + routeChanged: () => this.restarts.reroute(), + probed: (kind) => this.guard.probed(kind), }); - // A subagent's work state also goes to the child's own stream, after the - // parent's copy. During a replay, only its last state goes, after the - // replay (see `replayedChildStates`). - let childState = v2ChildStateUpdate(update); - if (childState && this.replays.has(notification.sessionId)) { - this.replayedChildStates.set(childState.sessionId, childState); - childState = undefined; - } - // Every update goes to the connection before any is awaited, so an update - // that another call sends meanwhile cannot come between them. The parent's - // copy is stamped first: a child's announcement starts its list. - const stamped = [ - ...updates.map((v2Update) => this.background.stamp({ ...notification, update: v2Update })), - ...(childState ? [this.background.stamp(childState)] : []), - ]; - await Promise.all( - stamped.map((v2Notification) => - this.ctx.notify(v2.methods.client.session.update, v2Notification), - ), + this.guard = new SubscriptionGuard(this.auth, logger, (sessionId) => + this.restarts.accountMayHaveChanged(sessionId), ); + this.restarts = new SessionRestarts({ + logger, + active: (sessionId) => this.sessions.get(sessionId), + start: (sessionId, params) => this.restartSession(sessionId, params), + lost: async (sessionId, previous) => { + await previous.retire(); + if (this.sessions.get(sessionId) === previous) { + this.stop( + sessionId, + "Claude Code could not start again. Resume the session to continue.", + ); + } + }, + send: (notification) => this.send(notification), + }); } - /** - * The v2 updates of a v1 update: for a tool call report, the updates of its - * terminal first (see {@link V2Terminals}), then the report itself, with the - * `_meta` keys that it changes (see {@link V2ToolCallMeta}), and during a - * replay the `_meta` of a tool call that the report cleared. - */ - private v2Updates( - sessionId: string, - update: AcpSessionNotification["update"], - ): v2.SessionUpdate[] { - if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { - const v2Update = v2SessionUpdate(update); - return v2Update ? [v2Update] : []; - } - const { terminal, report } = this.terminals.split(sessionId, update); - if (!report) return terminal; - const starts = report.sessionUpdate === "tool_call"; - const replaying = this.replays.has(sessionId); - // A v1 `tool_call` starts the tool call afresh, but v2 only has updates, - // which keep each field they leave out. The client may already hold this - // id: from an earlier report on this connection, or, during a replay, from - // before it (a client that resumes a session it showed before). - const restarts = - starts && (replaying || this.toolCallMeta.reported(sessionId, report.toolCallId)); - const [reported, metaPatch] = this.toolCallMeta.apply(sessionId, report, starts, replaying); - const reportUpdate = restarts ? v2RestartedToolCall(reported) : v2SessionUpdate(reported); - const metaUpdate = metaPatch - ? v2SessionUpdate({ sessionUpdate: "tool_call_update", ...metaPatch }) - : undefined; - return [ - ...terminal, - ...(reportUpdate ? [reportUpdate] : []), - ...(metaUpdate ? [metaUpdate] : []), - ]; + // --- SessionConnection --------------------------------------------------- + + send(notification: v2.UpdateSessionNotification): void { + this.ctx.notify(v2.methods.client.session.update, notification).catch((error) => { + this.logger.error(`Failed to send a ${notification.update.sessionUpdate} update:`, error); + }); } - /** - * The update that clears the message of a replayed chunk, when it is the - * first replayed chunk of that message (see {@link replaying}). - */ - private replayClear(sessionId: string, update: v2.SessionUpdate): v2.SessionUpdate | undefined { - const cleared = this.replays.get(sessionId); - if (!cleared) return undefined; - const kind = MESSAGE_OF_CHUNK.get(update.sessionUpdate); - if (!kind) return undefined; - const { messageId } = update as { messageId: string }; - const key = `${kind} ${messageId}`; - if (cleared.has(key)) return undefined; - cleared.add(key); - return emptyMessage(kind, messageId); + requestPermission( + request: v2.RequestPermissionRequest, + signal: AbortSignal, + ): Promise { + // Aborting `signal` sends `$/cancel_request`, and stops waiting for the answer. + return this.ctx.request(v2.methods.client.session.requestPermission, request, { + cancellationSignal: signal, + stopWaitingOnCancel: true, + }); } - async requestPermission( - params: AcpPermissionRequest, - signal?: AbortSignal, - ): Promise { - // The subject is an update of the tool call, with its stored `_meta`. - const toolCall = this.toolCallMeta.subject(params.sessionId, params.toolCall); - const response = await this.ctx.request( - v2.methods.client.session.requestPermission, - v2PermissionRequest({ ...params, toolCall }), - { cancellationSignal: signal, stopWaitingOnCancel: true }, - ); - return v1PermissionResponse(response); - } - - // Elicitation is the same in v1 and v2. createElicitation( - params: CreateElicitationRequest, - signal?: AbortSignal, + request: CreateElicitationRequest, + signal: AbortSignal, ): Promise { + // The v1 types accept a property schema with any tag; the v2 types accept + // one only in a received value. An MCP server's schema is relayed + // unchanged, as on v1, so this cast does not check it. return this.ctx.request( v2.methods.client.elicitation.create, - // The v1 types accept a property schema with any tag; the v2 types accept - // one only in a received value. As on v1, an MCP server's schema is - // relayed here unchanged, so this cast does not check it. - params as v2.CreateElicitationRequest, + request as v2.CreateElicitationRequest, { cancellationSignal: signal, stopWaitingOnCancel: true }, ); } @@ -461,39 +222,736 @@ class V2ClientConnection implements AcpClient { return this.ctx.notify(v2.methods.client.elicitation.complete, params); } - // v2 has no client file system. The agent never calls these on v2, because - // `v1InitializeRequest` reports no `fs` capability. - readTextFile(): Promise { - return Promise.reject(new Error("ACP v2 has no client file system")); + ended(session: V2Session): void { + // The old query of a restart that replaced it already. + if (this.sessions.get(session.sessionId) !== session) return; + this.stop(session.sessionId, "Claude Code stopped. Resume the session to continue."); } - writeTextFile(): Promise { - return Promise.reject(new Error("ACP v2 has no client file system")); + /** The session's query ended: a later `session/resume` starts it again from its transcript. */ + private stop(sessionId: string, message: string): void { + this.sessions.delete(sessionId); + this.stopped.set(sessionId, message); + this.translator.forget(sessionId); + this.auth.sessionEnded(sessionId); + this.guard.sessionEnded(sessionId); + this.restarts.forget(sessionId); + } + + signedOut(sessionId: string): void { + this.guard.signedOut(sessionId); + } + + // --- Requests -------------------------------------------------------------- + + /** + * The v2 handshake. An AIR client gets the AIR capabilities that this core + * serves in the response's `_meta` (see `air.ts`). + */ + initialize(request: v2.InitializeRequest): v2.InitializeResponse { + this.clientCapabilities = request.capabilities; + this.elicitation = { + form: request.capabilities?.elicitation?.form != null, + url: request.capabilities?.elicitation?.url != null, + }; + this.air = airSupport(request.capabilities); + const { authMethods, _meta } = this.auth.initialize(request.capabilities); + const airMeta = airInitializeMeta(this.air); + return { + ...(airMeta ? { _meta: airMeta } : {}), + protocolVersion: v2.PROTOCOL_VERSION, + info: { name: packageJson.name, title: "Claude Agent", version: packageJson.version }, + capabilities: { + session: V2_SESSION_CAPABILITIES, + // `providers/list`, `set`, and `disable`, served for every client. + providers: {}, + // A prompt that comes while Claude works waits in Claude Code's queue. + _meta: { claudeCode: { promptQueueing: true }, ..._meta }, + }, + authMethods, + }; + } + + /** + * `session/prompt`. Each prompt reads the auth identity again, as on v1. A + * restart of the session's query that is due runs first (see + * `restarts.ts`), then the subscription guard judges the account, among + * the session's prompts (see `V2Session.prompt`). The restart's await + * happens only when one is due, so the prompts of a session keep their + * order. + * + * Not `async`, and the session's promise is returned as is: the response + * then goes out before the turn's `running`, which the session sends right + * after it resolves the prompt. Nothing enforces this + * order but the number of promise hops (v2 allows either), and the golden + * files of the scenarios (`acp-scenarios-v2.test.ts`) hold it. + */ + prompt(params: v2.PromptRequest): Promise { + const prompt = () => + this.session(params.sessionId, "prompt").prompt(params, (query) => + this.guard.beforePrompt(params.sessionId, query), + ); + try { + this.session(params.sessionId, "prompt"); + this.auth.promptStarted(params.sessionId); + const restart = this.restarts.beforePrompt(params.sessionId); + return restart ? restart.then(prompt) : prompt(); + } catch (error) { + return Promise.reject(error); + } + } + + async newSession(params: v2.NewSessionRequest): Promise { + const sessionId = randomUUID(); + const session = await this.startSession(sessionId, params, undefined); + const response = { sessionId, ...(await this.setupFields(session)) }; + this.afterSetup(session, params); + return response; + } + + /** + * Copies the session's transcript into a new session (`forkSession`), and + * starts it from the copy, so the client can prompt it at once. + */ + async forkSession(params: v2.ForkSessionRequest): Promise { + if (this.ownerOfChild(params.sessionId)) throw restrictedChild(params.sessionId, "fork"); + // Rejects a bad `cwd` or MCP server before the transcript is copied. + await validatedSetup(params); + const { sessionId } = await forkSession(v1ForkSessionRequest(params), { + logger: this.logger, + messageIdForGrouping, + }); + const session = await this.startSession(sessionId, params, sessionId); + const response = { sessionId, ...(await this.setupFields(session)) }; + this.afterSetup(session, params); + return response; + } + + /** + * Resumes a stored session, or attaches to a live one. With `replayFrom: + * { type: "start" }` the stored history replays first. Either way, the + * state snapshot goes out after any replay and before the response. + */ + resumeSession(params: v2.ResumeSessionRequest): Promise { + const { sessionId } = params; + const run = this.resume(params); + let runs = this.resuming.get(sessionId); + if (!runs) this.resuming.set(sessionId, (runs = new Set())); + runs.add(run); + const settled = () => { + runs.delete(run); + if (runs.size === 0 && this.resuming.get(sessionId) === runs) this.resuming.delete(sessionId); + }; + run.then(settled, settled); + return run; + } + + /** The handler of {@link resumeSession}. */ + private async resume(params: v2.ResumeSessionRequest): Promise { + const { sessionId, replayFrom } = params; + if (replayFrom != null && replayFrom.type !== "start") { + throw RequestError.invalidParams( + { replayFrom }, + `Replaying from a ${replayFrom.type} cursor is not supported`, + ); + } + if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, "resume"); + await this.restarts.settled(sessionId); + // A concurrent resume of this session started its query: this one then + // attaches to the session that it started. + await this.startSettled(sessionId); + let session = this.sessions.get(sessionId); + let history: Promise | undefined; + if (session) { + // Resuming the active session synchronizes it again. Its work goes on, + // and the request's MCP servers and additional directories replace the + // session's. + const request = await validatedSetup(params); + if (request.cwd !== session.cwd) { + throw RequestError.invalidParams( + { cwd: request.cwd }, + `Session ${sessionId} works in ${session.cwd}: resume it with that \`cwd\``, + ); + } + await session.setMcpServers({ + ...clientOptions(params._meta)?.mcpServers, + ...sdkMcpServers(request.mcpServers), + }); + if (!sameDirectories(request.additionalDirectories ?? [], session.additionalDirectories)) { + // The SDK cannot change them on a running query: it restarts with + // them at its next turn boundary (see `restarts.ts`). + this.restarts.rootsChanged(sessionId, params); + } else { + // A restart of its query starts with these servers. + this.restarts.track(sessionId, params); + } + } else { + // One read of the transcript serves the replay and the resumed model. + history = replayFrom != null ? getSessionMessages(sessionId, { dir: params.cwd }) : undefined; + // A failed start leaves it unawaited. + history?.catch(() => {}); + session = await this.startSession(sessionId, params, sessionId, history); + } + let subagents: SubagentReplay | undefined; + if (replayFrom != null) { + const messages = await (history ?? getSessionMessages(sessionId, { dir: session.cwd })); + subagents = await session.tasks.loadReplay(messages); + session.output.replay( + messages, + subagents?.hooks(session.output, messages), + session.tasks.backgroundWork(sessionId), + session.tasks.replayedBackground(sessionId), + ); + subagents?.finish(); + const end = replayEndUpdate(this.clientCapabilities); + if (end) this.send({ sessionId, update: end }); + } + // The current title, after the replay and before the state. + await session.reports.publishTitle(); + for (const notification of session.resumeSnapshot()) this.send(notification); + const response = await this.setupFields(session); + this.afterSetup(session, params); + // The response ends the replay, and the replayed children's states before + // it are history: their current states follow it. Nothing enforces that + // the timer runs after the response goes out; it relies on the response + // being written when this handler returns, as the v1 core's + // `afterSetupResponse` does. + if (subagents) setTimeout(() => subagents.afterResponse(), 0); + return response; + } + + async listSessions(params: v2.ListSessionsRequest): Promise { + if (params.cwd != null && !path.isAbsolute(params.cwd)) { + throw RequestError.invalidParams({ cwd: params.cwd }, "`cwd` must be an absolute path"); + } + const offset = listOffset(params.cursor); + // One more than a page tells whether a next page exists. + const stored = await listSessions({ + dir: params.cwd ?? undefined, + includeWorktrees: false, + limit: SESSION_LIST_PAGE_SIZE + 1, + offset, + }); + const sessions = stored.slice(0, SESSION_LIST_PAGE_SIZE).flatMap((session) => + session.cwd + ? [ + { + sessionId: session.sessionId, + cwd: session.cwd, + title: sanitizeTitle(session.summary), + updatedAt: new Date(session.lastModified).toISOString(), + }, + ] + : [], + ); + return stored.length > SESSION_LIST_PAGE_SIZE + ? { sessions, nextCursor: `offset:${offset + SESSION_LIST_PAGE_SIZE}` } + : { sessions }; + } + + /** Closing is idempotent: a session that is not active closes at once. */ + async closeSession({ sessionId }: v2.CloseSessionRequest): Promise { + if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, "close"); + // A restart that runs ends first, so the close ends the session it started. + await this.restarts.closing(sessionId); + // A resume that runs answers first: the close ends what it started, and + // its updates go out before the close response. + await this.setupSettled(sessionId); + this.stopped.delete(sessionId); + const session = this.sessions.get(sessionId); + if (session) { + await session.close(); + if (this.sessions.get(sessionId) === session) this.sessions.delete(sessionId); + this.translator.forget(sessionId); + this.auth.sessionEnded(sessionId); + this.guard.sessionEnded(sessionId); + } + this.restarts.closed(sessionId); + return {}; + } + + /** Deleting a session that does not exist succeeds, as v2 asks. */ + async deleteSession({ sessionId }: v2.DeleteSessionRequest): Promise { + if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, "delete"); + // A resume that runs, or its start, may make the session live. + await this.setupSettled(sessionId); + const live = this.sessions.get(sessionId); + if (live || this.stopped.has(sessionId)) await this.closeSession({ sessionId }); + try { + await deleteSession(sessionId, live ? { dir: live.cwd } : undefined); + } catch (error) { + // A session without a transcript, such as one never prompted, has nothing to delete. + if (error instanceof Error && /not found/i.test(error.message)) return {}; + throw error; + } + return {}; + } + + async setConfigOption( + params: v2.SetSessionConfigOptionRequest, + ): Promise { + // On the new query, when a restart runs. + await this.restarts.settled(params.sessionId); + const configOptions = await this.session(params.sessionId, "configure").setConfigOption(params); + return { configOptions }; + } + + /** + * The active session `sessionId`, for a request that `method` names. A + * subagent's session takes no such request: the client can only watch it + * and cancel its run. + */ + session(sessionId: string, method: string): V2Session { + const session = this.sessions.get(sessionId); + if (session) return session; + if (this.ownerOfChild(sessionId)) throw restrictedChild(sessionId, method); + throw this.inactive(sessionId); + } + + /** The error of a request of `sessionId`, which is not running. */ + private inactive(sessionId: string): RequestError { + const stopped = this.stopped.get(sessionId); + return stopped !== undefined + ? RequestError.internalError({ sessionId }, stopped) + : RequestError.sessionNotFound(sessionId); + } + + async cancel(sessionId: string): Promise { + this.restarts.cancelled(sessionId); + const session = this.sessions.get(sessionId); + if (session) return session.cancel(); + await this.ownerOfChild(sessionId)?.tasks.cancelChild(sessionId); + } + + /** `terminal/cancel` and `tool_call/cancel`: stops a listed item of a session or a child. */ + async cancelItem( + sessionId: string, + target: { terminalId: string } | { toolCallId: string }, + ): Promise> { + const root = this.sessions.get(sessionId) ?? this.ownerOfChild(sessionId); + if (!root) throw this.inactive(sessionId); + if (!(await root.tasks.cancelItem(sessionId, target))) { + throw "terminalId" in target + ? RequestError.invalidParams( + { sessionId, terminalId: target.terminalId }, + `Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`, + ) + : RequestError.invalidParams( + { sessionId, toolCallId: target.toolCallId }, + `Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`, + ); + } + return {}; + } + + /** The root session that `sessionId` is a subagent session of. */ + private ownerOfChild(sessionId: string): V2Session | undefined { + return [...this.sessions.values()].find((session) => session.tasks.hasChild(sessionId)); + } + + async dispose(): Promise { + await this.restarts.dispose(); + await Promise.all([...this.starting.keys()].map((sessionId) => this.startSettled(sessionId))); + const sessions = [...this.sessions.values()]; + this.sessions.clear(); + this.stopped.clear(); + await Promise.all(sessions.map((session) => session.close())); + } + + // --- Session setup ----------------------------------------------------------- + + /** + * Starts the query of the live session `sessionId` again, resuming its + * conversation (see `restarts.ts`). Claude Code writes the conversation at + * the first prompt, so a session never prompted has none to resume: it + * starts afresh under the same id. + */ + private async restartSession(sessionId: string, params: SetupParams): Promise { + try { + return await this.startSession(sessionId, params, sessionId); + } catch (error) { + if (!( + error instanceof RequestError && error.code === RequestError.sessionNotFound(sessionId).code + )) { + throw error; + } + return this.startSession(sessionId, params, undefined); + } + } + + /** + * The config options and commands of a setup response. When reading the + * commands fails, the response goes without them, and an + * `available_commands_update` with the commands, read again, follows it. + */ + private async setupFields(session: V2Session): Promise<{ + configOptions: v2.SessionConfigOption[]; + availableCommands?: v2.AvailableCommand[]; + }> { + const commands = await session.commands.list().catch((error: unknown) => { + this.logger.error(`Session ${session.sessionId}: failed to read its commands`, error); + // After the response, which the SDK sends once this handler returns. + setTimeout(() => { + session.sendCommands().catch((error: unknown) => { + this.logger.error(`Session ${session.sessionId}: failed to send its commands`, error); + }); + }, 0); + return []; + }); + return { + configOptions: session.config.options(), + // The draft leaves out an empty list. + ...(commands.length > 0 ? { availableCommands: commands } : {}), + }; + } + + /** + * Resolves once the resumes of `sessionId` that run now, and every start of + * it, settled, whether they succeeded or not. A resume that comes later is + * not waited for. + */ + private async setupSettled(sessionId: string): Promise { + const resumes = [...(this.resuming.get(sessionId) ?? [])]; + await Promise.all(resumes.map((resume) => resume.catch(() => {}))); + await this.startSettled(sessionId); + } + + /** + * What follows a setup response: OAuth for its MCP servers, and a route + * restart that the session's start missed (`SessionRestarts.setupAnswered`). + * A restart that runs, such as for other additional directories, retires + * `session` and starts OAuth on the new query instead. + */ + private afterSetup(session: V2Session, params: SetupParams): void { + if (!this.restarts.restarting(session.sessionId)) afterSetupResponse(session, params); + this.restarts.setupAnswered(session.sessionId); + } + + /** Resolves once no start of `sessionId` runs, whether the starts succeeded or not. */ + private async startSettled(sessionId: string): Promise { + // Each wait can end with another start running, which began after it. + let start = this.starting.get(sessionId); + while (start) { + await start.catch(() => {}); + start = this.starting.get(sessionId); + } + } + + /** + * Starts the query of a session: a new one, or `resume` of a stored one + * (also a fork's copy), and tracks the start in {@link starting} until it + * settles. A resume checks that no start of `sessionId` runs right + * before, with no await in between. A restart's start is serialized by + * `restarts.ts`, which a resume and a close wait for first. + */ + private startSession( + sessionId: string, + params: v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest, + resume: string | undefined, + history?: Promise, + ): Promise { + const start = this.launchSession(sessionId, params, resume, history); + this.starting.set(sessionId, start); + const settled = () => { + if (this.starting.get(sessionId) === start) this.starting.delete(sessionId); + }; + start.then(settled, settled); + return start; + } + + /** + * The query start of {@link startSession}. Its options are the client's + * `_meta.claudeCode.options` under the ones this core controls. + */ + private async launchSession( + sessionId: string, + params: v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest, + resume: string | undefined, + /** The resumed transcript, when the caller reads it anyway (a replay). */ + history?: Promise, + ): Promise { + const request = await validatedSetup(params); + const client = clientOptions(params._meta); + // After a provider change that is still listing its models. + await this.auth.routeSettled(); + // The resumed transcript's last model and mode. + const resumed = + resume === undefined + ? undefined + : history + ? { + ...(await readResumedTail(resume, this.logger, false)), + model: await history.then(resumedModelFromTranscript, () => undefined), + } + : await readResumedTail(resume, this.logger); + // Read before the settings watchers open: only a failed query start disposes them. + const configured = await configuredSettings(client?.settings, request.cwd); + const pathToClaudeCodeExecutable = await claudeCliPath(); + const settings = new SettingsManager(request.cwd, { logger: this.logger }); + await settings.initialize(); + // A client can remove bypass from the session with `false`. + const allowBypass = + ALLOW_BYPASS && + settings.getSettings().permissions?.disableBypassPermissionsMode !== "disable" && + client?.allowDangerouslySkipPermissions !== false; + const allowAuto = !autoModeDisabled(settings.getSettings()); + const { mode, builtInDefault } = resolveInitialPermissionMode( + { + resumed: resumed?.permissionMode, + defaultMode: settings.getSettings().permissions?.defaultMode, + }, + this.logger, + { allowBypass, allowAuto }, + ); + // The settings stay live for the idle compaction (see `reports.ts`) until the session + // ends. They keep the values they read: their cwd and key helper. + const authStarted = this.auth.sessionStarting(settings); + const routeGeneration = this.auth.routeGeneration; + const routing = this.auth.routing(); + const thinking = resolveThinkingConfig( + process.env.MAX_THINKING_TOKENS, + process.env.CLAUDE_CODE_THINKING_DISPLAY, + this.logger, + ); + const mcpServers = { ...client?.mcpServers, ...sdkMcpServers(request.mcpServers) }; + + const clientPrompt = clientSystemPrompt(request.systemPrompt); + const input = new Pushable(); + // A client can turn the affordance off (`_meta.claudeCode.options`). Then + // Claude Code's interrupt stops background tasks, so `session/cancel` + // could not leave listed work running, and the session lists none. + const perTaskStopAffordance = client?.perTaskStopAffordance !== false; + const additionalDirectories = [ + ...(client?.additionalDirectories ?? []), + ...(request.additionalDirectories ?? []), + ]; + const session = new V2Session( + sessionId, + request.cwd, + request.additionalDirectories ?? [], + this, + input, + perTaskStopAffordance, + additionalDirectories, + ); + const elicitationOptions = session.elicitations.queryOptions(); + const subagent = subagentHooks((input) => session.tasks.subagentHook(input)); + const options: Options = { + systemPrompt: sessionSystemPrompt(clientPrompt, params._meta?.systemPrompt), + settingSources: ["user", "project", "local"], + ...(thinking !== undefined && { thinking }), + // Claude Code's interrupt then spares background tasks, which the + // client stops one at a time: `session/cancel` must leave listed work + // running. Before the client's options. + perTaskStopAffordance: true, + // AIR's predicted follow-up prompt (see `reports.ts`), as on v1. Only + // AIR gets it, so no other client's session spends a request on it. + // Before the client's options: `promptSuggestions: false` turns it off. + ...(this.air.client ? { promptSuggestions: true } : {}), + ...client, + // The checkpoints that AIR's file-change reports read (see + // `file-changes.ts`), only for a client that declared them, as on v1. + ...(this.air.fileChangeReport ? { enableFileCheckpointing: true } : {}), + // Claude Code applies the env of settings.json after the process env: + // the route goes into the programmatic settings too. + ...(routing.settings + ? { + settings: { + ...configured, + ...routing.settings, + env: { ...configured?.env, ...routing.settings.env }, + }, + } + : configured && { settings: configured }), + cwd: request.cwd, + additionalDirectories, + includePartialMessages: true, + // A subagent's text, for its own session's stream (see `tasks.ts`). + forwardSubagentText: true, + mcpServers, + permissionMode: mode, + allowDangerouslySkipPermissions: allowBypass, + canUseTool: denyingStoppedWork(session, session.elicitations.canUseTool(canUseTool(session))), + // MCP elicitations and the refusal fallback, for the modes the client + // renders. Without forms, AskUserQuestion is disallowed (below). + ...elicitationOptions, + pathToClaudeCodeExecutable, + // Echoes each prompt that Claude Code takes in (see `V2Session`). + extraArgs: { ...client?.extraArgs, "replay-user-messages": "" }, + disallowedTools: [ + ...(client?.disallowedTools ?? []), + ...(elicitationOptions.disallowedTools ?? []), + ], + tools: + client?.tools ?? + (params._meta?.disableBuiltInTools === true + ? [] + : { type: "preset", preset: "claude_code" }), + hooks: withClientHooks(client?.hooks, { + // A subagent's `cwd` (such as its worktree) and configuration, from its hooks. + SubagentStart: [subagent.SubagentStart], + SubagentStop: [subagent.SubagentStop], + PostToolUseFailure: [subagent.PostToolUseFailure], + // An Edit or Write shows its exact patch before it runs. + PreToolUse: [subagent.PreToolUse, fileChangePreviewHook(session, this.logger)], + PostToolUse: [ + subagent.PostToolUse, + { + hooks: [ + createPostToolUseHook({ onEnterPlanMode: async () => session.modeChanged("plan") }), + ], + }, + ], + PostModelSwitch: [ + subagent.PostModelSwitch, + modelSwitchHook((model) => session.modelSwitched(model)), + ], + ...reportHooks(session), + }), + env: { + ...process.env, + ...client?.env, + // The gateway login or the provider (see `auth.ts`). + ...routing.env, + // `session_state_changed`, which is the session's state. + CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1", + ...STARTUP_FAILURE_RESULTS_ENV, + }, + ...(resume !== undefined ? { resume } : { sessionId }), + }; + const q = query({ prompt: input, options }); + let initialization; + try { + initialization = await q.initializationResult(); + } catch (error) { + q.close(); + settings.dispose(); + if ( + resume !== undefined && + error instanceof Error && + error.message.includes("No conversation found with session ID") + ) { + throw RequestError.sessionNotFound(sessionId); + } + throw startupFailureError(error) ?? error; + } + authStarted(sessionId, initialization.account); + try { + this.guard.sessionStarted(sessionId, initialization.account, settings.getApiKeyHelper()); + } catch (error) { + q.close(); + settings.dispose(); + throw error; + } + // For the description of a child's agent type in its configuration. + session.tasks.configs.configureAgents(customAgents(initialization.agents ?? [])); + let models; + try { + models = await sessionModels( + q, + initialization.models, + settings, + routing.models, + routing.narrowsModels, + resumed, + sessionId, + this.logger, + ); + } catch (error) { + // Claude Code refused a fresh session's model pin. + q.close(); + settings.dispose(); + throw error; + } + // A failed sync of the mode is logged (`SessionModeManager.initialize`), + // and a failed read of the agents or of ultracode leaves them out. Only + // the effort that `recommendedValue` sets can fail the start. + let config; + try { + config = await SessionConfig.start( + sessionId, + q, + { mode, builtInDefault, allowBypass, allowAuto }, + models.models, + models.modelInfos, + models.currentModelInfo, + { + fastMode: { + state: initialization.fast_mode_state, + disabledReason: initialization.fast_mode_disabled_reason, + }, + client, + effortSettings: () => mergeEffortSettings(settings.getSettings(), configured), + recommendedValue: this.air.recommendedValue, + }, + (notification) => session.output.emitV1([notification]), + this.logger, + ); + } catch (error) { + q.close(); + settings.dispose(); + throw error; + } + session.reports.configure({ + settings, + env: options.env ?? {}, + mcpServers: request.mcpServers.map((server) => server.name), + mcpOAuth: this.elicitation.url && supportsMcpOAuth(q), + query: q, + model: { id: models.models.currentModelId, info: models.allowlistedModelInfo }, + }); + session.start(q, config, mcpServers, resume !== undefined); + this.sessions.set(sessionId, session); + this.stopped.delete(sessionId); + // A route change during the start did not find this session live. + this.restarts.track(sessionId, params, { + staleRoute: this.auth.routeGeneration !== routeGeneration, + }); + return session; } } -/** The message update of each message chunk. */ -const MESSAGE_OF_CHUNK = new Map([ - ["user_message_chunk", "user_message"], - ["agent_message_chunk", "agent_message"], - ["agent_thought_chunk", "agent_thought"], -]); - -function emptyMessage( - kind: "user_message" | "agent_message" | "agent_thought", - messageId: string, -): v2.SessionUpdate { - const message = { messageId, content: [] }; - switch (kind) { - case "user_message": - return { ...message, sessionUpdate: kind }; - case "agent_message": - return { ...message, sessionUpdate: kind }; - default: - return { ...message, sessionUpdate: kind }; +/** + * The setup request in v1 form, once it is valid: the MCP transports that + * `V2_SESSION_CAPABILITIES` lists, a `systemPrompt` that the session can apply + * (`clientSystemPrompt`), an absolute `cwd` that is a directory, and + * absolute additional directories. Every setup path calls it before it acts, + * so an invalid request creates, resumes or forks nothing. + */ +async function validatedSetup( + params: v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest, +) { + const request = v1NewSessionRequest({ ...params, mcpServers: params.mcpServers ?? [] }); + clientSystemPrompt(request.systemPrompt); + const { cwd } = request; + if (!path.isAbsolute(cwd)) { + throw RequestError.invalidParams({ cwd }, `\`cwd\` must be an absolute path, not ${cwd}`); } + const stat = await fs.stat(cwd).catch(() => undefined); + if (!stat?.isDirectory()) { + throw RequestError.invalidParams({ cwd }, `\`cwd\` is not a directory: ${cwd}`); + } + for (const directory of request.additionalDirectories ?? []) { + if (!path.isAbsolute(directory)) { + throw RequestError.invalidParams( + { additionalDirectories: request.additionalDirectories }, + `Additional directories must be absolute paths, not ${directory}`, + ); + } + } + return request; } -function isExtensionMethod(method: string): method is v2.ExtensionMethod { - return method.startsWith("_"); +function sameDirectories(left: readonly string[], right: readonly string[]): boolean { + const normalize = (directories: readonly string[]) => + [...new Set(directories.map((directory) => path.resolve(directory)))].sort().join("\n"); + return normalize(left) === normalize(right); +} + +function listOffset(cursor: string | null | undefined): number { + if (cursor == null) return 0; + const match = /^offset:(\d+)$/.exec(cursor); + if (!match) + throw RequestError.invalidParams({ cursor }, `Unknown session/list cursor: ${cursor}`); + return Number(match[1]); } diff --git a/src/v2/air.ts b/src/v2/air.ts new file mode 100644 index 00000000..e104cd3b --- /dev/null +++ b/src/v2/air.ts @@ -0,0 +1,66 @@ +/** + * The JetBrains AIR extensions (`docs/air-extensions.md`) that the v2 + * core serves, and their negotiation. Only a client that declares + * `_meta.jetbrains.air` in `initialize` gets them, with the wire output that + * the v1 core gives AIR. The extensions that ACP v2 covers on its own are + * not served (see "ACP v2" in docs/air-extensions.md). + */ +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import { + AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, + airCapabilityMeta, + clientSupportsAirCapability, + isAirClient, +} from "../air-extension.js"; +import { + AGENT_FILE_CHANGE_REPORT_CAPABILITY, + supportsAgentFileChangeReport, +} from "../file-change-audit.js"; + +/** What the connection's client takes of the AIR extensions. */ +export interface AirSupport { + /** + * The client is AIR. It gets the permission presentation, the AIR + * permission tool call `_meta`, AskUserQuestion's custom answer marker and + * the predicted follow-up prompt. + */ + readonly client: boolean; + /** `recommendedValue`: concrete model and effort rows with a recommendation. */ + readonly recommendedValue: boolean; + /** `agentFileChangeReport`: the changed files of a prompt's turn. */ + readonly fileChangeReport: boolean; +} + +/** A client that is not AIR. */ +export const NO_AIR: AirSupport = { + client: false, + recommendedValue: false, + fileChangeReport: false, +}; + +/** What the client declared in `initialize`. */ +export function airSupport(capabilities: v2.ClientCapabilities | null | undefined): AirSupport { + if (!isAirClient(capabilities)) return NO_AIR; + return { + client: true, + recommendedValue: clientSupportsAirCapability( + capabilities, + AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, + ), + fileChangeReport: supportsAgentFileChangeReport(capabilities), + }; +} + +/** + * The `initialize` response `_meta` of an AIR client: the capabilities this + * core serves, which the v1 core advertises too. `loadReplayEnd` is served + * (see `setup.ts`) but not listed: the client declares it, and the agent does + * not advertise it. Undefined for every other client. + */ +export function airInitializeMeta(air: AirSupport): Record | undefined { + if (!air.client) return undefined; + return airCapabilityMeta( + AGENT_FILE_CHANGE_REPORT_CAPABILITY, + AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, + ); +} diff --git a/src/v2/auth.ts b/src/v2/auth.ts new file mode 100644 index 00000000..1822b0ed --- /dev/null +++ b/src/v2/auth.ts @@ -0,0 +1,295 @@ +/** + * The connection's auth and LLM routing on the v2 core: the `authStatus` + * extension (`_meta.authStatus` and `_auth/status_update`), the gateway logins + * of `auth/login`, `auth/logout`, and `providers/list|set|disable`. + * + * The state and its rules are the v1 core's (`AuthStatusReporter`, + * `providers.ts`). The identity is read at the same moments as there: after + * `initialize`, from each session's account, after a login or a logout, and at + * the start of each prompt (see `auth-status.ts`). + * + * A gateway login routes the sessions started after it. + * `providers/set`, `providers/disable`, and a logout that drops a provider or + * a gateway login also route the live sessions: each one's query restarts at + * its next turn boundary (see `restarts.ts`). The request answers once the + * sessions at a turn boundary restarted, and does not wait for the busy ones. + */ +import { RequestError } from "@agentclientprotocol/sdk/unstable"; +import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { AccountInfo, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk"; +import type { AuthStatusKind } from "../auth-status.js"; +import type { Logger } from "../acp-agent.js"; +import { terminalAuthMethods } from "../auth-methods.js"; +import { + AUTH_STATUS_UPDATE_METHOD, + authStatusCapability, + gatewayAuthStatus, + notLoggedInAuthStatus, +} from "../auth-status.js"; +import { AuthStatusReporter, cliLogout, type AuthStatusSettings } from "../auth-status-reporter.js"; +import { clearContextWindowCache } from "../context-window.js"; +import { + createEnvForProvider, + defaultProviderConfig, + type GatewayAuthRequest, + gatewayAuthMethods, + gatewayRequestToProviderConfig, + isGatewayAuthMethod, + listProvidersResponse, + PROVIDER_ID, + type ProviderConfig, + providerConfigFromSetRequest, + validatedGatewayAuthRequest, + withListedModels, +} from "../providers.js"; +import { narrowToGatewayModels } from "../session-model.js"; +import { v2AuthMethod } from "./initialize.js"; + +/** The route of a session that starts now. */ +export interface SessionRouting { + /** Added to the query's env. Empty without a gateway login or a provider. */ + env: Record; + /** + * The programmatic settings of the query, with the route's env too: Claude + * Code applies the env of settings.json after the process env, so user or + * project settings could restore another `ANTHROPIC_BASE_URL` otherwise. + */ + settings?: Settings; + /** The models the session offers: those the provider's gateway listed. */ + models(models: ModelInfo[]): ModelInfo[]; + /** Whether {@link models} narrows the models to a gateway's list. */ + narrowsModels: boolean; +} + +/** What the auth state tells the rest of the core. */ +export interface AuthEvents { + /** + * The route of live sessions changed: resolves once the sessions at a turn + * boundary restarted (see `SessionRestarts.reroute`). + */ + routeChanged(): Promise; + /** An auth status probe read the identity `kind`. */ + probed(kind: AuthStatusKind): void; +} + +export class V2Auth { + private gatewayLogin?: GatewayAuthRequest; + private provider?: ProviderConfig; + /** Runs `providers/set` and `providers/disable` one at a time, in arrival order. */ + private providerUpdate: Promise = Promise.resolve(); + /** How many of {@link providerUpdate} have not settled. */ + private providerUpdates = 0; + /** The settings of each live session, for the probe at the start of its prompts. */ + private readonly sessionSettings = new Map(); + private readonly status: AuthStatusReporter; + /** + * Counts the route changes that restarted the live sessions. A session + * whose start read {@link routing} before one is not live yet then, so it + * restarts once its setup answered (see `SessionRestarts.track`). + */ + private routeChanges = 0; + + constructor( + ctx: v2.AgentContext, + private readonly logger: Logger, + private readonly events: AuthEvents, + ) { + this.status = new AuthStatusReporter({ + push: (authStatus) => ctx.notify(AUTH_STATUS_UPDATE_METHOD, { authStatus }), + logger, + gatewayLogin: () => this.gatewayLogin !== undefined, + providerOverride: () => this.provider !== undefined, + onProbe: (kind) => events.probed(kind), + }); + } + + /** + * The auth fields of the `initialize` response. It starts the probe, which + * nothing waits for: its push goes out after the response. + */ + initialize(capabilities: v2.ClientCapabilities | null | undefined): { + authMethods: v2.AuthMethod[]; + _meta: { authStatus: Record }; + } { + void this.status.probe(); + return { + authMethods: [ + // v2 advertises terminal logins only to a client with `auth.terminal`. + ...(capabilities?.auth?.terminal != null + ? terminalAuthMethods({ terminal: true, metaTerminal: false }) + : []), + // Only offered when the client advertises `auth._meta.gateway`. + ...(capabilities?.auth?._meta?.gateway === true ? gatewayAuthMethods() : []), + ].map(v2AuthMethod), + // The `authStatus` marker: this agent pushes its identity. + _meta: { authStatus: authStatusCapability() }, + }; + } + + /** `auth/login`: a gateway login. The terminal logins are the client's to run. */ + login(params: v2.LoginAuthRequest): v2.LoginAuthResponse { + if (!isGatewayAuthMethod(params.methodId)) { + const terminal = terminalAuthMethods({ terminal: true, metaTerminal: false }); + if (terminal.some((method) => method.id === params.methodId)) { + throw RequestError.invalidParams( + { methodId: params.methodId }, + `${params.methodId} is a terminal login: run it in a terminal, then retry the request`, + ); + } + throw RequestError.invalidParams( + { methodId: params.methodId }, + `Unsupported authentication method: ${params.methodId}`, + ); + } + this.gatewayLogin = validatedGatewayAuthRequest(params); + // The gateway holds the credentials from here on. A probe that started + // before this login is stale. + this.status.changed(); + this.status.set(gatewayAuthStatus(gatewayRequestToProviderConfig(this.gatewayLogin)?.baseUrl)); + return {}; + } + + /** + * `auth/logout`: drops the gateway login and the provider, runs `claude auth + * logout`, and reports the identity that remains (an API key from the env + * survives it). + */ + async logout(): Promise { + // A `providers/set` that is still listing would set a provider too. + const routed = this.overridesRoute() || this.providerUpdates > 0; + this.gatewayLogin = undefined; + this.status.changed(); + await this.updateProvider(async () => { + this.provider = undefined; + }); + // The windows learned under this login must not seed sessions under the next. + clearContextWindowCache(); + try { + await cliLogout(); + } finally { + // The live sessions leave the dropped route, also when the CLI logout + // failed. A plain CLI login needs no restart: the running CLI picks up + // the logout by itself. + if (routed) await this.rerouted(); + } + // The read starts after the credentials were cleared, so it does not join + // a probe in flight. If it cannot read them, the logout still happened. + if (!(await this.status.probe({ fresh: true }))) { + this.status.set(notLoggedInAuthStatus()); + } + return {}; + } + + listProviders(): v2.ListProvidersResponse { + return listProvidersResponse(this.provider ?? defaultProviderConfig(this.gatewayLogin)); + } + + async setProvider(params: v2.SetProviderRequest): Promise { + const config = providerConfigFromSetRequest(params); + await this.updateProvider(async () => { + this.provider = await withListedModels(config, this.provider, this.logger); + }); + await this.rerouted(); + return {}; + } + + async disableProvider(params: v2.DisableProviderRequest): Promise { + // An unknown provider: idempotent success. + if (params.providerId === PROVIDER_ID) { + await this.updateProvider(async () => { + this.provider = undefined; + }); + await this.rerouted(); + } + return {}; + } + + /** Whether a provider or a gateway login routes the traffic, rather than the CLI's own login. */ + overridesRoute(): boolean { + return (this.provider ?? gatewayRequestToProviderConfig(this.gatewayLogin)) != null; + } + + /** The number of route changes so far: read it right before {@link routing}. */ + get routeGeneration(): number { + return this.routeChanges; + } + + /** The route changed: restarts the live sessions (see `SessionRestarts.reroute`). */ + private rerouted(): Promise { + // In the same call as the reroute's look at the live sessions: a session + // either is among them or sees the new count when it is tracked. + this.routeChanges++; + return this.events.routeChanged(); + } + + /** Settles once the provider changes that came so far have applied. */ + async routeSettled(): Promise { + await this.providerUpdate.catch(() => undefined); + } + + /** The route of a session that starts now: the provider, else the gateway login. */ + routing(): SessionRouting { + const config = this.provider ?? gatewayRequestToProviderConfig(this.gatewayLogin); + const env = createEnvForProvider(config); + return { + env, + ...(config ? { settings: { apiKeyHelper: "", env } } : {}), + models: (models) => + config?.listedModels + ? narrowToGatewayModels(models, config.listedModels, config.bedrockRegionPrefix) + : models, + narrowsModels: config?.listedModels !== undefined, + }; + } + + /** + * A session starts under `settings`: probes run in them from now on. Call + * the result with the session's account once Claude Code reports it. It + * reports nothing when a login or a logout came in between. + */ + sessionStarting( + settings: AuthStatusSettings, + ): (sessionId: string, account: AccountInfo | undefined) => void { + this.status.useSettings(settings); + const epoch = this.status.epoch; + return (sessionId, account) => { + this.sessionSettings.set(sessionId, settings); + if (epoch === this.status.epoch) { + this.status.publishSessionAccount(account, settings.getApiKeyHelper()); + } + }; + } + + /** + * A prompt of `sessionId` starts: reads the identity again, without waiting. + * This finds a login or a logout done in another terminal. + */ + promptStarted(sessionId: string): void { + const settings = this.sessionSettings.get(sessionId); + if (settings) this.status.useSettings(settings); + void this.status.probe(); + } + + /** Reports the account that the query of `sessionId` cached, as the identity. */ + publishAccount(sessionId: string, account: AccountInfo): void { + this.status.publishSessionAccount( + account, + this.sessionSettings.get(sessionId)?.getApiKeyHelper(), + ); + } + + sessionEnded(sessionId: string): void { + this.sessionSettings.delete(sessionId); + } + + private updateProvider(update: () => Promise): Promise { + const next = this.providerUpdate.catch(() => undefined).then(update); + this.providerUpdate = next; + this.providerUpdates++; + const settled = () => { + this.providerUpdates--; + }; + next.then(settled, settled); + return next; + } +} diff --git a/src/v2/background.ts b/src/v2/background.ts index 24bcb5e8..7334983f 100644 --- a/src/v2/background.ts +++ b/src/v2/background.ts @@ -1,17 +1,35 @@ /** - * The `background` lists of the v2 sessions of a connection (see - * `background-work.ts`, which builds them). + * The `background` lists of a v2 session and of its subagent sessions (see + * `tasks.ts`, which builds them). * * A session that reports its background work gets its list on every - * `state_update` of its own stream, as v2 requires, from the list the agent + * `state_update` of its own stream, as v2 requires, from the list the core * reports when the session starts. A subagent session of such a session * reports its own list from its announcement on, also in the parent's mirror * of its state (`subagent_update.state`). */ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; -import type { BackgroundItem, BackgroundReport, WorkStateName } from "../background-work.js"; import type { V2Terminals } from "./terminal.js"; +/** An item of a `background` list. */ +export type BackgroundItem = + | { type: "terminal"; terminalId: string } + | { type: "tool_call"; toolCallId: string } + | { type: "subagent"; sessionId: string }; + +/** The work state of a session, as its `state_update` names it. */ +export type WorkStateName = "idle" | "running" | "requires_action" | "unknown"; + +/** A new list of the session `sessionId`, with what its repeated state needs. */ +export interface BackgroundReport { + sessionId: string; + items: BackgroundItem[]; + /** The session's work state now, or undefined when it has none yet. */ + state: WorkStateName | undefined; + /** The parent of a subagent session, which mirrors the child's state. */ + parentSessionId?: string; +} + /** `state` as the current work state of a session, with no stop reason. */ function currentState(state: WorkStateName): v2.StateUpdate { if (state === "idle") return { state: "idle" }; @@ -34,14 +52,8 @@ function withBackground(state: v2.StateUpdate, background: BackgroundItem[]): v2 const FINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); export class V2Background { - /** - * The latest list of each session that reports one, whether a state of the - * session carried it, and the parent of a subagent session. - */ - private readonly sessions = new Map< - string, - { items: BackgroundItem[]; reported: boolean; parent?: string } - >(); + /** The latest list of each session that reports one, and whether a state of the session carried it. */ + private readonly sessions = new Map(); /** * The tool calls that advertise `cancel`, by session and tool call. Like a @@ -78,7 +90,7 @@ export class V2Background { const subagent = update as Extract; let child = this.sessions.get(subagent.sessionId); if (!child) { - child = { items: [], reported: false, parent: sessionId }; + child = { items: [], reported: false }; this.sessions.set(subagent.sessionId, child); } if (subagent.state) { @@ -107,11 +119,7 @@ export class V2Background { const { sessionId, items, state, parentSessionId } = report; const session = this.sessions.get(sessionId); const unchanged = session && JSON.stringify(session.items) === JSON.stringify(items); - this.sessions.set(sessionId, { - items, - reported: session?.reported ?? false, - parent: parentSessionId ?? session?.parent, - }); + this.sessions.set(sessionId, { items, reported: session?.reported ?? false }); const known = new Set((session?.items ?? []).map((item) => JSON.stringify(item))); const updates: v2.UpdateSessionNotification[] = items.flatMap((item) => known.has(JSON.stringify(item)) ? [] : this.advertiseCancel(sessionId, item, false), @@ -131,8 +139,7 @@ export class V2Background { /** * The current `terminal_update` of each terminal that the session * `sessionId` lists, and the `cancel` capability of each tool call that it - * lists, whose other fields the agent sends (see - * `ClaudeAcpAgent.publishBackgroundToolCalls`). v2 requires them before the + * lists, whose other fields the core sends. v2 requires them before the * state snapshot of `session/resume`, as a replay may not have delivered * them. */ @@ -142,13 +149,6 @@ export class V2Background { ); } - /** The tool calls that the session `sessionId` lists. */ - listedToolCalls(sessionId: string): string[] { - return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) => - item.type === "tool_call" ? [item.toolCallId] : [], - ); - } - /** * The update that lets the client cancel the listed terminal or tool call * `item`, unless it advertises that already. `again` sends it anyway, as @@ -178,22 +178,4 @@ export class V2Background { }, ]; } - - /** The subagent sessions that the session `sessionId` lists. */ - listedSubagents(sessionId: string): string[] { - return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) => - item.type === "subagent" ? [item.sessionId] : [], - ); - } - - /** Forgets the list of a session, and those of its subagent sessions. */ - forget(sessionId: string): void { - this.sessions.delete(sessionId); - for (const key of this.cancellableToolCalls) { - if (key.startsWith(`${sessionId} `)) this.cancellableToolCalls.delete(key); - } - for (const [child, { parent }] of this.sessions) { - if (parent === sessionId) this.forget(child); - } - } } diff --git a/src/v2/child-config.ts b/src/v2/child-config.ts new file mode 100644 index 00000000..a8c4a86b --- /dev/null +++ b/src/v2/child-config.ts @@ -0,0 +1,129 @@ +/** + * The configuration of each child session, as the v1 core reports it + * (`NativeSubagentRuntime.reportConfiguration`): the options that the + * child's hooks report (its mode, model after a `PostModelSwitch`, effort and + * agent type, see `subagentHookOptions`) and the model of its responses. Each + * report merges into the child's options, by id, and the client gets all of + * them again when any changed, in `orderedSubagentConfiguration`'s order. + * + * A hook can come before the child's announcement: its options wait for it. + */ +import type { SessionConfigOption, SessionModeState } from "@agentclientprotocol/sdk/unstable"; +import type { AgentInfo, HookCallbackMatcher, HookInput } from "@anthropic-ai/claude-agent-sdk"; +import type { SubagentModels } from "../native-subagents.js"; +import { orderedSubagentConfiguration, subagentHookOptions } from "../subagent-configuration.js"; + +/** The most children not announced yet whose options wait. */ +const MAX_PENDING_CHILDREN = 64; + +/** What the configurations need of their root session. */ +export interface ConfigurationHost { + /** The root's modes, for the name of a child's mode. */ + modes(): SessionModeState["availableModes"]; + /** The entries of the root's `model` option, for a child's `model`. */ + models(): SubagentModels; + /** Whether `agentId` is an announced child session. */ + announced(agentId: string): boolean; +} + +export class ChildConfigurations { + /** Each child's options, by id, and the snapshot that the client last got. */ + private readonly children = new Map< + string, + { options: Map; published?: string } + >(); + /** The custom agents of the session, for the description of a child's agent type. */ + private agents: readonly AgentInfo[] = []; + + constructor(private readonly host: ConfigurationHost) {} + + /** The session's custom agents, from Claude Code's initialization (see `customAgents`). */ + configureAgents(agents: readonly AgentInfo[]): void { + this.agents = agents; + } + + /** The options that a hook of a subagent reports of it. */ + hookOptions(input: HookInput): SessionConfigOption[] { + return subagentHookOptions(input, { + availableModes: this.host.modes(), + models: this.host.models(), + agents: this.agents, + }); + } + + /** + * Takes options of the child `agentId`. Those of a child that is not + * announced wait for it, for at most {@link MAX_PENDING_CHILDREN} children. + * Returns whether they were kept. + */ + take(agentId: string, options: SessionConfigOption[]): boolean { + if (options.length === 0) return false; + let config = this.children.get(agentId); + if (!config) { + const pending = [...this.children.keys()].filter((id) => !this.host.announced(id)).length; + if (!this.host.announced(agentId) && pending >= MAX_PENDING_CHILDREN) return false; + config = { options: new Map() }; + this.children.set(agentId, config); + } + for (const option of options) config.options.set(option.id, option); + return true; + } + + /** + * The child's configuration to send, or undefined when the client holds it + * already. It counts as sent. + */ + unsent(agentId: string): SessionConfigOption[] | undefined { + const config = this.children.get(agentId); + if (!config) return undefined; + const options = orderedSubagentConfiguration(config.options.values()); + const snapshot = JSON.stringify(options); + if (config.published === snapshot) return undefined; + config.published = snapshot; + return options; + } + + /** The configuration that the client last got for the child `agentId`, if any. */ + published(agentId: string): SessionConfigOption[] | undefined { + const published = this.children.get(agentId)?.published; + return published === undefined ? undefined : (JSON.parse(published) as SessionConfigOption[]); + } + + /** A child that stays unexposed: its options go nowhere. */ + forget(agentId: string): void { + this.children.delete(agentId); + } +} + +/** + * The hook that reports a subagent's hooks to `report`, for each hook event + * that reports them. The hook makes no decision. + */ +export function subagentHooks( + report: (input: HookInput) => void, +): Record< + | "SubagentStart" + | "SubagentStop" + | "PreToolUse" + | "PostToolUse" + | "PostToolUseFailure" + | "PostModelSwitch", + HookCallbackMatcher +> { + const matcher: HookCallbackMatcher = { + hooks: [ + async (input) => { + if (input.agent_id) report(input); + return { continue: true }; + }, + ], + }; + return { + SubagentStart: matcher, + SubagentStop: matcher, + PreToolUse: matcher, + PostToolUse: matcher, + PostToolUseFailure: matcher, + PostModelSwitch: matcher, + }; +} diff --git a/src/v2/clear-context.ts b/src/v2/clear-context.ts new file mode 100644 index 00000000..8413d9be --- /dev/null +++ b/src/v2/clear-context.ts @@ -0,0 +1,254 @@ +/** + * ExitPlanMode's "Yes, clear context" option on the v2 core: the + * approved plan runs in a fresh context, in the same turn. + * + * The answer denies ExitPlanMode with an interrupt (`permissions/effects.ts`), + * so the plan does not run in the old context. Claude Code ends that cycle + * with an interruption result (`isExitPlanInterruptionResult`). The turn stays + * open, and the session sends Claude Code `/clear`, which mounts a fresh + * conversation (`conversation_reset`), sets the mode the user picked, and + * sends the plan as the prompt "Implement the following plan". The turn ends + * with that prompt's result, with the usage of the whole turn. + * + * The query stays, unlike v1's fresh query (`clear-context-coordinator.ts`): + * `/clear` is Claude Code's own way to start a fresh conversation, and the + * query's tasks, MCP servers and settings stay. The client sees the accepted plan's tool call `completed`, the + * options with the new mode, a `usage_update` of an empty context, the plan + * as the session's markdown plan, then the plan's run in the open turn, with + * no `user_message` for the adapter's prompts. + */ +import type { AcpSessionNotification } from "../acp-subagents.js"; +import type { ClearContextReset } from "../clear-context-coordinator.js"; +import { + acceptedPlanToolResult, + isExitPlanInterruptionResult, + observeExitPlanToolResults, +} from "../exit-plan.js"; +import type { PermissionMode, SDKResultMessage } from "@anthropic-ai/claude-agent-sdk"; +import { quotaMeta, type AccumulatedUsage, type ModelTokenTally } from "../session-usage.js"; +import type { TurnEnd } from "./outcome.js"; + +type Uuid = `${string}-${string}-${string}-${string}-${string}`; + +/** What the session does for a fresh context. */ +export interface ClearContextHost { + /** Sends Claude Code the prompt `text` with `uuid`, as the adapter's own prompt. */ + push(text: string, uuid: Uuid): void; + /** Sets the permission mode on the query and in the options. */ + enterMode(mode: PermissionMode): Promise; + /** Sends the fresh context's options, its empty usage, and the plan. */ + publish(plan: string): void; + logError(message: string, error: unknown): void; +} + +/** The turn while it continues in a fresh context. */ +type Continuation = { + reset: ClearContextReset; + clearUuid: Uuid; + planUuid: Uuid; + /** The results of the turn so far: the old context's, and `/clear`'s. */ + carried: TurnEnd[]; + /** Whether the plan's prompt went out: once the fresh context is mounted. */ + planSent: boolean; + /** Whether the plan's prompt started: its result ends the turn. */ + planStarted: boolean; +}; + +export class ClearContext { + /** The plan the user accepted with clearing the context, until its interruption. */ + private accepted: ClearContextReset | undefined; + /** An ExitPlanMode answer that interrupts the cycle, and whether its tool result came. */ + private interruption: { toolUseId: string; toolResultSeen: boolean } | undefined; + private continuation: Continuation | undefined; + /** The tool calls of the old context's tool uses, by id (`toolUseCache`). */ + private readonly toolUses: () => Record; + + constructor( + private readonly host: ClearContextHost, + toolUses: () => Record, + private readonly newUuid: () => Uuid, + ) { + this.toolUses = toolUses; + } + + /** + * An ExitPlanMode answer that interrupts the cycle: "No, keep planning", + * or a plan accepted with clearing the context (`reset`). + */ + interrupting(toolUseId: string, reset: ClearContextReset | undefined): void { + this.interruption = { toolUseId, toolResultSeen: false }; + this.accepted = reset; + } + + /** + * A user message of the main conversation. Returns the rewrite of its + * updates: the accepted plan's tool result, which Claude Code reports as a + * rejection, goes out `completed`, without the rejection's text. + */ + toolResults(message: { + type: string; + tool_result_meta?: unknown; + message?: { content?: unknown }; + }): ((notification: AcpSessionNotification) => AcpSessionNotification) | undefined { + const state = { + toolUseCache: this.toolUses(), + pendingExitPlanModeInterruption: this.interruption, + pendingExitPlanContextReset: this.accepted, + }; + const acceptedToolUse = observeExitPlanToolResults(message, message.message?.content, state); + this.interruption = state.pendingExitPlanModeInterruption; + if (!acceptedToolUse) return undefined; + return (notification) => + acceptedPlanToolResult( + notification as Parameters[0], + acceptedToolUse, + ) as AcpSessionNotification; + } + + /** + * A result of the open turn. Returns whether the turn goes on: it is the + * interruption of a plan accepted with clearing the context, or `/clear`'s. + * The fresh context starts with the first. + */ + holdsResult(message: SDKResultMessage, end: TurnEnd): boolean { + const continuation = this.continuation; + if (continuation) { + if (continuation.planStarted) return false; + continuation.carried.push(end); + // Without a `conversation_reset` (an older Claude Code), `/clear`'s result shows it ran. + this.mounted(); + return true; + } + const interruption = this.interruption; + if (!interruption || !isExitPlanInterruptionResult(message, interruption)) return false; + this.interruption = undefined; + const reset = this.accepted; + this.accepted = undefined; + // "No, keep planning": the turn ends. + if (!reset || reset.toolUseId !== interruption.toolUseId) return false; + const clearUuid = this.newUuid(); + this.continuation = { + reset, + clearUuid, + planUuid: this.newUuid(), + carried: [end], + planSent: false, + planStarted: false, + }; + this.host.push("/clear", clearUuid); + return true; + } + + /** Claude Code mounted a fresh conversation: the plan goes out in it. */ + conversationReset(): void { + this.mounted(); + } + + /** Whether the open turn waits for the plan's prompt to start. */ + holdsTurn(): boolean { + return this.continuation !== undefined && !this.continuation.planStarted; + } + + /** Claude Code took in the prompt `uuid`. Returns whether it is the plan's prompt. */ + promptStarted(uuid: string): boolean { + if (this.continuation?.planUuid !== uuid) return false; + this.continuation.planStarted = true; + return true; + } + + /** Whether `uuid` is one of the adapter's prompts for the fresh context: the client gets no `user_message`. */ + ownsPrompt(uuid: string): boolean { + return this.continuation?.clearUuid === uuid || this.continuation?.planUuid === uuid; + } + + /** + * The end of the turn that `end`'s result ends: with the usage of the + * results before it in the turn, the old context's and `/clear`'s. + */ + turnEnd(end: TurnEnd): TurnEnd { + const carried = this.continuation?.carried; + this.continuation = undefined; + return carried && carried.length > 0 ? combinedEnd([...carried, end]) : end; + } + + /** + * The turn ends otherwise (a cancel, a close): the fresh context goes on + * without the plan. Returns the uuids of the adapter's prompts that Claude + * Code may still run, whose work the client did not see. + */ + stop(): string[] { + this.interruption = undefined; + this.accepted = undefined; + const continuation = this.continuation; + this.continuation = undefined; + if (!continuation || continuation.planStarted) return []; + return continuation.planSent + ? [continuation.clearUuid, continuation.planUuid] + : [continuation.clearUuid]; + } + + /** The fresh context is mounted: the mode, the plan, and the plan's prompt. */ + private mounted(): void { + const continuation = this.continuation; + if (!continuation || continuation.planSent) return; + continuation.planSent = true; + const { mode, plan } = continuation.reset; + // Nothing orders the mode before the plan's prompt but this chain: the + // prompt goes out once Claude Code took the mode. + void this.host + .enterMode(mode) + .catch((error: unknown) => this.host.logError("Failed to set the plan's mode", error)) + .then(() => { + if (this.continuation !== continuation) return; + this.host.publish(plan); + this.host.push(`Implement the following plan:\n\n${plan}`, continuation.planUuid); + }); + } +} + +/** The end of a turn of several results: the last result, with their usage summed. */ +function combinedEnd(ends: TurnEnd[]): TurnEnd { + const total: AccumulatedUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }; + const rows: ModelTokenTally = {}; + for (const { _meta } of ends) { + add(total, _meta.quota.token_count); + for (const row of _meta.quota.model_usage) { + add( + (rows[row.model] ??= { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }), + row.token_count, + ); + } + } + const last = ends[ends.length - 1]; + const meta = quotaMeta(total, rows); + return { + result: last.result, + usage: { ...total, totalTokens: meta.quota.token_count.totalTokens }, + _meta: meta, + }; +} + +function add( + into: AccumulatedUsage, + count: { + inputTokens: number; + outputTokens: number; + cachedInputTokens: number; + cachedWriteTokens: number; + }, +): void { + into.inputTokens += count.inputTokens; + into.outputTokens += count.outputTokens; + into.cachedReadTokens += count.cachedInputTokens; + into.cachedWriteTokens += count.cachedWriteTokens; +} diff --git a/src/v2/commands.ts b/src/v2/commands.ts new file mode 100644 index 00000000..462d9c1f --- /dev/null +++ b/src/v2/commands.ts @@ -0,0 +1,85 @@ +/** + * The slash commands of a v2 session: the list that setup responses + * and `available_commands_update` carry, and the adapter's own `/mcp`. + * + * The list is built by the v1 core's `getAvailableSlashCommands`, so both + * cores advertise the same commands: Claude Code's commands without the + * unsupported and terminal-bound ones, MCP prompts as `mcp::`, + * the adapter's `/mcp`, and the adapter's `/remote-control` when the query + * serves Remote Control and Claude Code offers no command of that name (see + * `remote-control.ts`). + * + * `/mcp` is served as on v1 (`runMcpCommand`): Claude Code runs the command, + * and the adapter's server list replaces Claude Code's text in the turn's + * output. + */ +import type { ContentBlock } from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { Query, SDKSystemMessage, SlashCommand } from "@anthropic-ai/claude-agent-sdk"; +import { getAvailableSlashCommands } from "../acp-agent.js"; +import { parseMcpCommand, runMcpCommand, type McpCommand } from "../mcp-command.js"; +import { v2AvailableCommands } from "./session-requests.js"; + +export class SessionCommands { + /** Claude Code's terminal-bound commands, from the turn's `system/init`. */ + private terminalCommands: string[] | undefined; + + constructor(private readonly query: Query) {} + + /** The session's commands, in v2 form. */ + async list(): Promise { + return this.render(await this.query.supportedCommands()); + } + + /** The `available_commands_update` of Claude Code's `commands_changed` list. */ + changed(commands: SlashCommand[]): v2.SessionUpdate { + return { sessionUpdate: "available_commands_update", availableCommands: this.render(commands) }; + } + + /** + * Records the terminal-bound commands of a `system/init`. Returns whether + * they changed, so the list must go out again. + */ + init(message: SDKSystemMessage): boolean { + const terminal = message.terminal_slash_commands; + if (!terminal || JSON.stringify(terminal) === JSON.stringify(this.terminalCommands)) { + return false; + } + this.terminalCommands = terminal; + return true; + } + + /** Claude Code's terminal-bound commands, which it does not offer. */ + terminal(): readonly string[] | undefined { + return this.terminalCommands; + } + + /** The `/mcp` command that a prompt is, if it is one. */ + localCommand(prompt: ContentBlock[]): McpCommand | undefined { + const [first] = prompt; + if (prompt.length !== 1 || first?.type !== "text") return undefined; + return parseMcpCommand(first.text) ?? undefined; + } + + /** + * The text that replaces Claude Code's `output` of `command`, or `output` + * when the adapter cannot build it (a status read failed, or `signal` + * aborted). + */ + async replace( + query: Parameters[0], + command: McpCommand, + output: string, + signal: AbortSignal, + logError: (message: string) => void, + ): Promise { + const { markdown } = await runMcpCommand(query, command, output, signal, logError); + return markdown ?? output; + } + + private render(commands: SlashCommand[]): v2.AvailableCommand[] { + return v2AvailableCommands( + getAvailableSlashCommands(commands, this.terminalCommands, undefined, undefined, this.query), + ); + } +} diff --git a/src/v2/config.ts b/src/v2/config.ts new file mode 100644 index 00000000..a68ae1bc --- /dev/null +++ b/src/v2/config.ts @@ -0,0 +1,696 @@ +/** + * The config options of a v2 session: its permission mode, model, + * effort, Fast mode, ultracode and main-thread agent. Claude Code owns them + * all. The session sets each on the query when the client picks a value, and + * follows the changes it learns of (an approved plan's mode, a model switch + * that the adapter did not make, the Fast mode state that Claude Code + * reports). + * + * The options are built and changed by the v1 core's rules, from the shared + * modules (`session-config-options.ts`, `session-effort.ts`, + * `session-fast-mode.ts`, `session-ultracode.ts`, `session-model.ts`, + * `session-mode.ts`). Fast mode and ultracode are boolean options: every v2 + * client takes them. For an AIR client that declares `recommendedValue`, the + * model and effort options carry no `default` row and name a recommendation + * (`_meta.jetbrains.air.recommendedValue`), and the effort they show is set + * on the query at the start and at each model switch, as on v1. + * + * The mode follows `SessionModeManager`: a model whose `ModelInfo` does not + * report `supportsAutoMode` runs `auto` as `acceptEdits`, with a notice, + * whether the client picks `auto`, an answer to ExitPlanMode does, or a model + * switch leaves the session in it. + * + * The changes of one session run one at a time, in the order they came, as + * the v1 core's `serializeConfigurationChange` runs them: a change awaits + * Claude Code between its steps, and a model switch rebuilds every option at + * its end, so a change that ran meanwhile would be lost. + */ +import { + RequestError, + type SessionConfigOption, + type SessionModeState, + type SessionNotification, +} from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { + AgentInfo, + FastModeDisabledReason, + FastModeState, + HookCallbackMatcher, + ModelInfo, + PermissionMode, + PermissionResult, + Query, + Settings, +} from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import type { SubagentModels } from "../native-subagents.js"; +import { DEFAULT_AGENT_ID } from "../session-config-ids.js"; +import { + AGENT_CONFIG_ID, + buildConfigOptions, + configOptionValue, + discoverCustomAgents, +} from "../session-config-options.js"; +import { + EFFORT_CONFIG_ID, + effortFlagSettings, + effortForModelSwitch, + settleEffortAfterModelSwitch, + settingsEffortForModel, + type EffortState, +} from "../session-effort.js"; +import { + createFastModeConfigOption, + FAST_MODE_CONFIG_ID, + fastModeStateChange, + fastModeStateEnabled, + normalizeFastModeDisabledReason, +} from "../session-fast-mode.js"; +import { + MODEL_CONFIG_ID, + modelDisplayName, + modelEntryFor, + resolveModelPreference, + type SessionModelState, +} from "../session-model.js"; +import { + MODE_CONFIG_ID, + SessionModeManager, + type ModeAvailability, + type SessionMode, +} from "../session-mode.js"; +import { noticeUpdate } from "../session-notices.js"; +import { + createUltracodeConfigOption, + readUltracodeSettings, + ultracodeSupported, + ultracodeWorkflowsEnabled, +} from "../session-ultracode.js"; +import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "./session-requests.js"; + +type ConfigQuery = Pick< + Query, + "setPermissionMode" | "setModel" | "applyFlagSettings" | "supportedAgents" +>; + +/** Every v2 client takes boolean config options. */ +const BOOLEAN_OPTIONS = true; + +/** The session's options and what they are built from, as `SessionModeManager` reads and updates them. */ +type ConfigSession = SessionMode & { + query: ConfigQuery; + models: SessionModelState; + /** The options as the client has them, in v1 form. */ + configOptions: SessionConfigOption[]; +}; + +/** What a session starts with besides its mode and models (see {@link SessionConfig.start}). */ +export interface ConfigSetup { + /** The `fast_mode_state` and `fast_mode_disabled_reason` of the query's initialization. */ + fastMode: { state?: FastModeState; disabledReason?: FastModeDisabledReason }; + /** The client's `_meta.claudeCode.options`: its `effort` and `agent`. */ + client: { effort?: string; agent?: string } | undefined; + /** The settings that effort resolves from (`mergeEffortSettings`), read at each model switch. */ + effortSettings(): Settings; + /** The client declared AIR's `recommendedValue` (see the module doc). */ + recommendedValue: boolean; +} + +export class SessionConfig { + private readonly session: ConfigSession; + private readonly rules: SessionModeManager; + private readonly agents: AgentInfo[]; + private currentAgent: string; + /** Whether the client picked the agent (`session/set_config_option`), which a restart restores. */ + private agentSelected = false; + private effort: EffortState; + private fastModeEnabled: boolean; + private fastModeDisabledReason: FastModeDisabledReason | undefined; + private ultracodeEnabled: boolean; + private readonly ultracodeWorkflowsEnabled: boolean | undefined; + /** The end of the last change (see the module doc). */ + private changes: Promise = Promise.resolve(); + + private constructor( + private readonly sessionId: string, + query: ConfigQuery, + modes: SessionModeState, + models: SessionModelState, + /** What Claude Code reports of each model: its descriptions and capabilities. */ + private readonly modelInfos: ModelInfo[], + state: { + agents: AgentInfo[]; + currentAgent: string; + effort: EffortState; + fastModeEnabled: boolean; + fastModeDisabledReason: FastModeDisabledReason | undefined; + ultracodeEnabled: boolean; + ultracodeWorkflowsEnabled: boolean | undefined; + configOptions: SessionConfigOption[]; + }, + private readonly effortSettings: () => Settings, + /** The client declared AIR's `recommendedValue` (see the module doc). */ + private readonly recommendedValue: boolean, + /** Sends an update of the session, such as the Auto mode fallback's notice. */ + private readonly notify: (notification: SessionNotification) => void, + private readonly logger: Logger, + autoModeFallbackWarningPending: boolean, + ) { + this.session = { + query, + modes, + models, + modelInfos, + configOptions: state.configOptions, + autoModeFallbackWarningPending, + }; + this.agents = state.agents; + this.currentAgent = state.currentAgent; + this.effort = state.effort; + this.fastModeEnabled = state.fastModeEnabled; + this.fastModeDisabledReason = state.fastModeDisabledReason; + this.ultracodeEnabled = state.ultracodeEnabled; + this.ultracodeWorkflowsEnabled = state.ultracodeWorkflowsEnabled; + this.rules = new SessionModeManager({ + getSession: () => this.session, + sessionEndedMessage: "The session ended", + updateConfigOption: async () => {}, + sessionUpdate: async (notification) => notify(notification), + // Every v2 client takes `notice` updates. + supportsNotices: () => true, + logError: (...args) => logger.error(...args), + }); + } + + /** + * The config of a session that starts in `mode`, as the v1 core starts one + * (`ClaudeAcpAgent.createSession`): a mode the model cannot serve falls + * back, and the fallback's notice goes out with the first prompt. The + * custom agents are discovered, Fast mode follows the reported state, and + * ultracode follows Claude Code's `get_settings` report, or the settings + * without one. The client's `effort` pins the effort when the model offers + * it. + */ + static async start( + sessionId: string, + query: ConfigQuery & Query, + mode: { mode: PermissionMode; builtInDefault: boolean } & ModeAvailability, + models: SessionModelState, + modelInfos: ModelInfo[], + currentModelInfo: ModelInfo | undefined, + setup: ConfigSetup, + notify: (notification: SessionNotification) => void, + logger: Logger, + ): Promise { + const rules = new SessionModeManager({ + getSession: () => undefined, + sessionEndedMessage: "", + updateConfigOption: async () => {}, + sessionUpdate: async () => {}, + logError: (...args) => logger.error(...args), + }); + const { modes, autoModeFallbackWarningPending } = await rules.initialize({ + query, + requestedMode: mode.mode, + builtInDefault: mode.builtInDefault, + currentModelInfo, + currentModelId: models.currentModelId, + allowBypass: mode.allowBypass, + allowAuto: mode.allowAuto, + }); + const agents = await discoverCustomAgents(query); + // Only a listed agent is selected: another would leave the option's + // value outside its own entries. + const requestedAgent = setup.client?.agent; + const currentAgent = + requestedAgent && agents.some((agent) => agent.name === requestedAgent) + ? requestedAgent + : DEFAULT_AGENT_ID; + const fastModeEnabled = + setup.fastMode.state !== undefined && fastModeStateEnabled(setup.fastMode.state); + const fastModeDisabledReason = fastModeEnabled + ? undefined + : normalizeFastModeDisabledReason(setup.fastMode.disabledReason); + const settings = setup.effortSettings(); + const ultracodeReport = await readUltracodeSettings(query); + const ultracodeEnabled = ultracodeReport?.requested ?? settings.ultracode === true; + const workflowsEnabled = ultracodeWorkflowsEnabled(ultracodeReport, currentModelInfo); + const clientEffort = setup.client?.effort; + const configOptions = buildConfigOptions( + modes, + models, + modelInfos, + clientEffort ?? settingsEffortForModel(settings, currentModelInfo), + agents, + currentAgent, + { + supported: currentModelInfo?.supportsFastMode ?? false, + enabled: fastModeEnabled, + useBooleanOption: BOOLEAN_OPTIONS, + disabledReason: fastModeDisabledReason, + }, + { useRecommendedValue: setup.recommendedValue }, + { + supported: + ultracodeReport?.available ?? ultracodeSupported(workflowsEnabled, currentModelInfo), + enabled: ultracodeEnabled, + useBooleanOption: BOOLEAN_OPTIONS, + }, + ); + const shownEffort = configOptions.find( + (option) => option.id === EFFORT_CONFIG_ID, + )?.currentValue; + // The client's effort is a pin when the model offers it. + const pinned = + clientEffort !== undefined && shownEffort === clientEffort ? clientEffort : undefined; + // With `recommendedValue`, the shown effort is a concrete level, which + // Claude Code's own default need not be: it is set on the query, as on + // v1. A failure fails the session's start, as on v1. + const applied = + setup.recommendedValue && typeof shownEffort === "string" ? shownEffort : pinned; + if (setup.recommendedValue && typeof shownEffort === "string") { + await query.applyFlagSettings(effortFlagSettings(shownEffort, ultracodeEnabled)); + } + return new SessionConfig( + sessionId, + query, + modes, + models, + modelInfos, + { + agents, + currentAgent, + effort: { pinned, applied }, + fastModeEnabled, + fastModeDisabledReason, + ultracodeEnabled, + ultracodeWorkflowsEnabled: workflowsEnabled, + configOptions, + }, + setup.effortSettings, + setup.recommendedValue, + notify, + logger, + autoModeFallbackWarningPending, + ); + } + + /** The options, in v2 form. */ + options(): v2.SessionConfigOption[] { + return v2ConfigOptions(this.session.configOptions); + } + + /** The entries of the `model` option, for the `model` of a subagent's session. */ + subagentModels(): SubagentModels { + return { + entry: (model) => modelEntryFor(this.session.configOptions, this.modelInfos, model), + name: (model) => modelDisplayName(this.modelInfos, model), + }; + } + + /** The current model, and what Claude Code reports of it. */ + currentModel(): { id: string; info: ModelInfo | undefined } { + const id = this.session.models.currentModelId; + return { id, info: this.modelInfos.find((model) => model.value === id) }; + } + + availableModeIds(): string[] { + return this.session.modes.availableModes.map((mode) => mode.id); + } + + /** The session's modes, for the name of a subagent's mode. */ + availableModes(): SessionModeState["availableModes"] { + return this.session.modes.availableModes; + } + + /** The mode the session left when it entered plan mode, while it is in plan. */ + prePlanMode(): string | undefined { + return this.session.prePlanMode; + } + + /** A prompt goes out: the notice of a fallback at the session's start goes with the first. */ + async promptSent(): Promise { + if (this.session.autoModeFallbackWarningPending) { + await this.rules.publishFallbackWarning(this.sessionId, this.session); + } + } + + /** + * Sets the value of `params` on the query, and records it once Claude Code + * took it. The value is checked against the option as the session + * advertised it (`configOptionValue`): a select takes an `id` of its + * entries (or a model alias), a boolean takes a boolean. + */ + set(params: v2.SetSessionConfigOptionRequest): Promise { + return this.serialized(() => this.apply(params)); + } + + /** + * Sets the choices of `previous`, the config of the query that this one + * replaced (see `restarts.ts`), as the v1 core's `restoreSessionChoices` + * does: the model, then the mode, which the model decides, and its + * pre-plan mode, then ultracode, Fast mode, the effort pin (after + * ultracode, which its flag carries) and the agent the client picked. A + * value the new query refuses is logged. + */ + restoreFrom(previous: SessionConfig): Promise { + return this.serialized(async () => { + const restore = async (choice: string, apply: () => Promise) => { + try { + await apply(); + } catch (error) { + this.logger.error(`Could not restore ${choice} after the query restart:`, error); + } + }; + const select = (configId: string, value: string) => + restore(`${configId} "${value}"`, () => + this.apply({ sessionId: this.sessionId, configId, type: "id", value }), + ); + const model = previous.session.models.currentModelId; + if (this.session.models.currentModelId !== model) await select(MODEL_CONFIG_ID, model); + // After the model: the start falls back from `auto` on a model without Auto. + const mode = previous.session.modes.currentModeId; + if (this.session.modes.currentModeId !== mode) { + await select(MODE_CONFIG_ID, mode); + if (this.session.modes.currentModeId === mode) { + this.session.autoModeFallbackWarningPending = false; + } + } + if (this.session.modes.currentModeId === "plan") { + this.session.prePlanMode = previous.session.prePlanMode; + } + // The user's intent, not the option: a model without support hides it. + if (this.ultracodeEnabled !== previous.ultracodeEnabled) { + await restore("ultracode", () => this.setUltracode(previous.ultracodeEnabled)); + } + if (this.fastModeEnabled !== previous.fastModeEnabled) { + await restore("Fast mode", () => this.setFastMode(previous.fastModeEnabled)); + } + if (previous.effort.pinned !== undefined) { + await select(EFFORT_CONFIG_ID, previous.effort.pinned); + } + // Even when unchanged: Default must clear an agent that the setup's + // `_meta` picked. Default is valid without an `agent` option too. + if (previous.agentSelected) { + if (previous.currentAgent === DEFAULT_AGENT_ID) { + await restore("Default agent", () => this.selectAgent(DEFAULT_AGENT_ID)); + } else { + await select(AGENT_CONFIG_ID, previous.currentAgent); + } + } + }); + } + + private serialized(change: () => Promise): Promise { + const result = this.changes.then(change); + this.changes = result.then( + () => {}, + () => {}, + ); + return result; + } + + private async apply(params: v2.SetSessionConfigOptionRequest): Promise { + const request = v1SetSessionConfigOptionRequest(params); + const option = this.session.configOptions.find((entry) => entry.id === request.configId); + if (!option) { + throw RequestError.invalidParams( + { configId: request.configId }, + `Unknown config option: ${request.configId}`, + ); + } + const selected = configOptionValue(option, request, this.modelInfos); + if ("enabled" in selected) { + if (request.configId === FAST_MODE_CONFIG_ID) await this.setFastMode(selected.enabled); + else await this.setUltracode(selected.enabled); + return; + } + const value = selected.value; + switch (request.configId) { + case MODE_CONFIG_ID: + await this.applyMode(value as PermissionMode); + return; + case MODEL_CONFIG_ID: + await this.session.query.setModel(value); + await this.recordModel(value); + return; + case EFFORT_CONFIG_ID: + // Applied first, so a rejected control request leaves the option as it was. + await this.session.query.applyFlagSettings( + effortFlagSettings(value, this.ultracodeEnabled), + ); + this.setCurrentValue(EFFORT_CONFIG_ID, value); + // "Default" hands effort back to the CLI's per-model settings: it un-pins. + this.effort = + value !== "default" + ? { pinned: value, applied: value } + : { pinned: undefined, applied: undefined }; + return; + case AGENT_CONFIG_ID: + await this.selectAgent(value); + return; + default: + throw RequestError.invalidParams( + { configId: request.configId }, + `Unknown config option: ${request.configId}`, + ); + } + } + + /** + * Sets the mode `requested` on the query, and records it once Claude Code + * took it: `auto` that the model cannot serve runs as its fallback, with a + * notice. + */ + enterMode(requested: PermissionMode): Promise { + return this.serialized(() => this.applyMode(requested)); + } + + /** The mode that `requested` runs as on the current model (see the module doc). */ + effectiveMode(requested: PermissionMode): PermissionMode { + return this.rules.effectiveMode(this.session, requested); + } + + private async applyMode(requested: PermissionMode): Promise { + const mode = this.rules.effectiveMode(this.session, requested); + await this.session.query.setPermissionMode(mode); + this.rules.syncConfig(this.session, mode); + if (mode !== requested) await this.rules.publishFallbackWarning(this.sessionId, this.session); + } + + /** Claude Code switched the mode itself. Returns whether the option changed. */ + modeChanged(mode: PermissionMode): boolean { + if (this.session.modes.currentModeId === mode || !this.availableModeIds().includes(mode)) { + return false; + } + this.rules.syncConfig(this.session, mode); + return true; + } + + /** + * Claude Code switched the model without the adapter: a typed `/model`, or + * a refusal fallback. Returns whether the options changed. A model that the + * option does not list is recorded by its id, which the option then lists. + */ + modelSwitched(model: string): Promise { + return this.serialized(async () => { + const value = resolveModelPreference(this.modelInfos, model)?.value ?? model; + if (this.session.models.currentModelId === value) return false; + await this.recordModel(value); + return true; + }); + } + + /** + * Claude Code reported a Fast mode state (`system`/init, a result). Returns + * whether the option changed. Without a Fast mode option the model does not + * support it, and the state says nothing of the user's intent, which stays + * for a model that does (see `fastModeStateChange`). + */ + fastModeReported( + state: FastModeState | undefined, + reason: FastModeDisabledReason | undefined, + ): boolean { + if (!this.session.configOptions.some((option) => option.id === FAST_MODE_CONFIG_ID)) { + return false; + } + const change = fastModeStateChange( + { enabled: this.fastModeEnabled, disabledReason: this.fastModeDisabledReason }, + state, + reason, + ); + if (!change) return false; + this.fastModeEnabled = change.enabled; + this.fastModeDisabledReason = change.disabledReason; + this.refreshFastModeOption(); + if (change.notice) { + this.notify({ sessionId: this.sessionId, update: noticeUpdate(change.notice.notice) }); + } + return true; + } + + /** + * An allowed permission answer, with an `auto` mode that the model cannot + * serve set as its fallback (see the module doc). + */ + async permissionAllowed(result: PermissionResult): Promise { + const { permissionResult, fallbackApplied } = this.rules.applyPermissionFallback( + this.session, + result, + ); + if (fallbackApplied) await this.rules.publishFallbackWarning(this.sessionId, this.session); + return permissionResult; + } + + /** + * `null` clears the flag layer back to the standard Claude Code agent. The + * change takes effect on the next turn. Applied first, so a rejected + * control request leaves the option as it was. + */ + private async selectAgent(value: string): Promise { + await this.session.query.applyFlagSettings({ + agent: value === DEFAULT_AGENT_ID ? null : value, + }); + this.currentAgent = value; + this.agentSelected = true; + this.setCurrentValue(AGENT_CONFIG_ID, value); + } + + /** Applied first, so a rejected control request leaves the option as it was. */ + private async setFastMode(enabled: boolean): Promise { + await this.session.query.applyFlagSettings({ fastMode: enabled }); + this.fastModeEnabled = enabled; + this.refreshFastModeOption(); + } + + /** + * An explicit boolean leaves the effort level alone (`null` would reset the + * flag layer). Applied first, so a rejected control request leaves the + * option as it was. + */ + private async setUltracode(enabled: boolean): Promise { + await this.session.query.applyFlagSettings({ ultracode: enabled }); + this.ultracodeEnabled = enabled; + this.replaceOption(createUltracodeConfigOption(enabled, BOOLEAN_OPTIONS)); + } + + private refreshFastModeOption(): void { + this.replaceOption( + createFastModeConfigOption( + this.fastModeEnabled, + BOOLEAN_OPTIONS, + this.fastModeDisabledReason, + ), + ); + } + + private replaceOption(option: SessionConfigOption): void { + this.session.configOptions = this.session.configOptions.map((entry) => + entry.id === option.id ? option : entry, + ); + } + + private setCurrentValue(configId: string, value: string): void { + this.session.configOptions = this.session.configOptions.map((entry) => + entry.id === configId && typeof entry.currentValue === "string" + ? { ...entry, currentValue: value } + : entry, + ); + } + + /** + * Records the model `value`, as the v1 core does (`applyConfigOptionValue`): + * an `auto` mode it cannot serve falls back, and the options are rebuilt + * for it. Effort follows `effortForModelSwitch`; Fast mode and ultracode + * show only on a model that supports them, with the user's retained + * choice. + */ + private async recordModel(value: string): Promise { + const info = this.modelInfos.find((model) => model.value === value); + this.session.models = { ...this.session.models, currentModelId: value }; + const modeDowngraded = await this.rules.reconcileForModel(this.session, info); + // `model_not_allowed` described the model the session left. The other + // reasons are account- or environment-scoped and stay true. + if (this.fastModeDisabledReason === "model_not_allowed") { + this.fastModeDisabledReason = undefined; + } + const displayed = this.session.configOptions.find( + (option) => option.id === EFFORT_CONFIG_ID, + )?.currentValue; + const effortSwitch = effortForModelSwitch({ + state: this.effort, + displayed: typeof displayed === "string" ? displayed : undefined, + newModelInfo: info, + modelId: value, + settings: this.effortSettings(), + useRecommendedValue: this.recommendedValue, + }); + const options = buildConfigOptions( + this.session.modes, + this.session.models, + this.modelInfos, + effortSwitch.seed, + this.agents, + this.currentAgent, + { + supported: info?.supportsFastMode ?? false, + enabled: this.fastModeEnabled, + useBooleanOption: BOOLEAN_OPTIONS, + disabledReason: this.fastModeDisabledReason, + }, + { useRecommendedValue: this.recommendedValue }, + { + supported: ultracodeSupported(this.ultracodeWorkflowsEnabled, info), + enabled: this.ultracodeEnabled, + useBooleanOption: BOOLEAN_OPTIONS, + }, + ); + const effort = await settleEffortAfterModelSwitch( + effortSwitch, + options.find((option) => option.id === EFFORT_CONFIG_ID), + this.ultracodeEnabled, + this.recommendedValue, + (settings) => this.session.query.applyFlagSettings(settings), + ); + this.effort = effort.state; + this.session.configOptions = options.flatMap((option) => + option.id !== EFFORT_CONFIG_ID ? [option] : effort.option ? [effort.option] : [], + ); + // The model switch already happened: a failed effort sync only logs. + if ("error" in effort) { + this.logger.error( + `Session ${this.sessionId}: failed to synchronize effort after the switch to ${value}`, + effort.error, + ); + } + if (modeDowngraded) await this.rules.publishFallbackWarning(this.sessionId, this.session); + } +} + +/** + * The PostModelSwitch hook: a switch of the root's model that the adapter + * did not make (`source` other than `sdk`, its own `setModel`, and `resume`, + * which the setup read already) updates the `model` option. A subagent's + * switch (`agent_id`) is not the session's. + */ +export function modelSwitchHook(switched: (model: string) => void): HookCallbackMatcher { + return { + hooks: [ + async (input) => { + if ( + input.hook_event_name === "PostModelSwitch" && + !input.agent_id && + input.source !== "sdk" && + input.source !== "resume" + ) { + // After the hook's answer: Claude Code waits for it, and the + // session's control requests share its channel. + const model = input.to_model; + setImmediate(() => switched(model)); + } + return { continue: true }; + }, + ], + }; +} diff --git a/src/v2/elicitation.ts b/src/v2/elicitation.ts new file mode 100644 index 00000000..6f0a8aa9 --- /dev/null +++ b/src/v2/elicitation.ts @@ -0,0 +1,232 @@ +/** + * Claude Code's requests for user input that are not permission checks, as + * ACP v2 elicitations of one session: + * + * - AskUserQuestion: a form, on the session of the agent that asks (the root + * or a subagent's child session), with the question's `toolCallId`. + * - An MCP server's elicitation (`onElicitation`): a form or a URL. A mode + * that the client did not advertise is answered `cancel`. + * - The refusal fallback's consent prompt (`onUserDialog`): a form. + * - MCP OAuth: a URL, sent after the setup response (see `mcp-oauth.ts`). + * + * The first three are open requests of their session while they wait + * (`requires_action`), and a cancel or close withdraws them with + * `$/cancel_request`, as it does a permission request. An MCP server's URL + * request that the user accepts stays open until Claude Code reports it + * complete: `accept` only consents to open the URL, and the work waits for + * the user to finish there. Building the elicitations and reading the + * answers is shared with v1 (`elicitation.ts`). + */ +import type { + CompleteElicitationNotification, + CreateElicitationRequest, + CreateElicitationResponse, +} from "@agentclientprotocol/sdk/unstable"; +import type { CanUseTool, Options, Query } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import { + answerAskUserQuestion, + answerMcpElicitation, + answerUserDialog, + elicitUnlessWithdrawn, + REFUSAL_FALLBACK_DIALOG_KIND, + type Elicit, + type ElicitationSupport, +} from "../elicitation.js"; +import { startMcpServerAuthentication } from "../mcp-oauth.js"; +import type { RequestTarget } from "./tasks.js"; + +/** What the elicitations need of the connection and the session. */ +export interface ElicitationHost { + readonly sessionId: string; + readonly logger: Logger; + /** The modes that the client advertised in `initialize`. */ + readonly support: ElicitationSupport; + /** The client is JetBrains AIR: AskUserQuestion's "Other" fields carry AIR's custom answer marker. */ + readonly airClient: boolean; + createElicitation( + request: CreateElicitationRequest, + signal: AbortSignal, + ): Promise; + completeElicitation(params: CompleteElicitationNotification): Promise; + /** The session of the agent `agentId` (see `V2Session.target`). */ + target(agentId: string | undefined): Promise; + /** The session's query, once it started. */ + query(): Query | undefined; +} + +export class SessionElicitations { + /** Whether Claude Code takes `annotations[question].unanswered` (`ask_user_question_unanswered_v1`). */ + private reportUnanswered = false; + /** + * Aborted by `session/close`: stops the MCP OAuth runs. A query that ended + * on its own fails their next `mcpServerStatus`, which ends them too. + */ + private readonly closed = new AbortController(); + /** The accepted URL requests that wait for their `elicitation_complete`, by elicitation id. */ + private readonly awaitingCompletion = new Map void>>(); + /** The elicitation ids that Claude Code reported complete in the open turn. */ + private readonly completedIds = new Set(); + + constructor(private readonly host: ElicitationHost) {} + + /** + * The query options: the elicitation callbacks for the modes the client + * renders. Without form elicitation AskUserQuestion is disallowed, and the + * refusal fallback is not declared, so Claude Code ends the turn with the + * refusal instead. + */ + queryOptions(): Pick< + Options, + "onElicitation" | "onUserDialog" | "supportedDialogKinds" | "disallowedTools" + > { + const { support, sessionId, logger } = this.host; + return { + ...(support.form || support.url + ? { + onElicitation: async (request, { signal }) => + answerMcpElicitation(request, { + sessionId, + support, + logger, + elicit: this.elicit(await this.host.target(undefined), signal, true), + }), + } + : {}), + ...(support.form + ? { + onUserDialog: async (request, { signal }) => + answerUserDialog(request, { + sessionId, + logger, + elicit: this.elicit(await this.host.target(undefined), signal), + }), + supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND], + } + : { disallowedTools: ["AskUserQuestion"] }), + }; + } + + /** `canUseTool` that asks AskUserQuestion's questions, and leaves the other tools to `permissions`. */ + canUseTool(permissions: CanUseTool): CanUseTool { + return async (toolName, toolInput, options) => { + if (toolName !== "AskUserQuestion" || !this.host.support.form) { + return permissions(toolName, toolInput, options); + } + const target = await this.host.target(options.agentID); + return answerAskUserQuestion(toolInput, { + sessionId: target.sessionId, + toolUseID: options.toolUseID, + airClient: this.host.airClient, + reportUnanswered: () => this.reportUnanswered, + logger: this.host.logger, + elicit: this.elicit(target, options.signal), + }); + }; + } + + /** A `system/init` of the session: it lists Claude Code's capabilities. */ + init(capabilities: readonly string[] | undefined): void { + if (capabilities?.includes("ask_user_question_unanswered_v1")) this.reportUnanswered = true; + } + + /** Claude Code's `elicitation_complete`: a URL elicitation of an MCP server finished. */ + completed(elicitationId: string): void { + this.completedIds.add(elicitationId); + for (const release of this.awaitingCompletion.get(elicitationId) ?? []) release(); + if (!this.host.support.url || this.closed.signal.aborted) return; + this.host.completeElicitation({ elicitationId }).catch((error) => { + this.host.logger.error(`Failed to complete elicitation: ${error}`); + }); + } + + /** + * Starts OAuth in the background for the servers of `serverNames` that + * Claude Code reports as `needs-auth`. Call it only once the setup + * response is sent: v2 asks for no elicitation before it. + */ + authenticateMcpServers(serverNames: readonly string[]): void { + startMcpServerAuthentication( + { + urlElicitation: this.host.support.url, + createElicitation: (request, signal) => this.host.createElicitation(request, signal), + completeElicitation: (params) => this.host.completeElicitation(params), + logger: this.host.logger, + }, + { + key: this, + sessionId: this.host.sessionId, + currentQuery: () => (this.closed.signal.aborted ? undefined : this.host.query()), + closed: this.closed.signal, + }, + new Set(serverNames), + ); + } + + /** The session closes: the OAuth runs stop, and withdraw their elicitations. */ + close(): void { + this.closed.abort(); + } + + /** + * The open turn ended: its accepted URL requests no longer hold it. A + * server that never reports its completion holds only its turn. + */ + turnEnded(): void { + for (const releases of [...this.awaitingCompletion.values()]) { + for (const release of [...releases]) release(); + } + this.completedIds.clear(); + } + + /** + * Sends an elicitation as an open request of `target`. With + * `holdAcceptedUrl`, an accepted URL request then stays open (see the + * module doc). + */ + private elicit(target: RequestTarget, toolSignal: AbortSignal, holdAcceptedUrl = false): Elicit { + return (request) => + target.openRequest(toolSignal, async (signal) => { + const outcome = await elicitUnlessWithdrawn(signal, (signal) => + this.host.createElicitation(request, signal), + ); + const elicitationId = (request as { elicitationId?: unknown }).elicitationId; + if ( + holdAcceptedUrl && + request.mode === "url" && + typeof elicitationId === "string" && + "response" in outcome && + outcome.response.action === "accept" + ) { + // Opened before this request settles, so the state stays + // `requires_action`. Claude Code gets the answer at once: the MCP + // server waits for the user's interaction itself. + void target.openRequest(toolSignal, (signal) => + this.untilCompleted(elicitationId, signal), + ); + } + return outcome; + }); + } + + /** + * Resolves once Claude Code reports the elicitation `elicitationId` + * complete, `signal` aborts (the tool use ended, or a cancel or close + * withdrew it), or the turn ends. + */ + private untilCompleted(elicitationId: string, signal: AbortSignal): Promise { + if (this.completedIds.has(elicitationId) || signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const releases = this.awaitingCompletion.get(elicitationId) ?? new Set<() => void>(); + this.awaitingCompletion.set(elicitationId, releases); + const release = () => { + signal.removeEventListener("abort", release); + releases.delete(release); + if (releases.size === 0) this.awaitingCompletion.delete(elicitationId); + resolve(); + }; + signal.addEventListener("abort", release, { once: true }); + releases.add(release); + }); + } +} diff --git a/src/v2/file-changes.ts b/src/v2/file-changes.ts new file mode 100644 index 00000000..8e6e7f80 --- /dev/null +++ b/src/v2/file-changes.ts @@ -0,0 +1,134 @@ +/** + * AIR's agent file-change report (`agentFileChangeReport`) on a v2 + * session: a `session/prompt` asks for it in `_meta.jetbrains.air`, and the + * turn that serves the prompt ends with one `session_info_update` that lists + * the files it changed. + * + * v1 sends the report before the prompt's response, which comes at the + * turn's end. A v2 prompt is answered at its insertion, so the report goes + * out at the end of the turn that the prompt started or joined, before the + * turn's idle. Building the report is shared with v1 (`file-change-audit.ts`): + * a dry-run rewind of the prompt's checkpoint, and the file-tool edits of + * subagents, which no checkpoint holds. + */ +import type { Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { RequestError } from "@agentclientprotocol/sdk/unstable"; +import { + createNativeFileChangeReporter, + recordSubagentFileEdits, + type FileChangeReportUnavailableReason, + type NativeFileChangeReporter, + type NativeFileChangeReportTurn, +} from "../file-change-audit.js"; + +type ToolUses = Record; + +export class TurnFileChanges { + /** The reports of the prompts that are not inserted yet, by prompt uuid. */ + private readonly pending = new Map(); + /** The reports of the prompts that the open turn serves. */ + private readonly turn: NativeFileChangeReportTurn[] = []; + /** The tool uses of subagents in the open turn, for their file edits. */ + private subagentToolUses: ToolUses = {}; + + private constructor(private readonly reporter: NativeFileChangeReporter) {} + + /** + * The reports of a session, or undefined when the client did not declare + * `agentFileChangeReport`. `publish` sends one report's `_meta`. + */ + static create( + supported: boolean, + options: Parameters[0], + ): TurnFileChanges | undefined { + return supported ? new TurnFileChanges(createNativeFileChangeReporter(options)) : undefined; + } + + /** A `session/prompt` came: its `_meta` may ask for a report. */ + requested(promptUuid: string, meta: unknown): void { + const fileChangeReport = this.reporter.request(meta); + if (fileChangeReport) this.pending.set(promptUuid, { promptUuid, fileChangeReport }); + } + + /** The prompt failed before Claude Code inserted it: -32800 for a cancel, or another error. */ + promptFailed(promptUuid: string, error: unknown): void { + const report = this.pending.get(promptUuid); + if (!report) return; + this.pending.delete(promptUuid); + const cancelled = + error instanceof RequestError && error.code === RequestError.requestCancelled().code; + this.reporter.finish(report.fileChangeReport, cancelled ? "cancelled" : "providerError"); + } + + /** Claude Code inserted the prompt: the open turn serves it. */ + promptStarted(promptUuid: string): void { + const report = this.pending.get(promptUuid); + if (!report) return; + this.pending.delete(promptUuid); + this.turn.push(report); + } + + /** + * The open turn goes on in a fresh context (`clear-context.ts`), with the + * plan's prompt `planUuid`. `/clear` reset Claude Code's file history, so + * the checkpoints of the turn's own prompts are gone: its reports read the + * plan prompt's checkpoint. v1 sends the plan with the turn's prompt uuid + * instead (`clear-context-coordinator.ts`). + */ + contextCleared(planUuid: string): void { + for (const report of this.turn) report.promptUuid = planUuid; + } + + /** A message of the query: a subagent's file-tool edits go into the open turn's reports. */ + message(message: SDKMessage): void { + if (this.turn.length === 0) return; + if (message.type !== "assistant" && message.type !== "user") return; + const parent = message.parent_tool_use_id; + if (parent === null) return; + const content = message.message.content; + if (!Array.isArray(content)) return; + if (message.type === "assistant") { + for (const block of content) { + if (block.type === "tool_use") { + this.subagentToolUses[block.id] = { name: block.name, input: block.input }; + } + } + return; + } + for (const report of this.turn) { + recordSubagentFileEdits(report, parent, content, this.subagentToolUses); + } + } + + /** + * The open turn ends with `stopReason`. A turn that ended normally reads + * each report from its prompt's checkpoint, which takes up to + * `AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS`: the returned promise resolves once + * every report went out, and the turn's idle waits for it. A cancelled or + * failed turn sends its reports `unavailable` at once. + */ + turnEnding( + stopReason: string, + query: Pick | undefined, + ): Promise | undefined { + if (this.turn.length === 0) return undefined; + if (stopReason === "cancelled" || stopReason === "error" || !query) { + this.turnClosed(stopReason === "cancelled"); + return undefined; + } + const reports = this.turn.splice(0); + this.subagentToolUses = {}; + return Promise.all(reports.map((report) => this.reporter.report(report, query))).then(() => {}); + } + + /** + * The open turn closed. The reports that {@link turnEnding} did not read, + * such as of a turn that ended without a result, go out `unavailable`: + * `cancelled` after a cancel, else `providerError`. + */ + turnClosed(cancelled: boolean): void { + const reason: FileChangeReportUnavailableReason = cancelled ? "cancelled" : "providerError"; + for (const report of this.turn.splice(0)) this.reporter.finish(report.fileChangeReport, reason); + this.subagentToolUses = {}; + } +} diff --git a/src/v2/file-preview.ts b/src/v2/file-preview.ts new file mode 100644 index 00000000..fbe1def2 --- /dev/null +++ b/src/v2/file-preview.ts @@ -0,0 +1,42 @@ +/** + * The PreToolUse hook that shows an Edit's or Write's exact patch before the + * tool runs, also when no permission request shows it. + * + * A v2 diff cannot hold the input's snippet, so without it an auto-approved + * Edit would show its change only once it is done. Claude Code runs the tool + * only after the hook returns, so the file is still as it was, and the patch + * is the one a permission request for the tool would show. A patch whose tool + * call is not published yet waits for it (`SessionOutput.preview`). The hook + * makes no permission decision, and a failure only leaves the preview out. + */ +import type { HookCallbackMatcher } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import { previewPatchContent } from "../diff.js"; +import type { PermissionHost } from "./permissions.js"; + +/** The session of a tool use (the root's, or a subagent's), and the tool uses that a cancel drops. */ +export type PreviewHost = Pick; + +export function fileChangePreviewHook(host: PreviewHost, logger: Logger): HookCallbackMatcher { + return { + matcher: "Edit|Write", + hooks: [ + async (input, toolUseID, { signal }) => { + if (input.hook_event_name !== "PreToolUse" || !toolUseID) return { continue: true }; + try { + // Work that the client was told never ran shows nothing, as in `canUseTool`. + if (await host.dropsToolUse(toolUseID, input.agent_id, signal)) return { continue: true }; + const target = await host.target(input.agent_id); + // The work a cancel stopped shows nothing, as in `canUseTool`. + if (target.cancelling()) return { continue: true }; + const toolInput = (input.tool_input ?? {}) as Record; + const content = await previewPatchContent(input.tool_name, toolInput, target.cwd, "v2"); + if (content) target.output.preview(toolUseID, content); + } catch (error) { + logger.error(`Failed to preview ${toolUseID}:`, error); + } + return { continue: true }; + }, + ], + }; +} diff --git a/src/v2/initialize.ts b/src/v2/initialize.ts index 076c7786..2595c529 100644 --- a/src/v2/initialize.ts +++ b/src/v2/initialize.ts @@ -1,138 +1,21 @@ /** - * The ACP v2 `initialize` handshake, expressed through the v1 handshake of - * `ClaudeAcpAgent`. - * - * The agent decides its capabilities and auth methods in v1 terms. The v2 - * request becomes the v1 request that the agent reads, and the v1 response of - * the agent becomes the v2 response, so both protocol versions advertise from - * one source. + * What the ACP v2 `initialize` response advertises: the session capabilities, + * and the v2 form of the auth methods, which the shared auth modules build in + * v1 terms (`auth-methods.ts`). */ -import { - PROTOCOL_VERSION as V1_PROTOCOL_VERSION, - type AuthMethod, - type InitializeRequest, - type InitializeResponse, -} from "@agentclientprotocol/sdk/unstable"; -import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; -import { AIR_ASYNC_TASKS_CAPABILITY, AIR_GOAL_KEY } from "../air-extension.js"; +import type { AuthMethod } from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import { SYSTEM_PROMPT_CAPABILITY } from "../client-system-prompt.js"; -/** The v1 `initialize` request that a v2 client means. */ -export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest { - const capabilities = request.capabilities; - return { - protocolVersion: V1_PROTOCOL_VERSION, - clientInfo: request.info, - clientCapabilities: { - // v2 has no client file system and no client terminals: the agent - // reads, writes, and runs commands itself. - fs: { readTextFile: false, writeTextFile: false }, - terminal: false, - // Every v2 client handles boolean config options, notices, and - // compaction updates; v1 asks for a marker. With them, the agent reports - // advisories as notices rather than as transcript messages, and a - // compaction as a compaction entity rather than as a "Compact - // conversation" tool call, also on replay. - session: { configOptions: { boolean: {} }, notices: {}, compaction: {} }, - // Every v2 client takes `plan_update`, with the draft's markdown and file - // plans, and `plan_removed`; v1 asks for the `plan` marker. With it, the - // agent shows an approved ExitPlanMode plan as a markdown plan. - plan: {}, - // Subagent updates are part of v2's baseline session model (the subagents - // RFD), so every v2 client takes them; v1 asks for the `subagents` - // capability. With it, the agent reports each Agent or Task subagent as a - // child session. - subagents: {}, - // v1 marks terminal auth support with a boolean, v2 with an object. - auth: { - terminal: capabilities?.auth?.terminal != null, - ...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}), - }, - ...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}), - ...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}), - }, - ...(request._meta != null ? { _meta: request._meta } : {}), - }; -} - /** - * The top-level `_meta` keys of the agent's v1 `initialize` response that - * advertise an extension the v2 surface does not serve yet: `steering`, for - * `_session/steering`. - */ -const UNSERVED_EXTENSION_KEYS: readonly string[] = ["steering"]; - -/** - * The v2 `initialize` response for the v1 response of the agent. - * - * It does not advertise what the v2 surface does not serve yet: the - * extensions of {@link UNSERVED_EXTENSION_KEYS}, and the AIR extensions of - * {@link withoutUnservedAirExtensions}. - */ -export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse { - if (!response.agentInfo) { - throw new Error("ACP v2 requires agentInfo, and the agent reported none"); - } - const meta = response.agentCapabilities?._meta; - const topMeta = withoutUnservedAirExtensions( - Object.fromEntries( - Object.entries(response._meta ?? {}).filter( - ([key]) => !UNSERVED_EXTENSION_KEYS.includes(key), - ), - ), - ); - return { - protocolVersion: v2.PROTOCOL_VERSION, - info: response.agentInfo, - // v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth - // methods must serve `auth/logout`. - capabilities: { - session: V2_SESSION_CAPABILITIES, - // `providers/list`, `set`, and `disable`, which the agent serves for - // every client. - providers: {}, - ...(meta != null ? { _meta: meta } : {}), - }, - authMethods: (response.authMethods ?? []).map(v2AuthMethod), - ...(Object.keys(topMeta).length > 0 ? { _meta: topMeta } : {}), - }; -} - -/** - * `meta` without the AIR extensions whose methods the v2 surface does not - * register: the `asyncTasks` capability (`_session/async_task/stop`) and the - * `goal` entry (`_session/goal`) of `_meta.jetbrains.air`. - */ -function withoutUnservedAirExtensions(meta: Record): Record { - const jetbrains = asRecord(meta.jetbrains); - const air = asRecord(jetbrains.air); - if (Object.keys(air).length === 0) return meta; - const served = Object.fromEntries(Object.entries(air).filter(([key]) => key !== AIR_GOAL_KEY)); - if (Array.isArray(served.capabilities)) { - served.capabilities = served.capabilities.filter( - (capability) => capability !== AIR_ASYNC_TASKS_CAPABILITY, - ); - } - return { ...meta, jetbrains: { ...jetbrains, air: served } }; -} - -function asRecord(value: unknown): Record { - return value && typeof value === "object" && !Array.isArray(value) - ? (value as Record) - : {}; -} - -/** - * The session capabilities of the v2 surface, which serves each through a v1 - * method of the agent. + * The session capabilities that the v2 core serves. * * - The session baseline: `session/new`, `list`, `resume` (with replay), * `close`, `prompt`, `cancel`, and `update`. * - `session/delete`, `session/fork` (which returns a session the client can * prompt at once), and `additionalDirectories`. - * - The prompt content and MCP transports that the agent's v1 `initialize` - * lists: images, embedded context, and HTTP servers. Stdio servers are in the - * v1 baseline; v2 has no SSE transport. + * - The prompt content and MCP transports that the v1 `initialize` lists too: + * images, embedded context, and HTTP servers. v2 has no SSE transport. * - `systemPrompt`, in `append` and `override` mode. */ export const V2_SESSION_CAPABILITIES: v2.SessionCapabilities = { @@ -145,7 +28,7 @@ export const V2_SESSION_CAPABILITIES: v2.SessionCapabilities = { }; /** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */ -function v2AuthMethod(method: AuthMethod): v2.AuthMethod { +export function v2AuthMethod(method: AuthMethod): v2.AuthMethod { if ("type" in method && method.type === "terminal") { const { id, env, ...terminal } = method; return { diff --git a/src/v2/outcome.ts b/src/v2/outcome.ts new file mode 100644 index 00000000..c8cd4eba --- /dev/null +++ b/src/v2/outcome.ts @@ -0,0 +1,219 @@ +/** + * How a turn of an ACP v2 session ends: the stop reason, usage, error + * and `_meta.quota` of its `idle`. Whether Claude Code's result failed the + * turn, and its stop reason or error text, follow the rules that the v1 core + * reads its results with too (`resultEnd`). The error is the one v1 answers a + * failed prompt with: an authentication failure is `authRequired` + * (-32000), and any other carries the assistant message's error kind + * (`data.errorKind`). + */ +import { RequestError } from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { + SDKAssistantMessage, + SDKAssistantMessageError, + SDKResultMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { AUTONOMOUS_RESULT_ORIGINS, isSyntheticLoginMessage } from "../acp-agent.js"; +import { resultEnd } from "../result-end.js"; +import { providerFailureCategory } from "../session-failure-extension.js"; +import { + getMatchingModelUsage, + modelUsageIncrement, + normalizeModelUsage, + quotaMeta, + resumedFirstResultModelUsage, + tallyTotal, + type AccumulatedUsage, + type ModelTokenTally, +} from "../session-usage.js"; + +/** A result that ended a turn, with what the turn spent. */ +export type TurnEnd = { + result: SDKResultMessage; + usage: v2.Usage; + _meta: ReturnType; +}; + +/** + * The idle members this core reports. Typed as those members, so the checker + * matches each stop reason to its own, which it cannot against the whole + * union with its custom and unknown members. + */ +export type IdleOutcome = Extract< + v2.IdleStateUpdate, + { + stopReason: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled" | "error"; + } +>; + +/** What a prompt's turn that ended without a result spent. */ +const NOTHING_SPENT: AccumulatedUsage = { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, +}; + +/** + * The stop reason, usage and `_meta.quota` of an idle, from the result that + * ended the turn, and what its assistant messages reported (`report`). + * `prompted`: a prompt started the turn. Without a result, such as when an + * interrupt stopped the work before its result, a prompt's turn reports zero + * usage and a zero quota, and a cycle that Claude Code started on its own + * reports neither. A failed turn reports its error instead. + */ +export function idleOutcome( + end: TurnEnd | undefined, + cancelled: boolean, + report?: TurnReport, + prompted = false, +): IdleOutcome { + const spent = end + ? { usage: end.usage, _meta: end._meta } + : prompted + ? { usage: { ...NOTHING_SPENT, totalTokens: 0 }, _meta: quotaMeta(NOTHING_SPENT, {}) } + : {}; + if (cancelled) return { stopReason: "cancelled", ...spent }; + // Claude Code's sign-in message fails the turn. + if (report?.loginRequired) return failed(RequestError.authRequired()); + if (!end) return { stopReason: "end_turn", ...spent }; + const { result } = end; + const ended = resultEnd(result); + if (!("error" in ended)) return { stopReason: ended.stopReason, ...spent }; + const kind = report?.errorKind; + // Only these subtypes read the assistant's error kind for the failure's + // category. + const authFailure = + ended.loginRequired || + ((result.subtype === "success" || result.subtype === "error_during_execution") && + providerFailureCategory(kind) === "auth_required"); + return failed( + authFailure + ? RequestError.authRequired() + : RequestError.internalError(kind ? { errorKind: kind } : undefined, ended.error), + ); +} + +function failed(error: RequestError): IdleOutcome { + return { + stopReason: "error", + error: { + code: error.code, + message: error.message, + ...(error.data !== undefined ? { data: error.data } : {}), + }, + }; +} + +/** + * What the assistant messages of the open turn reported about its end: the + * error kind of a failed API request, Claude Code's sign-in message, and a + * refusal's explanation. Read from the main conversation's messages. + */ +export class TurnReport { + errorKind: SDKAssistantMessageError | undefined; + /** Claude Code showed its sign-in message ("Not logged in · Please run /login"). */ + loginRequired = false; + /** The explanation of the turn's refusal. */ + refusalExplanation: string | undefined; + + /** + * An assistant message of the main conversation. Returns whether it is + * Claude Code's sign-in message, which the client is not shown: its + * `/login` instruction is for Claude Code's terminal, and the turn's idle + * fails with `authRequired` instead. + */ + assistant(message: SDKAssistantMessage): boolean { + if (message.error) this.errorKind = message.error; + if (message.message.stop_reason === "refusal") { + this.refusalExplanation = + message.message.stop_details?.explanation ?? this.refusalExplanation; + } + if (!isSyntheticLoginMessage(message.message)) return false; + this.loginRequired = true; + return true; + } + + /** + * Claude Code's `model_refusal_no_fallback`: the explanation when the + * refused message carried none (older CLIs, gateways that drop + * `stop_details`). One for a turn that no user prompt started + * (`refused_user_message_uuid: null`) is not this turn's. + */ + refusalWithoutFallback(message: { + api_refusal_explanation?: string | null; + refused_user_message_uuid?: string | null; + content: string; + }): void { + if (this.refusalExplanation || message.refused_user_message_uuid === null) return; + this.refusalExplanation = message.api_refusal_explanation ?? message.content; + } + + reset(): void { + this.errorKind = undefined; + this.loginRequired = false; + this.refusalExplanation = undefined; + } +} + +/** + * Whether `result` is a placeholder: Claude Code answers background-task + * notifications that queued up with one model call, and gives each but the + * last a result with `num_turns: 0` before that call runs. The cycle goes on + * to the last one's result. + */ +export function isPlaceholderResult(result: SDKResultMessage): boolean { + return ( + result.origin != null && + AUTONOMOUS_RESULT_ORIGINS.has(result.origin.kind) && + result.num_turns === 0 && + !result.is_error + ); +} + +/** + * What each result spent. `result.modelUsage` is a running total of the + * query, so a result's rows are its increment over the previous reading. A + * resumed query's first reading continues the totals of its transcript, so + * that result's row is its own `usage`, under the model of the last + * assistant message (see `resumedFirstResultModelUsage`). + */ +export class ResultUsage { + private reading: ModelTokenTally | undefined; + private model: string | null = null; + + constructor(resumed: boolean) { + this.reading = resumed ? undefined : {}; + } + + /** An assistant message of the main conversation, by `model`. */ + assistant(model: string | undefined): void { + if (model && model !== "") this.model = model; + } + + /** What `result` spent. Every result moves the reading on, also a placeholder. */ + record(result: SDKResultMessage): TurnEnd { + const tally: AccumulatedUsage = { + inputTokens: result.usage.input_tokens ?? 0, + outputTokens: result.usage.output_tokens ?? 0, + cachedReadTokens: result.usage.cache_read_input_tokens ?? 0, + cachedWriteTokens: result.usage.cache_creation_input_tokens ?? 0, + }; + const reading = normalizeModelUsage(result.modelUsage); + const key = + this.model === null + ? null + : (getMatchingModelUsage(result.modelUsage ?? {}, this.model)?.key ?? this.model); + const rows = + this.reading === undefined + ? resumedFirstResultModelUsage(result.usage, key) + : modelUsageIncrement(reading, this.reading); + this.reading = reading; + return { + result, + usage: { ...tally, totalTokens: tallyTotal(tally) }, + _meta: quotaMeta(tally, rows), + }; + } +} diff --git a/src/v2/permission.ts b/src/v2/permission.ts deleted file mode 100644 index 71352874..00000000 --- a/src/v2/permission.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * The v2 form of the permission requests that `ClaudeAcpAgent` sends, and the - * v1 form of their answers. - */ -import type { RequestPermissionResponse } from "@agentclientprotocol/sdk/unstable"; -import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; -import type { AcpPermissionRequest } from "../permissions/presentation.js"; -import { v2ToolCallPatch } from "./tool-call.js"; - -/** - * The v2 form of a permission request of the agent. - * - * v1 asks about a tool call, whose title the prompt shows. v2 gives the prompt - * its own `title` and `description`, which the agent sets for a v2 client - * (`AcpPermissionRequest`), and asks about a `subject`: here always the tool - * call, as the same patch that a `tool_call_update` carries. - */ -export function v2PermissionRequest(request: AcpPermissionRequest): v2.RequestPermissionRequest { - if (request.title === undefined) { - throw new Error("An ACP v2 permission request needs a title"); - } - return { - sessionId: request.sessionId, - title: request.title, - ...(request.description ? { description: request.description } : {}), - subject: { type: "tool_call", toolCall: v2ToolCallPatch(request.toolCall) }, - options: request.options, - ...(request._meta != null ? { _meta: request._meta } : {}), - }; -} - -/** - * The v1 form of the answer to a permission request. - * - * v2 adds outcomes that v1 does not know, which the draft says are no - * approval. They become `cancelled`. In v2, `cancelled` also means that the - * user dismissed the prompt or the client declined it by policy. So the agent - * reads a `cancelled` outcome on v2 as the user refusing the tool, unless a - * `session/cancel` is stopping the session's work: then the tool use is - * aborted, as on v1. - */ -export function v1PermissionResponse( - response: v2.RequestPermissionResponse, -): RequestPermissionResponse { - const outcome = response.outcome; - return { - outcome: - outcome.outcome === "selected" - ? (outcome as Extract) - : { outcome: "cancelled" }, - ...(response._meta != null ? { _meta: response._meta } : {}), - }; -} diff --git a/src/v2/permissions.ts b/src/v2/permissions.ts new file mode 100644 index 00000000..904bb59e --- /dev/null +++ b/src/v2/permissions.ts @@ -0,0 +1,275 @@ +/** + * Claude Code's permission checks (`canUseTool`), asked as ACP v2 permission + * requests. + * + * The request is built and its answer read by the same helpers as on v1 + * (`permissions/*`), so a v2 client gets the same options and the same + * effects of each. + */ +import type { RequestPermissionResponse, ToolCallUpdate } from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { CanUseTool, PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk"; +import { previewPatchContent } from "../diff.js"; +import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js"; +import { buildClaudePermissionOptions } from "../permissions/options.js"; +import { + airPermissionMeta, + buildClaudePermissionPresentation, + permissionToolCallMeta, + type AcpPermissionRequest, +} from "../permissions/presentation.js"; +import { decodeClaudePermissionResponse } from "../permissions/response.js"; +import { ClientCapabilities } from "../tool-calls/client-capabilities.js"; +import type { ClearContextReset } from "../clear-context-coordinator.js"; +import type { RequestTarget } from "./tasks.js"; +import { v2ToolCallPatch } from "./tool-call.js"; + +const V2_CAPABILITIES = ClientCapabilities.from(undefined, { v2: true }); + +/** What a permission check needs of its root session. */ +export interface PermissionHost { + /** + * The client is JetBrains AIR: a request carries the AIR permission + * presentation, and its tool call the AIR `_meta.claudeCode` (see + * `permissionToolCallMeta`). + */ + readonly airClient: boolean; + /** The ids of the modes that the session offers. */ + availableModes(): string[]; + /** + * The session that the request goes to: the root, or the child session of + * the subagent `agentId`. It counts the request as open while it is + * (`requires_action`). + */ + target(agentId: string | undefined): Promise; + /** The user picked a mode in the answer, such as ExitPlanMode's "auto-accept edits". */ + modeChanged(mode: PermissionMode): void; + /** The mode the session left when it entered plan mode, while it is in plan. */ + prePlanMode(): string | undefined; + /** An allowed answer, with a mode the model cannot serve set as its fallback (see `config.ts`). */ + permissionAllowed(result: PermissionResult): Promise; + /** + * Whether ExitPlanMode of the agent `agentId` offers to clear the context + * (see `ClearContext`), and how much of the context is in use. + */ + contextReset(agentId: string | undefined): { offered: boolean; usedPercent: number | undefined }; + /** + * An ExitPlanMode answer that interrupts the cycle: "No, keep planning", or + * a plan accepted with clearing the context (`reset`). + */ + exitPlanInterrupting(toolUseId: string, reset: ClearContextReset | undefined): void; + /** + * A permission request of `target` is about to show the tool call + * `toolCallId` of `toolName` (see `SessionTasks.requestShows`). + */ + requestShows(target: RequestTarget, toolName: string, toolCallId: string): void; + /** The subject's tool call, with the `_meta` that the client holds of it. */ + toolCallSubject(sessionId: string, toolCall: T): T; + /** + * Whether the tool use is of work that a cancel stopped before the client + * saw it (see `StoppedWork`): it is denied without a request (see + * {@link denyingStoppedWork}). + */ + dropsToolUse( + toolUseId: string, + agentId: string | undefined, + signal: AbortSignal, + ): Promise; +} + +/** + * Denies the tool uses of work that a cancel stopped before the client saw it + * (see `StoppedWork`), without a request, before `inner` asks the client + * anything: a permission request, or AskUserQuestion's questions. The client + * was told that this work never ran. + */ +export function denyingStoppedWork( + host: Pick, + inner: CanUseTool, +): CanUseTool { + return async (toolName, toolInput, options) => { + if (await host.dropsToolUse(options.toolUseID, options.agentID, options.signal)) { + return { behavior: "deny", message: "The user cancelled this prompt." }; + } + return inner(toolName, toolInput, options); + }; +} + +export function canUseTool(host: PermissionHost): CanUseTool { + return async ( + toolName, + toolInput, + { + signal, + suggestions, + toolUseID, + matchedAskRule, + blockedPath, + decisionReason, + title, + displayName, + description, + defaultToNo, + suppressAlwaysAllowRule, + agentID, + mcpServer, + }, + ) => { + // No persistent "always allow" when the user's own ask rule forced the + // prompt, or when Claude Code says the rule it would write grants more + // than this ask. + const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true; + const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, noPersistentRule); + // A v2 diff cannot hold an Edit's snippet: the request shows the exact patch. + const previewCwd = (await host.target(agentID)).cwd; + const previewContent = await previewPatchContent(toolName, toolInput, previewCwd, "v2"); + // After the last await: a subagent announced meanwhile gets the request + // on its own session. + const target = await host.target(agentID); + const presentation = buildClaudePermissionPresentation({ + toolName, + input: toolInput, + toolUseID, + cwd: target.cwd, + capabilities: V2_CAPABILITIES, + previewContent, + blockedPath, + title, + displayName, + description, + decisionReason, + defaultToNo, + }); + // The tool call keeps the v2 rendering that the stream gave it. An AIR + // client also gets the heading and explanation in AIR's `_meta`. + if (host.airClient && presentation.title) { + presentation._meta = airPermissionMeta({ + title: presentation.title, + description: presentation.description, + defaultToNo, + }); + } + const toolCallMeta = permissionToolCallMeta({ + toolName, + parentToolUseId: target.parentToolUseId, + mcpServer, + airClient: host.airClient, + }); + if (toolCallMeta) presentation.toolCall._meta = toolCallMeta; + const contextReset = host.contextReset(agentID); + const options = buildClaudePermissionOptions({ + toolName, + displayName, + input: toolInput, + cwd: target.cwd, + durableChangeSet, + allowPersistentOptions: !noPersistentRule, + defaultToNo, + availableModes: host.availableModes(), + prePlanMode: host.prePlanMode(), + allowContextReset: contextReset.offered, + contextUsedPercent: contextReset.usedPercent, + }); + // The stream never shows SandboxNetworkAccess's tool call: the client + // gets it before the request, and its status after the answer. + const networkAccess = toolName === "SandboxNetworkAccess"; + if (networkAccess) target.output.permissionToolCall(toolName, toolUseID, toolInput); + host.requestShows(target, toolName, toolUseID); + const toolCall = host.toolCallSubject(target.sessionId, presentation.toolCall); + const response = v1PermissionResponse( + await target.requestPermission( + v2PermissionRequest({ ...presentation, toolCall, options, sessionId: target.sessionId }), + signal, + ), + ); + if (signal.aborted) throw new Error("Tool use aborted"); + // In v2, `cancelled` is also the user dismissing the prompt. Without a + // `session/cancel`, that is a refusal, and the turn goes on. + if (response.outcome.outcome === "cancelled") { + if (target.cancelling()) throw new Error("Tool use aborted"); + return { + behavior: "deny", + message: "User refused permission to run tool", + toolUseID, + decisionClassification: "user_reject", + }; + } + const decision = decodeClaudePermissionResponse( + response, + toolName, + toolInput, + toolUseID, + options, + durableChangeSet, + ); + const decoded = decision.permissionResult; + if (toolName === "ExitPlanMode" && decoded.behavior === "deny" && decoded.interrupt === true) { + const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : ""; + host.exitPlanInterrupting( + toolUseID, + decision.contextResetMode && plan + ? { toolUseId: toolUseID, plan, mode: decision.contextResetMode } + : undefined, + ); + } + const permissionResult = await host.permissionAllowed(decoded); + if (networkAccess) { + target.output.toolCallStatus( + toolUseID, + permissionResult.behavior === "allow" ? "completed" : "failed", + ); + } + if (permissionResult.behavior === "allow") { + const modeUpdate = permissionResult.updatedPermissions?.find( + (update) => update.type === "setMode" && update.destination === "session", + ); + if (modeUpdate?.type === "setMode") host.modeChanged(modeUpdate.mode); + } + return permissionResult; + }; +} + +/** + * The v2 form of a permission request. + * + * v1 asks about a tool call, whose title the prompt shows. v2 gives the prompt + * its own `title` and `description`, which the presentation sets for a v2 + * client (`AcpPermissionRequest`), and asks about a `subject`: here always the tool + * call, as the same patch that a `tool_call_update` carries. + */ +export function v2PermissionRequest(request: AcpPermissionRequest): v2.RequestPermissionRequest { + if (request.title === undefined) { + throw new Error("An ACP v2 permission request needs a title"); + } + return { + sessionId: request.sessionId, + title: request.title, + ...(request.description ? { description: request.description } : {}), + subject: { type: "tool_call", toolCall: v2ToolCallPatch(request.toolCall) }, + options: request.options, + ...(request._meta != null ? { _meta: request._meta } : {}), + }; +} + +/** + * The v1 form of the answer to a permission request. + * + * v2 adds outcomes that v1 does not know, which the draft says are no + * approval. They become `cancelled`. In v2, `cancelled` also means that the + * user dismissed the prompt or the client declined it by policy. So the v2 + * core reads a `cancelled` outcome as the user refusing the tool, unless a + * `session/cancel` is stopping the session's work: then the tool use is + * aborted, as on v1 (`canUseTool`). + */ +export function v1PermissionResponse( + response: v2.RequestPermissionResponse, +): RequestPermissionResponse { + const outcome = response.outcome; + return { + outcome: + outcome.outcome === "selected" + ? (outcome as Extract) + : { outcome: "cancelled" }, + ...(response._meta != null ? { _meta: response._meta } : {}), + }; +} diff --git a/src/v2/prompt.ts b/src/v2/prompt.ts index 816577a8..810d0bf0 100644 --- a/src/v2/prompt.ts +++ b/src/v2/prompt.ts @@ -1,8 +1,6 @@ /** - * ACP v2 `session/prompt`, served through the turn events of `ClaudeAcpAgent`. - * - * v2 answers the prompt once Claude Code takes it in, with the id of the user - * message it became, and reports the rest of the turn as `state_update`s. + * The v1 form of a v2 `session/prompt` and of its content, which the shared + * prompt conversion (`promptToClaude`) and the system prompt take. */ import { RequestError, @@ -12,134 +10,7 @@ import { type Role, } from "@agentclientprotocol/sdk/unstable"; import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; -import type { ClaudeAcpAgent } from "../acp-agent.js"; import { isSupportedBlobMimeType } from "../blob-resources.js"; -import type { ForegroundEvents, StopReason, TurnEvents, TurnOutcome } from "../turn-events.js"; - -/** - * Starts a turn for `params`, and answers when Claude Code takes the prompt - * in, with the id of the user message it became. `send` delivers a session - * update of the prompt's session; it must not throw, and must deliver updates - * in the order it is called, also relative to the agent's own updates. - * - * - Taken in: the prompt's `user_message`, then the answer. From then on, the - * turn is the session's foreground work, which reports its states (see - * {@link v2Foreground}). - * - Ended or failed before it was taken in: the answer is a JSON-RPC error, - * `-32800` for a prompt that a cancel ended in the queue. - */ -export function v2Prompt( - agent: ClaudeAcpAgent, - params: v2.PromptRequest, - send: (update: v2.SessionUpdate) => void, -): Promise { - return new Promise((resolve, reject) => { - let inserted = false; - const events: TurnEvents = { - inserted(messageId) { - inserted = true; - send({ sessionUpdate: "user_message", messageId, content: params.prompt }); - resolve({ messageId }); - }, - ended(outcome) { - if (inserted) return; - reject( - outcome.stopReason === "cancelled" - ? RequestError.requestCancelled() - : RequestError.internalError( - { stopReason: outcome.stopReason }, - "The turn ended before Claude Code took the prompt in", - ), - ); - }, - failed(error) { - if (!inserted) reject(error); - }, - }; - let request: PromptRequest; - try { - request = v1PromptRequest(params); - } catch (error) { - reject(error); - return; - } - agent.startTurn(request, events).catch(reject); - }); -} - -/** - * Reports the foreground work of a session (see {@link ForegroundEvents}) as - * its `state_update`s. `send` delivers a session update of that session, as - * for {@link v2Prompt}. - * - * - Started: `running`. For a prompt's turn, right after its `user_message`. - * - Waiting on a permission request or a question: `requires_action`, and - * `running` once none is open, unless a cancel withdrew the last one: the - * work is being stopped then, and its `idle` follows. - * - Ended: `idle` with the stop reason, usage, and `_meta` of the work. - * - Failed: `idle` with the `error` stop reason and the JSON-RPC error that v1 - * answers the prompt with, so a client can show its message and handle its - * code as for an error response (for example, `-32000` starts the client's - * sign-in). - * - * The work is a prompt's turn or a model cycle that Claude Code started on its - * own, such as its follow-up after a background subagent finished. A turn's - * work ends at its result, also when v1 holds the turn's answer for the - * background subagents it spawned (`Turn.deferredSettle`): v2 allows - * background updates while idle, so the subagents' output and requests arrive - * after `idle`, and their follow-up is a cycle of its own. - */ -export function v2Foreground(send: (update: v2.SessionUpdate) => void): ForegroundEvents { - return { - running() { - send({ sessionUpdate: "state_update", state: "running" }); - }, - awaitingUser() { - send({ sessionUpdate: "state_update", state: "requires_action" }); - }, - ended(outcome) { - send(idle(outcome)); - }, - failed(error) { - send({ - sessionUpdate: "state_update", - state: "idle", - stopReason: "error", - error: jsonRpcError(error), - }); - }, - }; -} - -/** The JSON-RPC error that v1 answers a failed prompt with. */ -function jsonRpcError(error: unknown): { code: number; message: string; data?: unknown } { - if (error instanceof RequestError) { - return { - code: error.code, - message: error.message, - ...(error.data !== undefined ? { data: error.data } : {}), - }; - } - return { - code: RequestError.internalError().code, - message: error instanceof Error ? error.message : String(error), - }; -} - -/** - * The `idle` of a turn that ended. Every stop reason the agent reports is one - * of v2's own, so the state is typed as those members of `IdleStateUpdate`: - * the type checker matches each stop reason to its member, which it cannot do - * against the whole union with its custom and unknown members. - */ -function idle({ stopReason, usage, _meta }: TurnOutcome): v2.SessionUpdate { - const state: Extract = { - stopReason, - ...(usage ? { usage } : {}), - ...(_meta ? { _meta } : {}), - }; - return { sessionUpdate: "state_update", state: "idle", ...state }; -} /** * A v2 prompt may hold content blocks that the agent does not take: audio, diff --git a/src/v2/remote-control.ts b/src/v2/remote-control.ts new file mode 100644 index 00000000..59a06de9 --- /dev/null +++ b/src/v2/remote-control.ts @@ -0,0 +1,89 @@ +/** + * The adapter's `/remote-control` on the v2 core, and the messages that + * a person types in another client of the session over Remote Control. + * + * The command, its listing, and its reply are the v1 core's + * (`remote-control.ts`). The command + * runs as a local command of its own turn, as v2 asks of one ("Local handling + * still uses the foreground lifecycle"): its prompt is inserted as a + * live-only `user_message`, the turn reports `running`, the reply goes out, + * and the turn ends `idle` with `end_turn`. It waits until no turn is open + * and no prompt waits in Claude Code's queue, and the prompts after it wait + * for it: v2 asks to hold a prompt rather than reject it while Claude works. + */ +import { randomUUID } from "node:crypto"; +import type { Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import { + adapterRemoteControlCommand, + isRemoteHumanMessage, + parseRemoteControlCommand, + runRemoteControlCommand, +} from "../remote-control.js"; + +/** What the command needs of its session. */ +export interface RemoteControlHost { + readonly logger: Logger; + query(): Query | undefined; + /** Claude Code's terminal-bound commands, which it does not offer. */ + terminalCommands(): readonly string[] | undefined; + /** Resolves `true` once no turn is open and no prompt waits in Claude Code, `false` once the session closed. */ + idle(): Promise; + /** Inserts the prompt `uuid` and opens its turn. */ + begin(uuid: string): void; + /** Sends the reply `text`, as agent text of the message `messageId`, and ends the turn. */ + end(text: string, messageId: string): void; +} + +export class SessionRemoteControl { + /** The uuids of the prompts that this session sent, until Claude Code echoes them. */ + private readonly sent = new Set(); + + constructor(private readonly host: RemoteControlHost) {} + + /** Whether `prompt` may be the adapter's command, which {@link run} then decides. */ + mayRun(prompt: Parameters[0]): boolean { + return parseRemoteControlCommand(prompt) !== null; + } + + /** + * Runs `prompt` as the adapter's command, as its turn, once the session is + * idle. Resolves `false` without running it when Claude Code serves the + * command itself or the query has no Remote Control: the prompt then goes + * to Claude Code. `stillPending` tells whether a cancel or close answered + * the prompt meanwhile. + */ + async run( + prompt: Parameters[0], + uuid: string, + stillPending: () => boolean, + ): Promise { + const query = this.host.query(); + if (!query) return false; + const command = await adapterRemoteControlCommand(prompt, query, this.host.terminalCommands()); + if (!command) return false; + if (!(await this.host.idle()) || !stillPending()) return true; + this.host.begin(uuid); + // The turn is open across the network call, so the session reads as busy. + const text = await runRemoteControlCommand(command.query, command.command, (message) => + this.host.logger.error(message), + ); + this.host.end(text, randomUUID()); + return true; + } + + /** The session sent the prompt `uuid` to Claude Code. */ + pushed(uuid: string): void { + this.sent.add(uuid); + } + + /** + * The content of a prompt echo to show as a user message: one that a + * person typed in another client of the session. `undefined` for the + * echo of a prompt that this session sent, and for any other echo. + */ + remoteMessage(message: SDKUserMessage): SDKUserMessage["message"]["content"] | undefined { + if (message.uuid && this.sent.delete(message.uuid)) return undefined; + return isRemoteHumanMessage(message) ? message.message.content : undefined; + } +} diff --git a/src/v2/reports.ts b/src/v2/reports.ts new file mode 100644 index 00000000..bcdd06c4 --- /dev/null +++ b/src/v2/reports.ts @@ -0,0 +1,618 @@ +/** + * The reports of a v2 session that are not Claude's output: the + * context window and cost (`usage_update`), Claude Code's warnings and MCP startup failures (`notice`), compactions + * (`compaction_update`), the title (`session_info_update`), the predicted + * follow-up prompt of an AIR client (`session_info_update._meta.promptSuggestions`), + * and the adapter's own idle compaction. + * + * They are built as the v1 core builds them, from the shared modules + * (`session-usage.ts`, `session-notices.ts`, `context-compaction.ts`, + * `session-titles.ts`, `prompt-suggestions.ts`, `idle-compaction.ts`), and go out through the + * session's v1-to-v2 translation. The session tells it where its turns start + * and end. + */ +import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk/unstable"; +import type { + ModelInfo, + ModelUsage, + Options, + Query, + SDKAssistantMessage, + SDKMessage, + SDKPartialAssistantMessage, + SDKResultMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import type { AcpSessionNotification } from "../acp-subagents.js"; +import { + ContextCompactionLifecycle, + contextCompactionMetadataFromBoundary, +} from "../context-compaction.js"; +import { + cacheResultContextWindow, + immediateContextWindow, + providerCacheKeyFor, + readContextWindow, +} from "../context-window.js"; +import { IdleCompaction } from "../idle-compaction.js"; +import { PromptSuggestions } from "../prompt-suggestions.js"; +import { + informationalNotice, + mcpServerStartupNotice, + normalizeNoticeText, + noticeUpdate, +} from "../session-notices.js"; +import { + DEFAULT_CONTEXT_WINDOW, + getMatchingModelUsage, + inferContextWindowFromModel, + snapshotFromUsage, + totalTokens, + ZERO_USAGE, + type UsageSnapshot, +} from "../session-usage.js"; +import { SessionTitles, type TitledSession } from "../session-titles.js"; +import type { SettingsManager } from "../settings.js"; +import { createTaskHook } from "../tools.js"; +import type { SessionOutput } from "./stream.js"; + +type UsageUpdate = Extract; + +/** What the reports need of their session. */ +export interface ReportsHost { + readonly sessionId: string; + readonly logger: Logger; + /** The client is JetBrains AIR, the only one that gets the predicted follow-up prompt. */ + readonly airClient: boolean; + /** Sends v1-shaped updates of the session, in the order of the calls. */ + emit(notifications: AcpSessionNotification[]): void; + /** For the idle compaction: whether the session is busy, and its `/compact`. */ + isBusy(): boolean; + compact(): Promise; + /** The session as titles read it, the same object each time; undefined until it starts and once it ends. */ + titledSession(): TitledSession | undefined; +} + +/** What the session's query was started with (see {@link SessionReports.configure}). */ +export interface ReportsSetup { + /** The session's live Claude Code settings, disposed with the reports. */ + settings: Pick; + /** The query's environment. */ + env: Record; + /** The names of the client's MCP servers. */ + mcpServers: readonly string[]; + /** + * Whether the session asks the user to sign in to an MCP server that needs + * it (see `SessionElicitations.authenticateMcpServers`): the client renders + * URL elicitations and the query can authenticate. Its startup then sends + * no "needs authentication" notice. + */ + mcpOAuth: boolean; + /** The query, for its context window (`getContextUsage`). */ + query: Pick; + /** + * The session's model, and its allowlisted entry: the only one whose + * identity strings are a trustworthy window signal (see + * `withCurrentModelInfo`). + */ + model: { id: string; info: ModelInfo | undefined }; +} + +export class SessionReports { + private readonly titles: SessionTitles; + private readonly compaction: ContextCompactionLifecycle; + private idleCompaction: IdleCompaction | undefined; + private settings: ReportsSetup["settings"] | undefined; + private clientMcpServers = new Set(); + private mcpOAuth = false; + private readonly reportedMcpServers = new Set(); + /** The tool uses whose `informational` progress went out as a notice. */ + private readonly noticedToolUses = new Set(); + + // The open turn's figures, reset when a turn starts. + private lastUsage: UsageSnapshot | null = null; + private lastTotal: number | null = null; + private lastModel: string | null = null; + /** A `rate_limit_event` that came before the turn had usage to report it with. */ + private pendingRateLimit: unknown = null; + /** The normalized texts of the turn's notices (see {@link repeats}). */ + private noticeTexts: string[] = []; + + /** The context window of the session's model (see `context-window.ts`). */ + private contextWindow = DEFAULT_CONTEXT_WINDOW; + /** Whether {@link contextWindow} came from Claude Code, not from a guess. */ + private contextWindowAuthoritative = false; + /** The model that {@link contextWindow} is of. */ + private modelId: string | undefined; + /** Scopes the cross-session window cache to the query's backend. */ + private providerCacheKey = ""; + private query: ReportsSetup["query"] | undefined; + private disposed = false; + /** The context used after the last main-thread request. */ + private contextUsed: number | undefined; + /** The follow-up prompt the client holds, for an AIR client only. */ + private suggestions: PromptSuggestions | undefined; + + constructor(private readonly host: ReportsHost) { + this.suggestions = host.airClient ? new PromptSuggestions() : undefined; + this.titles = new SessionTitles( + { + client: { sessionUpdate: async (notification) => host.emit([notification]) }, + logger: host.logger, + get sessions() { + const session = host.titledSession(); + return session ? { [host.sessionId]: session } : {}; + }, + }, + host.sessionId, + ); + // Every v2 client takes compaction updates. + this.compaction = new ContextCompactionLifecycle( + async (notification) => host.emit([notification]), + { + sessionId: host.sessionId, + presentation: "compaction_update", + logError: (message, error) => host.logger.error(message, error), + }, + ); + } + + /** Called once, before the query's first message. */ + configure(setup: ReportsSetup): void { + this.clientMcpServers = new Set(setup.mcpServers); + this.mcpOAuth = setup.mcpOAuth; + this.settings = setup.settings; + this.query = setup.query; + // From the same env the query got. + this.providerCacheKey = providerCacheKeyFor(setup.env); + this.seedContextWindow(setup.model.id, setup.model.info); + this.idleCompaction = new IdleCompaction( + { + isBusy: () => this.host.isBusy(), + contextTokens: () => this.contextUsed, + settings: () => setup.settings.getSettings(), + compact: () => this.host.compact(), + // The logger has no debug level. + log: (message) => this.host.logger.log(message), + }, + { env: setup.env }, + ); + } + + /** + * The session's query restarted with the same MCP servers (see + * `restarts.ts`): a server already reported is not reported again, and the + * prediction the client holds is still cleared at the next prompt. + */ + restartedFrom(previous: SessionReports): void { + for (const server of previous.reportedMcpServers) this.reportedMcpServers.add(server); + this.suggestions = previous.suggestions; + } + + /** The session's MCP servers changed, as a resume of the active session asks. */ + mcpServersChanged(names: readonly string[]): void { + this.clientMcpServers = new Set(names); + } + + // --- Turns --------------------------------------------------------------- + + turnStarted(): void { + this.lastUsage = null; + this.lastTotal = null; + this.lastModel = null; + this.noticeTexts = []; + } + + /** + * The turn ends: a compaction that Claude Code left open ends `cancelled`. + * Called before the turn's idle. + */ + turnEnded(): void { + void this.compaction.reset(); + } + + /** + * Whether the text of a result that streamed no model text was shown + * already: as a compaction, or as one of the turn's notices (a hook-blocked + * turn's result repeats the block reason). + */ + repeats(resultText: string): boolean { + return ( + this.compaction.hasDeliveredOutput || + this.noticeTexts.includes(normalizeNoticeText(resultText)) + ); + } + + /** + * Claude Code's session state went `idle`: the idle compaction may arm, and + * the title may have landed (see {@link SessionTitles.onTurnEnd}). + */ + idle(): void { + this.idleCompaction?.onIdle(); + const session = this.host.titledSession(); + if (session) { + this.titles.onTurnEnd(session).catch((error) => { + this.host.logger.error(`Session ${this.host.sessionId}: failed to update the title`, error); + }); + } + } + + // --- Titles ---------------------------------------------------------------- + + /** + * A client prompt came: it drops the armed idle compaction, and its text is + * for the title. The adapter's `/compact` is no client prompt. + */ + promptSent(prompt: PromptRequest["prompt"]): void { + this.idleCompaction?.disarm("prompt"); + this.titles.onPrompt(prompt); + // The prediction is spent: the user sent it or something else. A turn + // that predicts nothing (plan mode, an API error, the usage limit) then + // leaves no stale prediction in the composer. + this.sendSuggestion(this.suggestions?.clear()); + } + + /** The client prompt went to Claude Code, which may title the session from it. */ + promptQueued(prompt: PromptRequest["prompt"]): void { + const session = this.host.titledSession(); + if (session) this.titles.onPromptQueued(session, prompt); + } + + /** A live agent text chunk of the root session, for the title. */ + agentText(content: ContentBlock): void { + this.titles.onAssistantText(content); + } + + /** Sends the session's current title and `updatedAt`, as `session/resume` asks. */ + async publishTitle(): Promise { + const session = this.host.titledSession(); + if (!session) return; + try { + await this.titles.publishCurrent(session); + } catch (error) { + this.host.logger.error( + `Session ${this.host.sessionId}: failed to send the session info`, + error, + ); + } + } + + /** Drops the armed idle compaction: a prompt or a cancel came. */ + disarmIdleCompaction(reason: string): void { + this.idleCompaction?.disarm(reason); + } + + /** + * The session's model is `id` now: the idle compaction's request describes + * the old one, and the context window is seeded for the new one, as on v1 + * (`applyConfigOptionValue`). Nothing changes for the same model. + */ + modelChanged(id: string, info: ModelInfo | undefined): void { + if (id === this.modelId) return; + this.idleCompaction?.disarm("model change"); + this.seedContextWindow(id, info); + } + + /** + * A `conversation_reset`: Claude Code mounted a fresh transcript. A + * compaction still open ends `cancelled`, the title is evaluated again at + * the next idle, and the idle compaction's request and TTL describe the + * dropped conversation. + */ + conversationReset(): void { + void this.compaction.reset(); + // The fresh conversation's context is empty. + this.lastUsage = null; + this.lastTotal = null; + this.contextUsed = 0; + this.titles.reset(); + this.idleCompaction?.reset(); + // The prediction came from the transcript that the reset dropped. + this.sendSuggestion(this.suggestions?.clear()); + } + + /** Sends the usage of an empty context, as a fresh context for an approved plan starts. */ + emptyContext(): void { + this.host.emit([ + { + sessionId: this.host.sessionId, + update: { sessionUpdate: "usage_update", used: 0, size: this.contextWindow }, + }, + ]); + } + + /** The share of the context window in use, in percent, once a request reported it. */ + contextUsedPercent(): number | undefined { + if (this.contextUsed === undefined || this.contextWindow <= 0) return undefined; + return Math.max(0, Math.min(100, Math.round((this.contextUsed / this.contextWindow) * 100))); + } + + dispose(): void { + this.disposed = true; + this.idleCompaction?.dispose(); + this.settings?.dispose(); + } + + /** The PostCompact hook's summary of the root conversation's compaction. */ + compactSummary(summary: unknown): void { + this.compaction.recordSummary(summary); + } + + // --- The query's messages ---------------------------------------------- + + /** A root stream event: its usage, and the streamed summary of a compaction. */ + streamEvent(message: SDKPartialAssistantMessage): void { + const { event } = message; + const compactionBlock = + event.type === "content_block_start" && event.content_block.type === "compaction" + ? event.content_block + : event.type === "content_block_delta" && event.delta.type === "compaction_delta" + ? event.delta + : undefined; + // `content` is null on the opening block and on a failed compaction. + if (compactionBlock) { + void this.compaction.heartbeat(message.uuid, compactionBlock.content ?? undefined); + } + if (event.type === "message_start") { + this.lastUsage = snapshotFromUsage(event.message.usage); + const model = event.message.model; + if (model && model !== "") { + this.lastModel = model; + this.idleCompaction?.noteRequest(event.message.usage, event.message.id || undefined); + if (!this.contextWindowAuthoritative && this.contextWindow === DEFAULT_CONTEXT_WINDOW) { + this.contextWindow = inferContextWindowFromModel(model) ?? this.contextWindow; + } + } + } else if (event.type === "message_delta") { + const usage = event.usage; + const prev: Readonly = this.lastUsage ?? ZERO_USAGE; + // The delta's counters are cumulative; the nullable ones keep the + // previous value when the delta leaves them out. + this.lastUsage = { + input_tokens: usage.input_tokens ?? prev.input_tokens, + output_tokens: usage.output_tokens, + cache_read_input_tokens: usage.cache_read_input_tokens ?? prev.cache_read_input_tokens, + cache_creation_input_tokens: + usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens, + }; + } else { + return; + } + const used = totalTokens(this.lastUsage); + if (used === this.lastTotal) return; + this.lastTotal = used; + this.contextUsed = used; + this.sendUsage({ sessionUpdate: "usage_update", used, size: this.contextWindow }); + } + + /** + * A root assistant message: the turn's latest usage and model. A synthetic + * message (a CLI banner with an all-zero usage) is not a model response. + */ + assistant(message: SDKAssistantMessage): void { + if (message.message.model === "") return; + this.lastUsage = snapshotFromUsage(message.message.usage); + this.lastTotal = totalTokens(this.lastUsage); + this.contextUsed = this.lastTotal; + this.idleCompaction?.noteRequest(message.message.usage, message.message.id || undefined); + if (message.message.model) this.lastModel = message.message.model; + } + + /** + * A result: its cost, and the model's context window. The idle's quota is + * `outcome.ts`'s. The CLI predicts the next prompt right after it. + */ + result(message: SDKResultMessage): void { + this.suggestions?.resultArrived(); + const matching = this.lastModel + ? getMatchingModelUsage(message.modelUsage, this.lastModel) + : undefined; + const window = (matching?.usage as ModelUsage | undefined)?.contextWindow; + if (matching && typeof window === "number" && window > 0) { + this.contextWindow = window; + this.contextWindowAuthoritative = true; + cacheResultContextWindow(this.providerCacheKey, matching.key, this.lastModel, window); + } + if (this.lastTotal === null) return; + this.sendUsage({ + sessionUpdate: "usage_update", + used: this.lastTotal, + size: this.contextWindow, + cost: { amount: message.total_cost_usd, currency: "USD" }, + ...(message.origin ? { _meta: { "_claude/origin": message.origin } } : {}), + }); + } + + /** + * The messages that only report: Claude Code's notices, compaction states, + * rate limits and predicted prompts. Returns whether `message` was one. + */ + handle(message: SDKMessage): boolean { + switch (message.type) { + case "prompt_suggestion": + // AIR shows it as gray text in an empty composer, which Tab fills. + this.sendSuggestion(this.suggestions?.suggested(message.suggestion)); + return true; + case "rate_limit_event": + this.idleCompaction?.noteRateLimit(message.rate_limit_info); + // Sent before the turn's first usage: it goes out with that usage. + if (this.lastTotal === null) { + this.pendingRateLimit = message.rate_limit_info; + } else { + this.sendUsage({ + sessionUpdate: "usage_update", + used: this.lastTotal, + size: this.contextWindow, + _meta: { "_claude/rateLimit": message.rate_limit_info }, + }); + } + return true; + case "system": + break; + default: + return false; + } + switch (message.subtype) { + case "init": + this.mcpStartupFailures(message.mcp_servers); + // The turn goes on: the session opens it. + return false; + case "status": + if (message.status === "compacting") { + void this.compaction.start(message.uuid); + } else if (message.compact_result === "success") { + void this.compaction.finish(message.uuid, "completed"); + } else if (message.compact_result === "failed") { + this.idleCompaction?.noteCompactionFailed(message.compact_error); + void this.compaction.finish(message.uuid, "failed", { + ...(message.compact_error ? { error: message.compact_error } : {}), + }); + } + return true; + case "compact_boundary": { + const metadata = message.compact_metadata; + void this.compaction.finish( + message.uuid, + "completed", + metadata + ? contextCompactionMetadataFromBoundary(metadata, { + idleCompaction: this.idleCompaction?.isCompacting, + }) + : {}, + true, + ); + // The boundary carries the context that the compaction kept. The + // window stays: compaction frees context, it does not change the model. + const used = metadata?.post_tokens ?? 0; + this.lastUsage = null; + this.lastTotal = used; + this.contextUsed = used; + this.sendUsage({ sessionUpdate: "usage_update", used, size: this.contextWindow }); + return true; + } + case "informational": { + // 'info' shows only in Claude Code's transcript mode: not worth a notice. + if (message.level === "info") return true; + if (message.tool_use_id) { + if (this.noticedToolUses.has(message.tool_use_id)) return true; + this.noticedToolUses.add(message.tool_use_id); + } + this.host.emit([ + { sessionId: this.host.sessionId, update: noticeUpdate(informationalNotice(message)) }, + ]); + this.noticeTexts.push(normalizeNoticeText(message.content)); + return true; + } + default: + return false; + } + } + + /** + * The window of model `id` without IPC: one learned from a result of this + * or another session on the same backend, else a guess from the model's + * names. A guess is then read from Claude Code in the background + * (`readContextWindow`), until a result reports the window. + */ + private seedContextWindow(id: string, info: ModelInfo | undefined): void { + this.modelId = id; + const seeded = immediateContextWindow(this.providerCacheKey, id, info); + this.contextWindow = seeded.size; + this.contextWindowAuthoritative = seeded.authoritative; + const query = this.query; + if (seeded.authoritative || !query) return; + const current = () => !this.disposed && this.modelId === id; + // A synchronous throw must not fail the caller either. + Promise.resolve() + .then(() => readContextWindow(query)) + .then( + (window) => { + if (!current() || this.contextWindowAuthoritative || window === undefined) return; + this.contextWindow = window; + this.contextWindowAuthoritative = true; + }, + (error: unknown) => { + if (current()) { + this.host.logger.error( + `Session ${this.host.sessionId}: failed to read the context window`, + error, + ); + } + }, + ); + } + + /** A notice for each client MCP server that did not start, once per server. */ + private mcpStartupFailures(servers: Array<{ name: string; status: string }> | undefined): void { + for (const server of servers ?? []) { + if (!this.clientMcpServers.has(server.name) || this.reportedMcpServers.has(server.name)) { + continue; + } + const notice = mcpServerStartupNotice(server, this.mcpOAuth); + if (!notice) continue; + this.reportedMcpServers.add(server.name); + this.host.emit([{ sessionId: this.host.sessionId, update: noticeUpdate(notice) }]); + } + } + + /** Sends the `_meta` of {@link PromptSuggestions}, when it has one. */ + private sendSuggestion(meta: Record | undefined): void { + if (!meta) return; + this.host.emit([ + { + sessionId: this.host.sessionId, + update: { sessionUpdate: "session_info_update", _meta: meta }, + }, + ]); + } + + /** + * Sends a `usage_update` with the model the usage belongs to and any + * pending rate limit in `_meta`, as ACP's usage_update has no field for them. + */ + private sendUsage(update: UsageUpdate): void { + let meta: Record = { ...(update._meta ?? {}) }; + if (this.pendingRateLimit !== null) { + meta = { "_claude/rateLimit": this.pendingRateLimit, ...meta }; + this.pendingRateLimit = null; + } + if (this.lastModel) meta["_claude/model"] = this.lastModel; + this.host.emit([ + { + sessionId: this.host.sessionId, + update: { ...update, ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}) }, + }, + ]); + } +} + +/** + * The hooks that feed the reports: the PostCompact summary of the root + * conversation's compaction (a subagent's own is not the session's), and + * the TaskCreated and TaskCompleted changes of the session's Task* plan. + */ +export function reportHooks(session: { + readonly reports: SessionReports; + readonly output: SessionOutput; +}): NonNullable { + const taskHook = createTaskHook({ + taskState: session.output.taskState, + onChange: async () => session.output.taskPlanChanged(), + }); + return { + PostCompact: [ + { + hooks: [ + async (input) => { + if (input.hook_event_name === "PostCompact" && !input.agent_id) { + session.reports.compactSummary(input.compact_summary); + } + return { continue: true }; + }, + ], + }, + ], + TaskCreated: [{ hooks: [taskHook] }], + TaskCompleted: [{ hooks: [taskHook] }], + }; +} diff --git a/src/v2/restarts.ts b/src/v2/restarts.ts new file mode 100644 index 00000000..258a19ef --- /dev/null +++ b/src/v2/restarts.ts @@ -0,0 +1,373 @@ +/** + * Restarts of a v2 session's query: a new + * query resumes the same Claude session under the same id, and takes over + * from the old one between turns. + * + * Claude Code reads the LLM route (the env of a provider or a gateway login) + * and its account once, when its process starts. So a query must start again + * when either changes: + * + * - The route: `providers/set`, `providers/disable`, and an `auth/logout` + * that drops a provider or a gateway login. Each live session restarts at + * its next turn boundary: no turn is open, no prompt of the session waits + * to go to Claude Code or in its queue, and no subagent runs, background + * ones included. A session at a turn boundary restarts right away, and the + * request that changed the route answers once it did. A busy session's + * restart is due at its next turn boundary, which the request does not + * wait for: until then the session keeps its query, and its prompts go to + * that query under the old route (the provider RFD lets an Agent leave + * running sessions on it). That restart starts under the route in force + * when it runs. A session whose query start read the route before the + * change, and that became live after it, restarts the same way once its + * setup response went out. + * A session that cannot start again stops: its requests then fail with + * what happened, until a resume starts it again. + * - The additional directories: a resume of the active session with other + * ones restarts it as for a new route, and does not wait for it. Claude + * Code reads them once, when its process starts, and the SDK has no call + * that changes them on a running query. + * - The account, under `--hide-claude-auth` (see `subscription-guard.ts`): + * the next prompt that finds the session at a turn boundary restarts it + * first, so the guard judges the new account. If the restart fails, such + * as when the guard refuses the account, the prompt fails with its error + * and the session stays as it was, for a retry after a sign-in. + * + * The new query starts before the old one ends, so a failed start leaves the + * session as it was. The old one ends at its next turn boundary after that, + * so work that started on it during the start, such as a turn that Claude + * Code started on its own, is not cut. Its background commands end with it + * (their terminals exit), as when a query ends. The new query starts without + * the effort of the setup's `_meta`, and the client's choices are set again + * on it (`SessionConfig.restoreFrom`), which then reports its options in a + * `config_option_update`. Ending the old query withdraws its MCP OAuth + * runs, and the new one starts OAuth for the setup's MCP servers that still + * need it, as a setup does. One restart of a session runs at + * a time: a route restart waits for an account restart that runs, as for a + * busy session. + * + * Prompts wait only for a restart that runs, from the new query's start to + * the old one's end, and go to the new query. A `session/cancel` or + * `session/close` answers them with -32800, and stops the work that runs on + * the old query while the restart waits for it. + */ +import { RequestError } from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { Logger } from "../acp-agent.js"; +import { withoutEffort } from "../session-effort.js"; +import type { V2Session } from "./session.js"; +import { afterSetupResponse } from "./setup.js"; + +/** The request that set the session up, whose options a restart starts with again. */ +export type SetupParams = v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest; + +/** What the restarts need of the agent. */ +export interface RestartHost { + readonly logger: Logger; + /** The active session `sessionId`. */ + active(sessionId: string): V2Session | undefined; + /** + * Starts a new query that resumes `sessionId`, with the options of `params` + * and the current route. The new session is active once it resolves. + */ + start(sessionId: string, params: SetupParams): Promise; + /** `previous` could not start again: the session stops. */ + lost(sessionId: string, previous: V2Session): Promise; + send(notification: v2.UpdateSessionNotification): void; +} + +/** A route restart of a busy session: waiting for its turn boundary, then running. */ +type Reroute = { done: Promise; started: boolean }; + +export class SessionRestarts { + /** The setup request of each live session. */ + private readonly setups = new Map(); + /** The sessions whose account may have changed: their next prompt at a turn boundary restarts them. */ + private readonly accountChanged = new Set(); + /** The route restarts that wait for the session's turn boundary. Prompts do not wait for them. */ + private readonly reroutes = new Map(); + /** The restarts that run: from the new query's start to the old one's end. */ + private readonly swaps = new Map>(); + /** The session that each running restart replaces. */ + private readonly retiring = new Map(); + /** The sessions that `session/close` closes: a restart that runs does not track them again. */ + private readonly closingSessions = new Set(); + /** + * The sessions whose start read the route before a change that + * {@link reroute} did not find them live for: they restart once their + * setup answered ({@link setupAnswered}). + */ + private readonly staleRoutes = new Set(); + /** The prompts that wait for a restart, each with what rejects it. */ + private readonly held = new Map void>>(); + + constructor(private readonly host: RestartHost) {} + + /** + * The session started from `params`, or a resume of the active session + * brought new ones. `staleRoute`: the start read the route before a route + * change came. + */ + track(sessionId: string, params: SetupParams, { staleRoute = false } = {}): void { + if (this.closingSessions.has(sessionId)) return; + this.setups.set(sessionId, params); + // A restart's start: the change found the session it replaces live, and + // its reroute runs again after this restart. + if (staleRoute && !this.swaps.has(sessionId)) this.staleRoutes.add(sessionId); + } + + /** + * The setup response of `sessionId` goes out: a route restart that its + * start missed runs from then, as for a live session (see {@link reroute}). + * Not before: the restart's updates must not precede the response. + */ + setupAnswered(sessionId: string): void { + if (!this.staleRoutes.delete(sessionId)) return; + // The handler returns the response right after this call, and the SDK + // writes it before a timer runs. Nothing else orders the two. + setTimeout(() => { + if (this.setups.has(sessionId)) void this.rerouteSession(sessionId); + }, 0); + } + + /** + * A resume of the active session brought other additional directories: + * the session restarts with `params`, as for a new route (see the module + * doc). Does not wait for it. + */ + rootsChanged(sessionId: string, params: SetupParams): void { + this.track(sessionId, params); + void this.rerouteSession(sessionId); + } + + /** + * The session is no longer active, such as when its query ended. A route + * restart that is due does not run. + */ + forget(sessionId: string): void { + this.setups.delete(sessionId); + this.accountChanged.delete(sessionId); + this.staleRoutes.delete(sessionId); + } + + /** + * The route changed: restarts every live session at its next turn + * boundary. Resolves once each session that is at one now restarted, or + * was closed or lost. Their restarts start in this call, so a prompt that + * comes after it waits for them (`beforePrompt`). The busy sessions' + * restarts are only due: a failure of one stops its session later + * (`RestartHost.lost`). + */ + async reroute(): Promise { + await Promise.all([...this.setups.keys()].map((sessionId) => this.rerouteSession(sessionId))); + } + + /** The session's account may have changed: its next prompt at a turn boundary restarts it. */ + accountMayHaveChanged(sessionId: string): void { + if (this.setups.has(sessionId)) this.accountChanged.add(sessionId); + } + + /** + * What a prompt of `sessionId` waits for before it goes to the session: a + * restart that runs, or an account restart that is due. `undefined` when + * none is: the prompt goes on in the same call, so prompts keep their + * order. A route restart that waits for a turn boundary does not hold it: + * the prompt goes to the current query. + */ + beforePrompt(sessionId: string): Promise | undefined { + const running = this.swaps.get(sessionId); + if (running) return this.hold(sessionId, running); + const session = this.host.active(sessionId); + if (this.accountChanged.has(sessionId) && session?.atTurnBoundary(true)) { + return this.hold(sessionId, this.swap(sessionId, session, { keepOnFailure: true })); + } + return undefined; + } + + /** + * Whether a restart runs: its new query starts MCP OAuth once it took + * over, so the setup response of the session it replaces need not. + */ + restarting(sessionId: string): boolean { + return this.swaps.has(sessionId); + } + + /** What a request other than a prompt waits for: a restart that runs. */ + settled(sessionId: string): Promise | undefined { + return this.swaps.get(sessionId)?.catch(() => {}); + } + + /** + * `session/cancel`: the prompts that wait for a restart fail with -32800, + * and work that runs on the query that a restart replaces stops too (the + * restart waits for it). + */ + cancelled(sessionId: string): void { + this.release(sessionId, RequestError.requestCancelled()); + const retiring = this.retiring.get(sessionId); + // Until the new query started, the old one is the active session, which the cancel stops. + if (retiring && retiring !== this.host.active(sessionId)) void retiring.cancel(); + } + + /** + * `session/close`: the prompts that wait fail with -32800, and a route + * restart that waits for a turn boundary is dropped (the closed session + * reaches none). Resolves once a restart that runs is done, so the close + * ends the session that it started. + */ + async closing(sessionId: string): Promise { + this.closingSessions.add(sessionId); + this.forget(sessionId); + this.release(sessionId, RequestError.requestCancelled()); + // The close stops the old query's work too, which the restart waits for. + await this.retiring.get(sessionId)?.close(); + await this.swaps.get(sessionId)?.catch(() => {}); + } + + /** `session/close` closed the session. */ + closed(sessionId: string): void { + this.closingSessions.delete(sessionId); + this.forget(sessionId); + } + + /** Waits for the restarts that run, so no new query starts after the agent's sessions closed. */ + async dispose(): Promise { + for (const sessionId of [...this.setups.keys(), ...this.retiring.keys()]) { + this.closingSessions.add(sessionId); + this.forget(sessionId); + this.release(sessionId, RequestError.requestCancelled()); + } + await Promise.all([...this.retiring.values()].map((session) => session.close())); + await Promise.all([...this.swaps.values()].map((swap) => swap.catch(() => {}))); + } + + /** + * Restarts the session for a new route. At a turn boundary, with no restart + * that runs, the restart starts in this call, and the result resolves once + * it is done. Otherwise it is due at the next turn boundary, and the result + * resolves at once. + */ + private async rerouteSession(sessionId: string): Promise { + const failed = (error: unknown) => { + this.host.logger.error(`Session ${sessionId}: could not restart for the new route`, error); + }; + const session = this.host.active(sessionId); + if (session && !this.swaps.has(sessionId) && session.atTurnBoundary(true)) { + // A restart due at the boundary that came has not resumed yet: this + // one runs in its place. + this.reroutes.delete(sessionId); + await this.swap(sessionId, session, { keepOnFailure: false }).catch(failed); + return; + } + const waiting = this.reroutes.get(sessionId); + // One that waits starts under the route in force when it runs. One that + // started may have read the route before this change: it runs again. + if (waiting && !waiting.started) return; + const reroute: Reroute = { done: Promise.resolve(), started: false }; + const current = () => this.reroutes.get(sessionId) === reroute; + reroute.done = (async () => { + await waiting?.done; + let session: V2Session | undefined; + for (;;) { + // After an account restart that runs, on the session it started. + await this.swaps.get(sessionId)?.catch(() => {}); + session = this.host.active(sessionId); + if (!current() || !session) return; + // `false` once the session closed. + if (!(await session.turnBoundary(true)) || !current()) return; + // A restart can start at the boundary too, and a prompt that it held + // can end the boundary again before this resumes: wait again then. + if ( + !this.swaps.has(sessionId) && + this.host.active(sessionId) === session && + session.atTurnBoundary(true) + ) { + break; + } + } + if (!this.setups.has(sessionId)) return; + reroute.started = true; + await this.swap(sessionId, session, { keepOnFailure: false }); + })() + .catch(failed) + .finally(() => { + if (current()) this.reroutes.delete(sessionId); + }); + this.reroutes.set(sessionId, reroute); + } + + /** + * Starts the new query, then ends the old one and sets the client's choices + * on the new one. With `keepOnFailure`, a failed start rejects and leaves + * `previous` active. Otherwise the session is lost. + */ + private swap( + sessionId: string, + previous: V2Session, + { keepOnFailure }: { keepOnFailure: boolean }, + ): Promise { + const run = (async () => { + const params = this.setups.get(sessionId); + if (!params) return; + this.host.logger.log(`Restarting the query of session ${sessionId}`); + let session: V2Session; + try { + // The client's effort pin is restored below instead. + session = await this.host.start(sessionId, withoutEffort(params)); + } catch (error) { + if (keepOnFailure) throw error; + this.host.logger.error(`Session ${sessionId}: could not start again`, error); + await this.host.lost(sessionId, previous); + return; + } + // The new query read the account in force. + this.accountChanged.delete(sessionId); + session.reports.restartedFrom(previous.reports); + // Work that started on the old query while the new one started, such + // as a turn that Claude Code started on its own, ends first. New + // prompts wait for this restart meanwhile (`beforePrompt`). + await previous.turnBoundary(true); + await previous.retire(); + // A close during the restart closes the new session next. + if (this.closingSessions.has(sessionId)) return; + await session.config.restoreFrom(previous.config); + this.host.send({ + sessionId, + update: { sessionUpdate: "config_option_update", configOptions: session.config.options() }, + }); + // Closing the old query withdrew its MCP OAuth runs. The servers of a + // resume that came during the restart are tracked by now. + afterSetupResponse(session, this.setups.get(sessionId) ?? params); + })().finally(() => { + if (this.swaps.get(sessionId) === run) this.swaps.delete(sessionId); + if (this.retiring.get(sessionId) === previous) this.retiring.delete(sessionId); + }); + this.retiring.set(sessionId, previous); + this.swaps.set(sessionId, run); + return run; + } + + /** Waits for `work`, unless a cancel or close of the session comes first. */ + private hold(sessionId: string, work: Promise): Promise { + return new Promise((resolve, reject) => { + let holds = this.held.get(sessionId); + if (!holds) this.held.set(sessionId, (holds = new Set())); + const settle = (error?: unknown) => { + if (!holds.delete(settle)) return; + if (holds.size === 0 && this.held.get(sessionId) === holds) this.held.delete(sessionId); + if (error === undefined) resolve(); + else reject(error); + }; + holds.add(settle); + work.then( + () => settle(), + (error: unknown) => settle(error ?? new Error("The restart failed")), + ); + }); + } + + private release(sessionId: string, error: unknown): void { + const holds = this.held.get(sessionId); + this.held.delete(sessionId); + for (const settle of [...(holds ?? [])]) settle(error); + } +} diff --git a/src/v2/session-requests.ts b/src/v2/session-requests.ts new file mode 100644 index 00000000..de353897 --- /dev/null +++ b/src/v2/session-requests.ts @@ -0,0 +1,133 @@ +/** + * The v1 form of the v2 session setup and config requests, which the shared + * session modules take (`sdkMcpServers`, `forkSession`, `configOptionValue`), + * and the v2 form of the config options and commands that they build. + */ +import { + RequestError, + type AvailableCommand, + type ForkSessionRequest, + type McpServer, + type NewSessionRequest, + type SessionConfigOption, + type SessionConfigSelectGroup, + type SessionConfigSelectOptions, + type SetSessionConfigOptionRequest, + type SystemPrompt, +} from "@agentclientprotocol/sdk/unstable"; +import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import { V2_SESSION_CAPABILITIES } from "./initialize.js"; +import { v1ContentBlock } from "./prompt.js"; + +export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest { + const { mcpServers, systemPrompt, ...request } = params; + // v1 requires the list; v2 treats an omitted list as an empty one. + return { + ...request, + mcpServers: v1McpServers(mcpServers) ?? [], + ...v1SystemPromptField(systemPrompt), + }; +} + +/** The v1 request of a v2 `session/fork`. */ +export function v1ForkSessionRequest(params: v2.ForkSessionRequest): ForkSessionRequest { + const { mcpServers, systemPrompt, ...request } = params; + return { + ...request, + ...(mcpServers ? { mcpServers: v1McpServers(mcpServers) } : {}), + ...v1SystemPromptField(systemPrompt), + }; +} + +/** + * The v1 `systemPrompt`. Its content converts like prompt content: a v2 + * annotation audience can be any string, and v1 takes only `user` or `assistant`. + */ +function v1SystemPromptField(systemPrompt: v2.SystemPrompt | null | undefined): { + systemPrompt?: SystemPrompt; +} { + if (systemPrompt == null) return {}; + return { systemPrompt: { ...systemPrompt, content: systemPrompt.content.map(v1ContentBlock) } }; +} + +/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */ +export function v1SetSessionConfigOptionRequest( + params: v2.SetSessionConfigOptionRequest, +): SetSessionConfigOptionRequest { + const request = { + sessionId: params.sessionId, + configId: params.configId, + ...(params._meta != null ? { _meta: params._meta } : {}), + }; + if (v2.SetSessionConfigOptionRequest.isId(params)) { + return { ...request, value: params.value }; + } + if (v2.SetSessionConfigOptionRequest.isBoolean(params)) { + return { ...request, type: "boolean", value: params.value }; + } + throw RequestError.invalidParams( + { type: params.type }, + `Config option values of type ${params.type} are not supported`, + ); +} + +/** v2 names the id of an option `configId` and the id of a select group `groupId`. */ +export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] { + return options.map((option) => { + const { id, ...rest } = option; + if (rest.type === "select") { + return { ...rest, configId: id, options: v2SelectOptions(rest.options) }; + } + return { ...rest, configId: id }; + }); +} + +function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions { + if (!isGrouped(options)) return options; + return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group })); +} + +function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] { + return options.some((entry) => "group" in entry); +} + +/** + * v2 tags each command with `type: "command"` and its free-text input with + * `type: "text"`. A receiver drops a command without the `type` tag. + */ +export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] { + return commands.map(({ input, ...command }) => ({ + type: "command", + ...command, + ...(input != null ? { input: { type: "text", ...input } } : {}), + })); +} + +/** + * The v1 list of the MCP servers of a v2 setup request, or undefined for no + * list. A request with two servers of the same `name`, or with a transport + * that the agent does not advertise in `session.mcp` (such as `acp` or a + * custom `_` type), fails with invalid params (-32602): `sdkMcpServers` would + * otherwise drop the server without telling the client. + */ +function v1McpServers(servers: v2.McpServer[] | null | undefined): McpServer[] | undefined { + if (servers == null) return undefined; + v2.assertSupportedMcpServers(servers, V2_SESSION_CAPABILITIES.mcp); + return servers.map(v1McpServer); +} + +/** + * v2 requires a `type` on every transport and makes the empty lists optional. + * `sdkMcpServers` recognizes a v1 stdio server by its missing `type`, so the + * v2 tag is dropped. {@link v1McpServers} has already rejected every other transport. + */ +function v1McpServer(server: v2.McpServer): McpServer { + if (v2.McpServer.isStdio(server)) { + const { name, command, args, env, _meta } = server; + return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) }; + } + if (v2.McpServer.isHttp(server)) { + return { ...server, headers: server.headers ?? [] }; + } + throw new Error(`Unreachable: MCP server of type ${server.type} passed the transport check`); +} diff --git a/src/v2/session-update.ts b/src/v2/session-update.ts index 61521288..492d3b5b 100644 --- a/src/v2/session-update.ts +++ b/src/v2/session-update.ts @@ -1,10 +1,10 @@ /** - * The v2 form of the session updates that `ClaudeAcpAgent` sends as v1 - * updates. + * The v2 form of the v1 session updates that the shared SDK-to-ACP mapping + * produces (`toAcpNotifications`). */ import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; import type { AcpSessionNotification } from "../acp-subagents.js"; -import { v2AvailableCommands, v2ConfigOptions } from "./session.js"; +import { v2AvailableCommands, v2ConfigOptions } from "./session-requests.js"; import { checklistPlanOperation } from "../plans.js"; import { v2ToolCallUpdate } from "./tool-call.js"; @@ -53,9 +53,7 @@ export function v2SessionUpdate( // v1's draft plan operations have v2's shape. return update; case "subagent_update": { - // The subagents RFD gives it the same shape in v1 and v2. One that - // reports a state also has a copy on the child's stream (see - // `v2ChildStateUpdate`). + // The subagents RFD gives it the same shape in v1 and v2. const { state, ...association } = update; return { ...association, ...(state != null ? { state: v2SubagentState(state) } : {}) }; } @@ -75,26 +73,6 @@ export function v2SessionUpdate( } } -/** - * The child's own `state_update` for a v1 `subagent_update` that reports the - * child's work state, or `undefined` when it reports none. - * - * v1 reports a child's work state only on its parent, as the `state` of the - * `subagent_update`. v2 reports it on the child's own stream, as for any - * session, and mirrors it on the parent (the subagents RFD). The child's copy - * goes right after the parent's: never before the announcement, and both - * copies of a state before any later state. - */ -export function v2ChildStateUpdate( - update: AcpSessionNotification["update"], -): v2.UpdateSessionNotification | undefined { - if (update.sessionUpdate !== "subagent_update" || update.state == null) return undefined; - return { - sessionId: update.sessionId, - update: { sessionUpdate: "state_update", ...v2SubagentState(update.state) }, - }; -} - type V1SubagentState = NonNullable< Extract["state"] >; diff --git a/src/v2/session.ts b/src/v2/session.ts index 6e885799..d7a65102 100644 --- a/src/v2/session.ts +++ b/src/v2/session.ts @@ -1,194 +1,1385 @@ /** - * The ACP v2 session lifecycle, expressed through the v1 session methods of - * `ClaudeAcpAgent`: v2 requests become the v1 requests that the agent serves, - * and its v1 responses become v2 responses. + * One ACP v2 session, served straight from its Claude Code query. + * + * ACP v2 and Claude Code describe a session the same way: prompts go into a + * queue, Claude Code works through them, and the session reports whether it + * is working. So this session keeps little of its own: + * + * - A prompt is pushed to Claude Code at once, with a uuid. Its response is + * the prompt's insertion: the `command_lifecycle` frame `started` for that + * uuid, or the prompt's echo, whichever comes first. `cancelled`, + * `discarded` or `refused` fails the prompt. + * - The foreground work is Claude Code's turn: it starts with a prompt's + * `started` frame or echo, or with the main conversation's output (a + * cycle that Claude Code starts on its own, such as after a background + * task ends), and ends with the turn's `result`, whose stop reason, usage + * and quota the idle carries (see `outcome.ts`). Claude Code's own + * `session_state_changed` stays `running` while background subagents work, + * which v2 lists as background work instead (`background`), so its `idle` + * only ends a turn that ended without a result. + * - `requires_action` is this session's own: a turn is open, and a + * permission request or elicitation of this session is open, or, while + * the turn waits on subagents, each of them waits on one. + * - Subagents are child sessions, and background work is listed on every + * state (see {@link SessionTasks}). + * - `session/cancel` answers the prompts that are not inserted yet with + * -32800, and sends Claude Code's interrupt with `cancelQueued`, which ends + * them in Claude Code too. A run of one that Claude Code had taken in + * already is dropped (see {@link StoppedWork}). With + * `perTaskStopAffordance`, the interrupt spares background tasks. + * + * Output goes out as it comes, whatever prompt it belongs to (see + * {@link SessionOutput}). */ +import { randomUUID } from "node:crypto"; import { RequestError, - type AvailableCommand, - type ForkSessionRequest, - type LoadSessionRequest, - type McpServer, - type NewSessionRequest, - type NewSessionResponse, - type ResumeSessionRequest, - type ResumeSessionResponse, - type SessionConfigOption, - type SessionConfigSelectGroup, - type SessionConfigSelectOptions, - type SetSessionConfigOptionRequest, - type SystemPrompt, + type CompleteElicitationNotification, + type CreateElicitationRequest, + type CreateElicitationResponse, + type PromptRequest, + type ToolCallUpdate, } from "@agentclientprotocol/sdk/unstable"; -import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; -import { V2_SESSION_CAPABILITIES } from "./initialize.js"; -import { v1ContentBlock } from "./prompt.js"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { + FastModeDisabledReason, + FastModeState, + PermissionMode, + PermissionResult, + Query, + SDKMessage, + SDKResultMessage, + SDKUserMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { AUTONOMOUS_RESULT_ORIGINS, promptToClaude, type Logger } from "../acp-agent.js"; +import type { McpCommand } from "../mcp-command.js"; +import type { ElicitationSupport } from "../elicitation.js"; +import { Pushable } from "../utils.js"; +import { v1PromptRequest } from "./prompt.js"; +import { V2Background, stateUpdate as currentStateUpdate } from "./background.js"; +import type { V2UpdateTranslator } from "./update-translator.js"; +import { MODEL_CONFIG_ID } from "../session-model.js"; +import { modelRefusalFallback, noticeUpdate } from "../session-notices.js"; +import { planModeUpdate } from "../exit-plan.js"; +import { resetPlanUpdates } from "../plans.js"; +import type { ClearContextReset } from "../clear-context-coordinator.js"; +import { agentFileChangeReportMeta } from "../file-change-audit.js"; +import type { AirSupport } from "./air.js"; +import { ClearContext } from "./clear-context.js"; +import { TurnFileChanges } from "./file-changes.js"; +import { SessionCommands } from "./commands.js"; +import type { SessionConfig } from "./config.js"; +import { + idleOutcome, + isPlaceholderResult, + ResultUsage, + TurnReport, + type TurnEnd, +} from "./outcome.js"; +import { StoppedWork } from "./stopped-work.js"; +import { SessionReports } from "./reports.js"; +import { SessionElicitations } from "./elicitation.js"; +import { SessionOutput } from "./stream.js"; +import { SessionRemoteControl } from "./remote-control.js"; +import { SessionTasks, type RequestTarget } from "./tasks.js"; -export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest { - const { mcpServers, systemPrompt, ...request } = params; - // v1 requires the list; v2 treats an omitted list as an empty one. - return { - ...request, - mcpServers: v1McpServers(mcpServers) ?? [], - ...v1SystemPromptField(systemPrompt), - }; +/** What a session needs of its connection. */ +export interface SessionConnection { + readonly logger: Logger; + readonly translator: V2UpdateTranslator; + /** The AIR extensions that the client takes, read at `initialize` (see `air.ts`). */ + readonly air: AirSupport; + /** Sends an update of the session. Updates go out in the order of the calls. */ + send(notification: v2.UpdateSessionNotification): void; + requestPermission( + request: v2.RequestPermissionRequest, + signal: AbortSignal, + ): Promise; + /** The elicitation modes that the client advertised. */ + readonly elicitation: ElicitationSupport; + createElicitation( + request: CreateElicitationRequest, + signal: AbortSignal, + ): Promise; + completeElicitation(params: CompleteElicitationNotification): Promise; + /** The query of `session` ended on its own: the session cannot go on. */ + ended(session: V2Session): void; + /** A turn of the session failed for a missing login (`authRequired`). */ + signedOut(sessionId: string): void; } -/** - * The v1 request that a v2 `session/resume` means. - * - * v2 restores a session with `session/resume` and replays its history only - * when `replayFrom` asks for it. v1 has two methods: `session/resume`, which - * never replays, and `session/load`, which replays the whole history before it - * responds. `replayFrom: { type: "start" }` is the second; a cursor that - * starts elsewhere is rejected. - */ -export function v1RestoreSessionRequest( - params: v2.ResumeSessionRequest, -): - | { method: "resume"; request: ResumeSessionRequest } - | { method: "load"; request: LoadSessionRequest } { - const { replayFrom, mcpServers, systemPrompt, ...rest } = params; - const request = { ...rest, ...v1SystemPromptField(systemPrompt) }; - if (replayFrom == null) { - return { - method: "resume", - request: { ...request, ...(mcpServers ? { mcpServers: v1McpServers(mcpServers) } : {}) }, +type PendingPrompt = { + prompt: v2.ContentBlock[]; + /** Whether the prompt went to Claude Code (see {@link V2Session.prompt}). */ + pushed: boolean; + resolve: (response: v2.PromptResponse) => void; + reject: (error: unknown) => void; +}; + +type ForegroundState = "idle" | "running" | "requires_action"; + +/** An open permission request of the session. */ +type OpenRequest = { + /** Withdraws the request, with `$/cancel_request`. */ + abort: AbortController; + /** Whether a cancel came while it was open: its work is being stopped. */ + coveredByCancel: boolean; +}; + +type McpServers = Parameters[0]; + +/** The `command_lifecycle` frame of a uuid-stamped prompt. It is not part of the SDK's types. */ +type CommandLifecycleFrame = { type: "command_lifecycle"; command_uuid: string; state: string }; + +export class V2Session implements RequestTarget { + /** The prompts not inserted yet, by uuid. */ + private readonly pending = new Map(); + /** Whether a turn of Claude Code is open: the session's foreground work. */ + private foreground = false; + /** The prompt whose `started` frame came last in the open turn. */ + private turnCommand: string | undefined; + /** The state the client was told last. */ + private reportedState: ForegroundState = "idle"; + /** The open permission requests and elicitations of the session. */ + private readonly requests = new Set(); + /** Whether `session/cancel` stops the open turn: its idle reports `cancelled`. */ + private cancelledWork = false; + /** What a cancel stopped before the client saw it, and the reach of its interrupt. */ + readonly stopped = new StoppedWork(); + /** The interrupt of the last `session/cancel`, while it runs. */ + private interrupting: Promise | undefined; + /** Pushes prompts in the order they came (see {@link prompt}). */ + private pushes: Promise = Promise.resolve(); + /** Pushes the adapter's own prompts of the open turn in order (see {@link pushOwnPrompt}). */ + private ownPushes: Promise = Promise.resolve(); + /** The result that ended the open turn, with what the turn spent. */ + private lastEnd: TurnEnd | undefined; + private usage = new ResultUsage(false); + /** + * Whether Claude Code's `idle` for the last result is still to come. It can + * come after the next cycle's first output, and does not end that cycle. + */ + private idleOwed = false; + /** Whether Claude Code sends `command_lifecycle` frames (`msg_lifecycle_v1`). */ + private lifecycleFrames = false; + /** What the open turn's assistant messages reported about its end (see {@link TurnReport}). */ + private readonly turnReport = new TurnReport(); + /** Set by `session/close`, or when the query ends: nothing more goes out. */ + private closed = false; + private closing: Promise | undefined; + /** Set by {@link start}. */ + private query: Query | undefined; + readonly output: SessionOutput; + /** Usage, quota, notices, compactions and the title (see {@link SessionReports}). */ + readonly reports: SessionReports; + /** Resolves the adapter's own `/compact` once a turn ends (see {@link compact}). */ + private ownCommandDone: (() => void) | undefined; + /** The `background` lists of the session and its children, stamped on each state. */ + private readonly background: V2Background; + readonly tasks: SessionTasks; + readonly elicitations: SessionElicitations; + /** AIR's file-change reports, when the client declared them (see `file-changes.ts`). */ + private readonly fileChanges: TurnFileChanges | undefined; + config!: SessionConfig; + commands!: SessionCommands; + /** ExitPlanMode's "clear context" option (see {@link ClearContext}). */ + readonly clearContext: ClearContext; + /** The `/mcp` prompts not inserted yet, by uuid, and the open turn's (see `SessionCommands`). */ + private readonly localCommands = new Map(); + private turnLocalCommand: { command: McpCommand; abort: AbortController } | undefined; + /** The JSON of the query's MCP servers. */ + private mcpServers = ""; + /** The adapter's `/remote-control`, and messages typed in other clients. */ + private readonly remoteControl: SessionRemoteControl; + /** Who waits for {@link turnBoundary}, with whether it waits for a restart. */ + private readonly boundaryWaiters: Array<{ + restart: boolean; + resolve: (reached: boolean) => void; + }> = []; + + constructor( + readonly sessionId: string, + readonly cwd: string, + /** The session's additional directories, as its query got them. */ + readonly additionalDirectories: readonly string[], + private readonly connection: SessionConnection, + private readonly input: Pushable, + /** Whether Claude Code's interrupt spares background tasks (see {@link SessionTasks}). */ + reportsBackground = true, + /** + * The query's additional directories: the request's and those of the + * client's `_meta.claudeCode.options`. A file-change report lists paths + * in them and in `cwd`. + */ + workspaceDirectories: readonly string[] = additionalDirectories, + ) { + this.output = new SessionOutput( + sessionId, + cwd, + connection.translator, + (update) => this.send(update), + connection.logger, + ); + // eslint-disable-next-line @typescript-eslint/no-this-alias + const session = this; + const titled = { + get query() { + return session.query!; + }, + get queryClosed() { + return session.closed; + }, + get cancelled() { + return session.cancelledWork; + }, + cwd, }; + this.reports = new SessionReports({ + sessionId, + logger: connection.logger, + airClient: connection.air.client, + emit: (notifications) => this.output.emitV1(notifications), + isBusy: () => this.foreground || this.pending.size > 0 || this.requests.size > 0, + compact: () => this.compact(), + titledSession: () => (this.query && !this.closed ? titled : undefined), + }); + this.output.onAgentText = (content) => this.reports.agentText(content); + this.fileChanges = TurnFileChanges.create(connection.air.fileChangeReport, { + cwd, + additionalDirectories: [...workspaceDirectories], + publish: async (report) => + this.send({ + sessionUpdate: "session_info_update", + _meta: agentFileChangeReportMeta(report), + }), + logError: (message) => connection.logger.error(`Session ${sessionId}: ${message}`), + }); + this.clearContext = new ClearContext( + { + push: (text, uuid) => this.pushOwnPrompt(text, uuid), + enterMode: (mode) => this.config.enterMode(mode), + publish: (plan) => { + this.send({ + sessionUpdate: "config_option_update", + configOptions: this.config.options(), + }); + // Otherwise the client shows the old context's usage until the fresh one reports its own. + this.reports.emptyContext(); + // The approved plan becomes the session's plan. Its ExitPlanMode + // ended as a rejection, so the stream did not show it. + this.output.emitV1([{ sessionId, update: planModeUpdate(plan) }]); + }, + logError: (message, error) => + connection.logger.error(`Session ${sessionId}: ${message}`, error), + }, + () => this.output.toolUses(), + randomUUID, + ); + this.background = new V2Background(connection.translator.terminals); + this.tasks = new SessionTasks( + { + sessionId, + cwd, + logger: connection.logger, + translator: connection.translator, + background: this.background, + reportsBackground, + send: (notification) => this.sendNotification(notification), + rootState: () => this.foregroundState(), + // A cancel withdraws the requests of the subagents that the work waits on. + foregroundRequestsChanged: () => this.reportState(this.cancelledWork), + models: () => this.config.subagentModels(), + modes: () => this.config.availableModes(), + stopTask: async (taskId) => { + await this.query?.stopTask(taskId); + }, + }, + this.output, + ); + this.tasks.hostRequest = (request, signal) => + unlessWithdrawn(signal, () => connection.requestPermission(request, signal)); + this.elicitations = new SessionElicitations({ + sessionId, + logger: connection.logger, + support: connection.elicitation, + airClient: connection.air.client, + createElicitation: (request, signal) => + unlessWithdrawn(signal, () => connection.createElicitation(request, signal)), + completeElicitation: (params) => connection.completeElicitation(params), + target: (agentId) => this.target(agentId), + query: () => this.query, + }); + this.remoteControl = new SessionRemoteControl({ + logger: connection.logger, + query: () => this.query, + terminalCommands: () => this.commands.terminal(), + idle: () => this.turnBoundary(false), + begin: (uuid) => { + this.inserted(uuid); + this.openTurn(undefined); + }, + end: (text, messageId) => { + this.output.agentMessage(text, messageId); + this.closeTurn(); + }, + }); } - const servers = v1McpServers(mcpServers); - if (replayFrom.type !== "start") { - throw RequestError.invalidParams( - { replayFrom }, - `Replaying from a ${replayFrom.type} cursor is not supported`, + + /** + * Starts reading the query. Called once, after its initialization. + * `mcpServers` are the servers the query started with. + */ + start(query: Query, config: SessionConfig, mcpServers: McpServers, resumed = false): void { + this.query = query; + this.config = config; + this.usage = new ResultUsage(resumed); + this.mcpServers = JSON.stringify(mcpServers); + this.commands = new SessionCommands(query); + void this.consume(query); + } + + /** Sends the session's commands in an update, read again from Claude Code. */ + async sendCommands(): Promise { + const availableCommands = await this.commands.list(); + this.send({ sessionUpdate: "available_commands_update", availableCommands }); + } + + /** + * What `session/resume` sends before its response: the listed children + * with their current states, the current update of each listed item, then + * the session's state, with its list. A snapshot is no new completion, so + * its idle has no stop reason. + */ + resumeSnapshot(): v2.UpdateSessionNotification[] { + return [ + ...this.tasks.currentState(), + this.background.stamp({ + sessionId: this.sessionId, + update: currentStateUpdate(this.foregroundState()), + }), + ]; + } + + /** + * Replaces the session's MCP servers, as a resume of the active session + * asks. The same servers stay as they are, connected. + */ + async setMcpServers(servers: McpServers): Promise { + const key = JSON.stringify(servers); + if (key === this.mcpServers) return; + await this.query?.setMcpServers(servers); + this.reports.mcpServersChanged(Object.keys(servers)); + this.mcpServers = key; + } + + // --- Requests --------------------------------------------------------- + + /** + * Pushes the prompt to Claude Code. Answers once Claude Code inserted it + * (see the module doc), right after the prompt's `user_message`. + * + * A prompt waits for the interrupt of an earlier `session/cancel` before it + * goes to Claude Code: the interrupt's `cancelQueued` must not end a prompt + * that came after the cancel. + * + * `guard`, the subscription guard's check (see `subscription-guard.ts`), + * runs in the prompt's turn among the session's prompts, so they keep their + * order, and a cancel or close answers a prompt that waits for it with + * -32800 as any prompt not inserted yet. Its refusal answers the prompt. + * The adapter's `/remote-control` is not guarded. + */ + prompt( + params: v2.PromptRequest, + guard?: (query: Query) => Promise | undefined, + ): Promise { + if (this.closed) { + return Promise.reject(RequestError.sessionNotFound(this.sessionId)); + } + // Throws invalid params for content that Claude cannot take. + const request: PromptRequest = v1PromptRequest(params); + const remoteControl = this.remoteControl.mayRun(request.prompt); + const guarded = guard && !remoteControl ? guard : undefined; + // A prompt that the guard refuses titles nothing and sends no notice. + const sent = () => { + this.reports.promptSent(request.prompt); + // The notice of a mode fallback at the session's start, which sends it at once. + void this.config.promptSent(); + }; + if (!guarded) sent(); + const uuid = randomUUID(); + const message = { ...promptToClaude(request), uuid }; + // Only a prompt that goes to Claude Code gets a file-change report, as on + // v1: the adapter's `/remote-control` registers it once it passes the + // prompt on (below). + if (!remoteControl) this.fileChanges?.requested(uuid, params._meta); + const localCommand = this.commands.localCommand(request.prompt); + if (localCommand) this.localCommands.set(uuid, localCommand); + return new Promise((resolve, reject) => { + const pending: PendingPrompt = { prompt: params.prompt, pushed: false, resolve, reject }; + this.pending.set(uuid, pending); + // A cancel or close answered it before it went out. + const answered = () => this.pending.get(uuid) !== pending || this.closed; + this.pushes = this.pushes + .then(() => this.interrupting) + .then(async () => { + if (answered()) return; + if (guarded) { + try { + // Set by `start`, which runs before the agent lists the session. + await guarded(this.query!); + } catch (error) { + this.failPrompt(uuid, error); + return; + } + if (answered()) return; + sent(); + } + // The prompts after the adapter's `/remote-control` wait for it. + if (remoteControl) { + try { + if (await this.remoteControl.run(request.prompt, uuid, () => !answered())) return; + } catch (error) { + this.failPrompt(uuid, error); + return; + } + if (answered()) return; + this.fileChanges?.requested(uuid, params._meta); + } + pending.pushed = true; + this.remoteControl.pushed(uuid); + this.input.push(message); + this.reports.promptQueued(request.prompt); + }); + }); + } + + /** + * Whether no turn is open and no prompt waits in Claude Code's queue. With + * `restart`, for a restart of the query (see `restarts.ts`), also no + * prompt of the session waits to go to Claude Code, which the restart would + * answer with -32800, and no subagent runs. Without it, for the adapter's + * `/remote-control`, whose own prompt waits for the boundary. + */ + atTurnBoundary(restart: boolean): boolean { + return ( + !this.closed && + !this.foreground && + ![...this.pending.values()].some((prompt) => restart || prompt.pushed) && + !(restart && this.tasks.hasLiveSubagents()) ); } - return { - method: "load", - request: { ...request, mcpServers: servers ?? [] }, - }; -} -/** - * The v1 request of a v2 `session/fork`. In both versions the agent's fork - * returns a session that the client can prompt, with its config options. - */ -export function v1ForkSessionRequest(params: v2.ForkSessionRequest): ForkSessionRequest { - const { mcpServers, systemPrompt, ...request } = params; - return { - ...request, - ...(mcpServers ? { mcpServers: v1McpServers(mcpServers) } : {}), - ...v1SystemPromptField(systemPrompt), - }; -} - -/** - * The v1 `systemPrompt`. Its content converts like prompt content: a v2 - * annotation audience can be any string, and v1 takes only `user` or `assistant`. - */ -function v1SystemPromptField(systemPrompt: v2.SystemPrompt | null | undefined): { - systemPrompt?: SystemPrompt; -} { - if (systemPrompt == null) return {}; - return { systemPrompt: { ...systemPrompt, content: systemPrompt.content.map(v1ContentBlock) } }; -} - -/** v2 drops `modes`: the session mode is the `mode` config option, which the agent always lists. */ -export function v2NewSessionResponse(response: NewSessionResponse): v2.NewSessionResponse { - return { sessionId: response.sessionId, ...v2ResumeSessionResponse(response) }; -} - -export function v2ResumeSessionResponse(response: ResumeSessionResponse): v2.ResumeSessionResponse { - return { - ...v2ConfigOptionsField(response.configOptions), - ...(response._meta != null ? { _meta: response._meta } : {}), - }; -} - -/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */ -export function v1SetSessionConfigOptionRequest( - params: v2.SetSessionConfigOptionRequest, -): SetSessionConfigOptionRequest { - const request = { - sessionId: params.sessionId, - configId: params.configId, - ...(params._meta != null ? { _meta: params._meta } : {}), - }; - if (v2.SetSessionConfigOptionRequest.isId(params)) { - return { ...request, value: params.value }; + /** + * Resolves `true` at the next turn boundary (see {@link atTurnBoundary}), + * at once if the session is at one, and `false` once the session closed. + */ + turnBoundary(restart: boolean): Promise { + if (this.closed) return Promise.resolve(false); + if (this.atTurnBoundary(restart)) return Promise.resolve(true); + return new Promise((resolve) => this.boundaryWaiters.push({ restart, resolve })); } - if (v2.SetSessionConfigOptionRequest.isBoolean(params)) { - return { ...request, type: "boolean", value: params.value }; - } - throw RequestError.invalidParams( - { type: params.type }, - `Config option values of type ${params.type} are not supported`, - ); -} -/** v2 names the id of an option `configId` and the id of a select group `groupId`. */ -export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] { - return options.map((option) => { - const { id, ...rest } = option; - if (rest.type === "select") { - return { ...rest, configId: id, options: v2SelectOptions(rest.options) }; + private checkBoundary(): void { + for (const waiter of [...this.boundaryWaiters]) { + if (!this.closed && !this.atTurnBoundary(waiter.restart)) continue; + this.boundaryWaiters.splice(this.boundaryWaiters.indexOf(waiter), 1); + waiter.resolve(!this.closed); } - return { ...rest, configId: id }; - }); -} - -function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions { - if (!isGrouped(options)) return options; - return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group })); -} - -function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] { - return options.some((entry) => "group" in entry); -} - -/** - * v2 tags each command with `type: "command"` and its free-text input with - * `type: "text"`. A receiver drops a command without the `type` tag. - */ -export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] { - return commands.map(({ input, ...command }) => ({ - type: "command", - ...command, - ...(input != null ? { input: { type: "text", ...input } } : {}), - })); -} - -/** v1 responses may send `null`; v2 omits the field instead. */ -function v2ConfigOptionsField(options: SessionConfigOption[] | null | undefined): { - configOptions?: v2.SessionConfigOption[]; -} { - return options != null ? { configOptions: v2ConfigOptions(options) } : {}; -} - -/** - * The v1 list of the MCP servers of a v2 setup request, or undefined for no - * list. A request with two servers of the same `name`, or with a transport - * that the agent does not advertise in `session.mcp` (such as `acp` or a - * custom `_` type), fails with invalid params (-32602): the v1 session setup - * would otherwise drop the server without telling the client. - */ -function v1McpServers(servers: v2.McpServer[] | null | undefined): McpServer[] | undefined { - if (servers == null) return undefined; - v2.assertSupportedMcpServers(servers, V2_SESSION_CAPABILITIES.mcp); - return servers.map(v1McpServer); -} - -/** - * v2 requires a `type` on every transport and makes the empty lists optional. - * The agent recognizes a v1 stdio server by its missing `type`, so the v2 tag - * is dropped. {@link v1McpServers} has already rejected every other transport. - */ -function v1McpServer(server: v2.McpServer): McpServer { - if (v2.McpServer.isStdio(server)) { - const { name, command, args, env, _meta } = server; - return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) }; } - if (v2.McpServer.isHttp(server)) { - return { ...server, headers: server.headers ?? [] }; + + /** + * The adapter's idle compaction: `/compact`, as Claude Code's own turn. It + * is no prompt of the client's, so it gets no `user_message`, and the client + * sees its turn as `running`, then `idle`. Resolves when a turn ends after + * it: the idle compaction runs it only while the session is idle, so the + * next turn to end is its own. + */ + private compact(): Promise { + return new Promise((resolve) => { + this.ownCommandDone?.(); + this.ownCommandDone = resolve; + const message = { + ...promptToClaude({ + sessionId: this.sessionId, + prompt: [{ type: "text", text: "/compact" }], + }), + uuid: randomUUID(), + }; + this.pushes = this.pushes + .then(() => this.interrupting) + .then(() => { + if (this.closed) { + resolve(); + return; + } + this.remoteControl.pushed(message.uuid); + this.input.push(message); + }); + }); + } + + /** + * Sends Claude Code the adapter's own prompt `text`, after the interrupt of + * an earlier `session/cancel`, as {@link prompt} does. It is no prompt of + * the client's: it gets no `user_message`, also when its echo carries the + * human `origin` that `promptToClaude` stamps. + * + * These prompts continue the open turn (`clear-context.ts`), so they do not + * wait on {@link pushes}: a `/remote-control` there waits for the turn to + * end, which needs them. The client's prompts keep their order among + * themselves on `pushes`. + */ + private pushOwnPrompt(text: string, uuid: ReturnType): void { + const message = { + ...promptToClaude({ sessionId: this.sessionId, prompt: [{ type: "text", text }] }), + uuid, + }; + this.ownPushes = this.ownPushes + .then(() => this.interrupting) + .then(() => { + if (this.closed) return; + this.remoteControl.pushed(uuid); + this.input.push(message); + }); + } + + /** + * Stops the foreground work, and answers the prompts not inserted yet with + * -32800. The work's idle reports `cancelled`. Background work goes on. + * + * The interrupt goes out also while the session is idle: + * Claude Code may run a cycle that this session has not read yet. + */ + async cancel(): Promise { + if (this.closed || !this.query) return; + this.reports.disarmIdleCompaction("cancel"); + this.stopForeground(); + // `cancelQueued` is untyped in the SDK (`Query.interrupt` takes it since + // the CLI's `interrupt_cancel_queued_v1`): Claude Code then drops the + // queued prompts, which this session answered already. + const interrupt = this.query.interrupt as (options: { + cancelQueued: boolean; + }) => Promise; + const done = interrupt.call(this.query, { cancelQueued: true }).then( + () => undefined, + (error: unknown) => { + // `session/cancel` is a notification: no one can be answered. Nothing + // stopped, so the work's idle reports how it really ends. + this.connection.logger.error(`Session ${this.sessionId}: the interrupt failed`, error); + this.cancelledWork = false; + this.output.cancelled = false; + this.stopped.interruptFailed(); + this.tasks.foregroundCancelFailed(); + // The withdrawn requests reported no `running`: the work goes on. + this.reportState(); + }, + ); + this.interrupting = done; + await done; + if (this.interrupting === done) this.interrupting = undefined; + } + + /** + * What a cancel or close stops at once: the prompts not inserted yet, the + * open turn, and the work of the subagents that it waits on. The open + * permission requests stay open: Claude Code's interrupt withdraws them, + * and the end of the stopped work withdraws what is left (see + * {@link closeTurn}). + */ + private stopForeground(): void { + const pushed: string[] = []; + for (const [uuid, prompt] of [...this.pending]) { + if (prompt.pushed) pushed.push(uuid); + this.failPrompt(uuid, RequestError.requestCancelled()); + } + // A turn that waits for its plan's prompt in a fresh context: the + // adapter's prompts are stopped work too, and no frame of the turn's own + // prompt is left to end it. + const heldTurn = this.clearContext.holdsTurn(); + pushed.push(...this.clearContext.stop()); + this.stopped.cancelled(pushed, this.foreground); + if (this.foreground) { + this.cancelledWork = true; + this.output.cancelled = true; + } + for (const request of this.requests) request.coveredByCancel = true; + this.turnLocalCommand?.abort.abort(); + // The subagents that the work waits on stop with it. Background ones go on. + this.tasks.cancelForeground(); + if (heldTurn) this.closeTurn(); + } + + async setConfigOption( + params: v2.SetSessionConfigOptionRequest, + ): Promise { + await this.config.set(params); + if (params.configId === MODEL_CONFIG_ID) this.modelChangedReports(); + const options = this.config.options(); + // v2 sends every config change as an update too, for the session's other clients. + this.send({ sessionUpdate: "config_option_update", configOptions: options }); + return options; + } + + /** + * Cancels as `session/cancel` does, ends the open turn with its cancelled + * idle, then ends the query, which stops the background work too. Nothing + * of the session goes out after it resolves. It does not wait for Claude + * Code to end the work: the query's end stops it, and the + * tool calls that the work leaves open end `cancelled` before its idle. + */ + close(): Promise { + this.closing ??= (async () => { + if (!this.closed) this.stopForeground(); + // v2 asks to withdraw every request of the session before the close + // response, also its background subagents' and the MCP OAuth runs'. + this.tasks.withdrawAll(); + this.elicitations.close(); + for (const request of this.requests) { + request.coveredByCancel = true; + request.abort.abort(); + } + this.closeTurn(); + this.failPending(RequestError.requestCancelled()); + this.closed = true; + this.checkBoundary(); + this.endReports(); + this.input.end(); + this.query?.close(); + })(); + return this.closing; + } + + /** + * Ends the session's query, which a new one replaced (see `restarts.ts`). + * The session is idle then. Its background work ends with the query: its + * terminals exit and its listed tool calls end before the list drops them, + * as when a query ends. Then it closes as {@link close} does. + */ + retire(): Promise { + if (!this.closed && !this.closing) { + this.tasks.queryEnded({ + code: RequestError.internalError().code, + message: "The session's query restarted", + }); + } + return this.close(); + } + + // --- Permission requests ----------------------------------------------- + + get airClient(): boolean { + return this.connection.air.client; + } + + availableModes(): string[] { + return this.config.availableModeIds(); + } + + cancelling(): boolean { + return this.cancelledWork; + } + + dropsToolUse( + toolUseId: string, + agentId: string | undefined, + signal: AbortSignal, + ): Promise { + return this.stopped.dropsToolUse(toolUseId, agentId, signal); + } + + /** The session of a permission request: this one, or a subagent's (see `canUseTool`). */ + target(agentId: string | undefined): Promise { + return this.tasks.target(agentId, this); + } + + requestPermission( + request: v2.RequestPermissionRequest, + toolSignal: AbortSignal, + ): Promise { + return this.openRequest(toolSignal, async (signal) => { + try { + // The subject is an upsert of the tool call: the client may see the + // request before the tool use streams. + return await unlessWithdrawn(signal, () => + this.connection.requestPermission(request, signal), + ); + } catch (error) { + if (signal.aborted) return { outcome: { outcome: "cancelled" } }; + throw error; + } + }); + } + + /** Runs `send` as an open request of this session (see {@link RequestTarget.openRequest}). */ + async openRequest( + toolSignal: AbortSignal, + send: (signal: AbortSignal) => Promise, + ): Promise { + const open: OpenRequest = { abort: new AbortController(), coveredByCancel: false }; + // After `session/close` nothing of the session goes out: the request is + // withdrawn before it is sent. + if (this.closed) open.abort.abort(); + const signal = AbortSignal.any([toolSignal, open.abort.signal]); + this.requests.add(open); + this.reportState(); + try { + return await send(signal); + } finally { + this.requests.delete(open); + // A request withdrawn after a cancel (Claude Code's interrupt aborts + // `toolSignal`) ends work that is stopping: its idle follows, so no + // `running` comes first. One that the user answers, or that Claude Code + // withdraws without a cancel (a hook answered it), lets the work go on. + this.reportState(open.coveredByCancel && signal.aborted); + } + } + + modeChanged(mode: PermissionMode): void { + if (this.config.modeChanged(mode)) { + this.send({ sessionUpdate: "config_option_update", configOptions: this.config.options() }); + } + } + + prePlanMode(): string | undefined { + return this.config.prePlanMode(); + } + + /** A fresh context continues the open turn: only the root's plan in a turn offers it. */ + contextReset(agentId: string | undefined): { + offered: boolean; + usedPercent: number | undefined; + } { + return { + offered: agentId === undefined && this.foreground, + usedPercent: this.reports.contextUsedPercent(), + }; + } + + exitPlanInterrupting(toolUseId: string, reset: ClearContextReset | undefined): void { + // The fresh context starts in the mode that the answer's mode runs as on + // the model, such as `acceptEdits` for `auto` on a model without Auto, + // with no fallback notice. + this.clearContext.interrupting( + toolUseId, + reset && { ...reset, mode: this.config.effectiveMode(reset.mode) }, + ); + } + + permissionAllowed(result: PermissionResult): Promise { + return this.config.permissionAllowed(result); + } + + requestShows(target: RequestTarget, toolName: string, toolCallId: string): void { + this.tasks.requestShows(target.output, toolName, toolCallId); + } + + toolCallSubject(sessionId: string, toolCall: T): T { + return this.connection.translator.toolCallMeta.subject(sessionId, toolCall); + } + + /** + * Claude Code switched the model without the adapter: a typed `/model` + * (see `modelSwitchHook`), or a refusal fallback. + */ + modelSwitched(model: string): void { + this.config.modelSwitched(model).then( + (changed) => { + if (!changed) return; + this.modelChangedReports(); + this.send({ sessionUpdate: "config_option_update", configOptions: this.config.options() }); + }, + (error: unknown) => { + this.connection.logger.error( + `Session ${this.sessionId}: failed to follow a model switch`, + error, + ); + }, + ); + } + + /** The reports follow the model: its context window, and the idle compaction. */ + private modelChangedReports(): void { + const { id, info } = this.config.currentModel(); + this.reports.modelChanged(id, info); + } + + /** Claude Code reported a Fast mode state, on `system`/init or a result. */ + private fastModeReported(message: { + fast_mode_state?: FastModeState; + fast_mode_disabled_reason?: FastModeDisabledReason; + }): void { + if (this.config.fastModeReported(message.fast_mode_state, message.fast_mode_disabled_reason)) { + this.send({ sessionUpdate: "config_option_update", configOptions: this.config.options() }); + } + } + + /** + * Claude Code mounted a fresh conversation (such as for `/clear`), whose + * Task* ids and task store are independent of the previous transcript. The + * old conversation's plans are removed (`plan_removed` of the checklist and + * of the approved ExitPlanMode plan) before a follow-up prompt can + * republish stale tasks, and the title and the idle compaction start over. + * The fresh context of an approved plan removes no plan: that plan becomes + * the session's plan in it. + */ + private conversationReset(): void { + // First, the subagents of the dropped conversation end. + this.tasks.conversationReset(); + this.output.taskState.clear(); + if (!this.clearContext.holdsTurn()) { + // Every v2 client has plan operations. v2 publishes an approved plan + // under the plain `plan-mode` id, also to AIR (see `planModeUpdate`). + const updates = resetPlanUpdates(true, false); + this.output.emitV1(updates.map((update) => ({ sessionId: this.sessionId, update }))); + } + this.reports.conversationReset(); + // A fresh context for an approved plan: the plan goes out in it. + this.clearContext.conversationReset(); + } + + // --- The query's messages ---------------------------------------------- + + private async consume(query: Query): Promise { + try { + for await (const message of query) { + if (this.closed) break; + try { + await this.handle(message); + } catch (error) { + this.connection.logger.error( + `Session ${this.sessionId}: failed to handle a ${message.type} message`, + error, + ); + } + this.checkBoundary(); + } + this.ended(undefined); + } catch (error) { + this.ended(error); + } + } + + /** + * Claude Code's tool results. A SendMessage result can restore the agent + * that it resumed from the history: the results go out after the restore + * (see `SessionTasks.restoreResumed`). + */ + private toolResultMessage(message: SDKUserMessage): void | Promise { + const restoring = this.tasks.restoreResumed(this.output, message); + if (restoring) return restoring.then(() => this.toolResults(message)); + this.toolResults(message); + } + + private toolResults(message: SDKUserMessage): void { + if (message.parent_tool_use_id !== null) { + this.tasks.toolResults(this.output, message); + return; + } + // The rewrite applies to the updates of this message only. + this.output.rewrite = this.clearContext.toolResults(message); + try { + this.tasks.toolResults(this.output, message); + } finally { + this.output.rewrite = undefined; + } + } + + /** + * Handles one message. Only a `/mcp` turn's result, a result whose turn + * reads file-change reports, and a subagent's restore from the history are + * awaited (see {@link result}, {@link endTurn} and `SessionTasks.restored`). + */ + private handle(message: SDKMessage): void | Promise { + if ((message as { type: string }).type === "command_lifecycle") { + this.lifecycle(message as unknown as CommandLifecycleFrame); + return; + } + // Before the tasks take a subagent's messages: its file edits are the turn's. + this.fileChanges?.message(message); + // Task events, and a subagent's messages, which go to its session. + // A subagent that a task restores from the history is read before the next message. + if (this.tasks.handle(message)) return this.tasks.restored(); + switch (message.type) { + case "conversation_reset": + this.conversationReset(); + return; + case "stream_event": + if (this.stopped.drops(message)) return; + if (message.parent_tool_use_id === null) { + this.outputStarts(); + this.reports.streamEvent(message); + } + this.output.streamEvent(message); + return; + case "assistant": + if (message.parent_tool_use_id === null) this.usage.assistant(message.message.model); + if (this.stopped.drops(message)) return; + if (message.parent_tool_use_id === null) { + // Claude Code's sign-in message is not shown (see `TurnReport`). + if (this.turnReport.assistant(message)) return; + this.outputStarts(); + this.reports.assistant(message); + } + this.output.assistant(message); + return; + case "user": + // With `replay-user-messages`, Claude Code echoes each prompt it takes + // in. A prompt Claude Code reported `started` for is inserted already. + if ("isReplay" in message && message.isReplay) { + if (message.uuid && message.parent_tool_use_id === null) { + // A message typed in another client of the session. + const remote = this.remoteControl.remoteMessage(message); + if (remote !== undefined) this.output.userMessage(remote, message.uuid); + this.promptStarted(message.uuid); + } + return; + } + if (this.stopped.drops(message)) return; + return this.toolResultMessage(message); + case "result": + return this.result(message); + case "system": + switch (message.subtype) { + case "init": + if (message.capabilities?.includes("msg_lifecycle_v1")) this.lifecycleFrames = true; + this.elicitations.init(message.capabilities); + // A fresh init (such as after a reinitialize) can carry another Fast mode state. + this.fastModeReported(message); + this.reports.handle(message); + // Its terminal-bound commands leave the list. + if (this.commands.init(message)) this.resendCommands(); + return; + case "status": + // Each model request starts with one, which lists the prompts it serves. + if (message.status === "requesting") { + const prompts = (message as { user_message_uuids?: unknown }).user_message_uuids; + this.stopped.requesting(Array.isArray(prompts) ? prompts : []); + } + this.report(message); + return; + case "local_command_output": + // The output of a local command, such as `/cost`. + if (this.stopped.drops(message)) return; + this.output.agentMessage(message.content, message.uuid); + return; + case "commands_changed": + this.send(this.commands.changed(message.commands)); + return; + case "elicitation_complete": + this.elicitations.completed(message.elicitation_id); + return; + case "model_refusal_fallback": { + // Claude Code retried a refused turn on the fallback model. + const fallback = modelRefusalFallback(message); + this.output.emitV1([ + { sessionId: this.sessionId, update: noticeUpdate(fallback.notice) }, + ]); + if (fallback.persistent) this.modelSwitched(message.fallback_model); + return; + } + case "model_refusal_no_fallback": + this.turnReport.refusalWithoutFallback(message); + return; + case "permission_denied": + // A rule, the classifier or the mode denied a tool use before it ran. + this.tasks.permissionDenied(message); + return; + case "session_state_changed": + if (message.state === "idle") { + this.claudeCodeIdle(); + this.reports.idle(); + } + return; + default: + this.report(message); + return; + } + default: + this.report(message); + return; + } + } + + /** + * The messages that report on the session rather than carry Claude's + * output (see {@link SessionReports}). + */ + private report(message: SDKMessage): void { + if (message.type === "tool_progress") { + this.output.toolProgress(message); + return; + } + if (message.type === "system" && message.subtype === "memory_recall") { + this.output.memoryRecall(message); + return; + } + // A compaction runs in a turn: it opens the turn if nothing reported it yet. + if ( + message.type === "system" && + message.subtype === "status" && + message.status === "compacting" + ) { + this.openTurn(undefined); + } + this.reports.handle(message); + } + + private lifecycle(frame: CommandLifecycleFrame): void { + this.lifecycleFrames = true; + const uuid = frame.command_uuid; + switch (frame.state) { + case "started": + this.promptStarted(uuid); + return; + case "cancelled": + if (this.failPrompt(uuid, RequestError.requestCancelled())) return; + this.stopped.promptEnded(uuid, true); + // The prompt had started: the interrupt stopped its turn. + if (uuid === this.turnCommand) this.closeTurn(); + return; + case "discarded": + case "refused": + this.failPrompt( + uuid, + RequestError.internalError( + { lifecycle: frame.state }, + `Claude Code ${frame.state} the prompt before it ran`, + ), + ); + return; + case "completed": + // Comes after the turn's result. A turn that ended without one ends here. + this.stopped.promptEnded(uuid); + if (uuid === this.turnCommand) this.closeTurn(); + return; + default: + // `queued`: the prompt waits in Claude Code's queue. + return; + } + } + + /** + * Claude Code took in the prompt `uuid`: its `started` frame or its echo. + * The prompt's `user_message` comes before the turn's `running`. v2 allows + * either order. A prompt that a cancel abandoned starts + * nothing (see {@link StoppedWork}). + */ + private promptStarted(uuid: string): void { + if (this.stopped.promptStarted(uuid, this.foreground)) return; + // Claude Code takes its prompts in order: the work that a cancel stopped + // has ended, also when the cancel came while it ran nothing. + this.tasks.foregroundCancelEnded(); + if (this.clearContext.promptStarted(uuid)) this.fileChanges?.contextCleared(uuid); + this.inserted(uuid); + this.openTurn(uuid); + } + + private inserted(uuid: string): void { + const prompt = this.pending.get(uuid); + if (!prompt) return; + this.pending.delete(uuid); + this.fileChanges?.promptStarted(uuid); + this.send({ sessionUpdate: "user_message", messageId: uuid, content: prompt.prompt }); + prompt.resolve({ messageId: uuid }); + } + + /** + * Output of the main conversation while no turn is open starts a cycle + * that Claude Code started on its own. Without lifecycle frames, a pushed + * prompt's output can come before its echo, so output while one waits is + * that prompt's, and its echo opens the turn. + */ + private outputStarts(): void { + if (this.foreground) return; + if (!this.lifecycleFrames && [...this.pending.values()].some((prompt) => prompt.pushed)) { + return; + } + this.openTurn(undefined); + } + + private result(message: SDKResultMessage): void | Promise { + // Every result moves the usage reading on, also one that ends nothing. + const end = this.usage.record(message); + if (isPlaceholderResult(message)) return; + this.idleOwed = true; + const { dropped, interrupted } = this.stopped.result(message); + // Also a dropped result: the cancel's interrupt reached no further. + this.tasks.foregroundCancelEnded(); + if (dropped) { + this.turnReport.reset(); + return; + } + // The interrupt stopped work that Claude Code had started before the cancel arrived. + if (interrupted) this.cancelledWork = true; + // A result with no open turn: a turn that sent nothing before it. + this.openTurn(undefined); + // A cycle that Claude Code started on its own leaves its Fast mode state + // to the next result of a prompt. + if (!(message.origin && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind))) { + this.fastModeReported(message); + } + this.reports.result(message); + // The turn goes on in a fresh context (see `ClearContext`). + if (this.clearContext.holdsResult(message, end)) { + this.turnReport.reset(); + return; + } + this.lastEnd = this.clearContext.turnEnd(end); + const local = this.turnLocalCommand; + if (message.stop_reason === "refusal") { + // The refused message shows nothing: its explanation answers. + const explanation = this.turnReport.refusalExplanation; + if (explanation) this.output.agentMessage(explanation, message.uuid); + } else if ( + message.subtype === "success" && + !message.is_error && + message.result && + !this.output.hasAgentText() && + !this.reports.repeats(message.result) + ) { + // A local command (such as `/context`) answers with its result alone: + // no model text streamed for it. A result that only repeats a notice or + // a compaction of the turn is not shown again. + if (local && this.query) { + // The adapter's `/mcp` text replaces Claude Code's. `consume` waits + // for it, so it goes out before the turn's idle. + return this.commands + .replace(this.query, local.command, message.result, local.abort.signal, (error) => + this.connection.logger.error(`Session ${this.sessionId}: ${error}`), + ) + .then((text) => { + this.output.agentMessage(text, message.uuid); + return this.endTurn(); + }); + } + this.output.agentMessage(message.result, message.uuid); + } + return this.endTurn(); + } + + /** + * Ends the open turn at its result. The file-change reports that its + * prompts asked for go out first (see `file-changes.ts`). `consume` waits + * for them, so the idle follows them. + */ + private endTurn(): void | Promise { + if (!this.foreground || this.clearContext.holdsTurn()) return; + const { stopReason } = idleOutcome(this.lastEnd, this.cancelledWork, this.turnReport); + const reporting = this.fileChanges?.turnEnding(stopReason, this.query); + if (!reporting) return this.closeTurn(); + return reporting.then(() => this.closeTurn()); + } + + /** + * Claude Code's `idle`. The one that follows a result is that result's, and + * can come after the next cycle's first output. Any other ends the open + * turn, such as one that an interrupt stopped before it ran. + */ + private claudeCodeIdle(): void { + if (this.idleOwed) { + this.idleOwed = false; + return; + } + this.stopped.idle(); + this.tasks.foregroundCancelEnded(); + this.closeTurn(); + } + + /** Sends the commands again, without failing the message that changed them. */ + private resendCommands(): void { + this.sendCommands().catch((error) => { + this.connection.logger.error(`Session ${this.sessionId}: failed to send its commands`, error); + }); + } + + /** The query ended, with `error` when it failed. The session cannot go on. */ + private ended(error: unknown): void { + this.endReports(); + if (this.closing || this.closed) { + this.tasks.queryEnded(undefined); + this.failPending(RequestError.requestCancelled()); + this.closed = true; + return; + } + const failure = RequestError.internalError( + error instanceof Error ? { cause: error.message } : undefined, + "Claude Code stopped. Resume the session to continue.", + ); + this.failPending(failure); + this.connection.logger.error(`Session ${this.sessionId}: the query ended`, error); + this.tasks.queryEnded({ code: failure.code, message: failure.message }); + if (this.foreground) { + // Cancelled work that ended with the query was still cancelled. + if (this.cancelledWork) this.output.closeUnfinished(true); + else this.output.finishUnfinished("failed"); + this.fileChanges?.turnClosed(this.cancelledWork); + // As `closeTurn`: a compaction still `in_progress` ends before the idle. + this.reports.turnEnded(); + this.foreground = false; + this.reportedState = "idle"; + this.send({ + sessionUpdate: "state_update", + state: "idle", + ...(this.cancelledWork + ? idleOutcome(undefined, true, undefined, this.turnCommand !== undefined) + : { + stopReason: "error" as const, + error: { code: failure.code, message: failure.message }, + }), + }); + } + this.closed = true; + this.checkBoundary(); + this.connection.ended(this); + } + + /** The session ends: its idle compaction is off, and its own `/compact` settles. */ + private endReports(): void { + this.reports.dispose(); + this.ownCommandDone?.(); + this.ownCommandDone = undefined; + } + + // --- State --------------------------------------------------------------- + + /** + * Opens a turn, if none is open. `command` is the prompt that started it, + * if one did. + */ + private openTurn(command: string | undefined): void { + if (command !== undefined) { + this.turnCommand = command; + const local = this.localCommands.get(command); + this.localCommands.delete(command); + if (local) this.turnLocalCommand = { command: local, abort: new AbortController() }; + } + if (this.foreground) return; + this.foreground = true; + this.lastEnd = undefined; + this.output.resetAgentText(); + this.reports.turnStarted(); + this.reportState(); + } + + /** + * Ends the open turn: its idle goes out with the turn's outcome. A turn + * that waits for its plan's prompt in a fresh context goes on: its end is + * that prompt's. + */ + private closeTurn(): void { + if (!this.foreground || this.clearContext.holdsTurn()) return; + // v2 asks for a final status of the turn's tool calls before its idle. A + // tool call whose task goes on in the background is not part of the work. + const cancelled = idleOutcome(this.lastEnd, this.cancelledWork).stopReason === "cancelled"; + this.output.closeUnfinished(cancelled, this.tasks.backgroundCalls(this.sessionId)); + if (cancelled) { + // v2 asks to stop waiting for the stopped work's requests. Claude Code's + // interrupt withdraws them, and this withdraws what it left open. + for (const request of this.requests) { + if (request.coveredByCancel) request.abort.abort(); + } + } + this.reports.turnEnded(); + this.elicitations.turnEnded(); + this.fileChanges?.turnClosed(cancelled); + this.foreground = false; + this.reportState(); + this.cancelledWork = false; + this.output.cancelled = false; + this.lastEnd = undefined; + this.turnCommand = undefined; + this.turnReport.reset(); + const ownCommandDone = this.ownCommandDone; + this.ownCommandDone = undefined; + ownCommandDone?.(); + this.turnLocalCommand = undefined; + this.checkBoundary(); + } + + /** + * While the turn waits on subagents, it is blocked when each of them is + * (see `SessionTasks.foregroundBlocked`). Otherwise by the session's own + * open requests. + */ + private foregroundState(): ForegroundState { + if (!this.foreground) return "idle"; + const blocked = this.tasks.foregroundBlocked() ?? this.requests.size > 0; + return blocked ? "requires_action" : "running"; + } + + /** + * Reports the foreground state when it changed. `withdrawnByCancel`: the + * change is the end of a request that a cancel withdrew, so the work is + * stopping and its idle follows: no `running` goes out for it. + */ + private reportState(withdrawnByCancel = false): void { + if (this.closed) return; + const state = this.foregroundState(); + if (state === this.reportedState) return; + // The client keeps `requires_action`: the next report sends `running` + // if the work goes on after all, such as when the interrupt fails. + if (withdrawnByCancel && state === "running") return; + this.reportedState = state; + const update = this.stateUpdate(state); + this.send(update); + const error = (update as { error?: { code?: unknown } }).error; + if (state === "idle" && error?.code === RequestError.authRequired().code) { + this.connection.signedOut(this.sessionId); + } + } + + private stateUpdate(state: ForegroundState): v2.SessionUpdate { + switch (state) { + case "running": + return { sessionUpdate: "state_update", state }; + case "requires_action": + return { sessionUpdate: "state_update", state }; + default: + return { + sessionUpdate: "state_update", + state, + ...idleOutcome( + this.lastEnd, + this.cancelledWork, + this.turnReport, + this.turnCommand !== undefined, + ), + }; + } + } + + private send(update: v2.SessionUpdate): void { + this.sendNotification({ sessionId: this.sessionId, update }); + } + + /** Sends an update of the session or of a child, with its `background` list. */ + private sendNotification(notification: v2.UpdateSessionNotification): void { + if (this.closed) return; + this.connection.send(this.background.stamp(notification)); + } + + /** Fails the prompt `uuid` if it is not answered yet. Returns whether it was pending. */ + private failPrompt(uuid: string, error: unknown): boolean { + const prompt = this.pending.get(uuid); + if (!prompt) return false; + this.pending.delete(uuid); + this.localCommands.delete(uuid); + this.fileChanges?.promptFailed(uuid, error); + prompt.reject(error); + this.checkBoundary(); + return true; + } + + private failPending(error: unknown): void { + for (const uuid of [...this.pending.keys()]) this.failPrompt(uuid, error); } - throw new Error(`Unreachable: MCP server of type ${server.type} passed the transport check`); +} + +/** + * Sends a request to the client unless `signal` aborted already. The ACP SDK + * would send it anyway, then withdraw it with `$/cancel_request`. + */ +function unlessWithdrawn(signal: AbortSignal, send: () => Promise): Promise { + if (signal.aborted) return Promise.reject(new Error("The request was withdrawn")); + return send(); } diff --git a/src/v2/setup.ts b/src/v2/setup.ts new file mode 100644 index 00000000..267da971 --- /dev/null +++ b/src/v2/setup.ts @@ -0,0 +1,130 @@ +/** + * Session setup of the v2 core, on the v1 core's rules: the query + * options that `_meta.claudeCode.options` and the agent's environment add, + * the models a session offers, and the updates that a `session/resume` + * sends before its response. + */ +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import { + type HookCallbackMatcher, + type HookEvent, + type ModelInfo, + type Options, + type Query, +} from "@anthropic-ai/claude-agent-sdk"; +import type { Logger, NewSessionMeta } from "../acp-agent.js"; +import { + AIR_LOAD_REPLAY_END_CAPABILITY, + AIR_LOAD_REPLAY_END_KEY, + clientSupportsAirCapability, + withAirMeta, +} from "../air-extension.js"; +import { + aliasResumedModelRow, + applyAvailableModelsAllowlist, + getAvailableModels, + type SessionModelState, + withCurrentModelInfo, +} from "../session-model.js"; +import type { SettingsManager } from "../settings.js"; +import type { SetupParams } from "./restarts.js"; +import type { V2Session } from "./session.js"; + +/** The SDK options a client passes in `_meta.claudeCode.options`. */ +export function clientOptions(meta: unknown): Options | undefined { + return (meta as NewSessionMeta | undefined)?.claudeCode?.options; +} + +/** The core's hooks, after the client's own hooks of the same event. */ +export function withClientHooks( + client: Options["hooks"], + own: Partial>, +): Partial> { + const hooks = { ...client }; + for (const [event, matchers] of Object.entries(own) as [HookEvent, HookCallbackMatcher[]][]) { + hooks[event] = [...(client?.[event] ?? []), ...matchers]; + } + return hooks; +} + +/** + * The models of a session, as the v1 core picks them: the SDK's models, with + * a resumed session's model row under its family alias (`aliasResumedModelRow`), + * narrowed by the settings' `availableModels` allowlist and then by the + * provider (`narrow`), with the current model from `ANTHROPIC_MODEL`, the + * settings' `model`, or the transcript of a resumed session + * (`getAvailableModels`, which sets it on the query when needed), and the + * current model's capabilities, also for a resumed model outside the + * allowlist (`withCurrentModelInfo`). + */ +export async function sessionModels( + query: Query, + sdkModels: ModelInfo[], + settings: SettingsManager, + narrow: (models: ModelInfo[]) => ModelInfo[], + narrowedByProvider: boolean, + resumed: { model?: string } | undefined, + sessionId: string, + logger: Logger, +): Promise<{ + models: SessionModelState; + modelInfos: ModelInfo[]; + currentModelInfo: ModelInfo | undefined; + allowlistedModelInfo: ModelInfo | undefined; +}> { + const { availableModels, modelOverrides } = settings.getSettings(); + // A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new session does. + const listed = aliasResumedModelRow(sdkModels, resumed?.model); + const allowed = Array.isArray(availableModels) + ? applyAvailableModelsAllowlist( + listed, + availableModels, + modelOverrides, + settings.getManagedDeniedModels(), + ) + : listed; + const narrowed = narrow(allowed); + const models = await getAvailableModels( + query, + narrowed, + sdkModels, + settings, + logger, + resumed !== undefined, + sessionId, + resumed?.model, + narrowedByProvider, + ); + return { models, ...withCurrentModelInfo(narrowed, listed, models.currentModelId) }; +} + +/** + * The update that ends a replay, for a client that declares AIR's + * `loadReplayEnd` capability, as v1 sends it. + */ +export function replayEndUpdate( + capabilities: v2.ClientCapabilities | null | undefined, +): v2.SessionUpdate | undefined { + if (!clientSupportsAirCapability(capabilities, AIR_LOAD_REPLAY_END_CAPABILITY)) return undefined; + // No `title` and no `updatedAt`: `null` would clear them. + return { + sessionUpdate: "session_info_update", + _meta: withAirMeta(undefined, AIR_LOAD_REPLAY_END_KEY, true), + }; +} + +/** + * Starts OAuth for the setup request's MCP servers that need it, once the + * setup response is sent: v2 asks for no elicitation before it. The handler + * returns the response right after this call, and the SDK writes it before a + * timer runs. Nothing else orders the two. A restart calls it on the new + * query too: closing the old one withdrew the old one's OAuth runs. + */ +export function afterSetupResponse(session: V2Session, params: SetupParams): void { + // The setup already rejected servers of an unknown transport. + const names = (params.mcpServers ?? []).flatMap((server) => + typeof server.name === "string" ? [server.name] : [], + ); + if (names.length === 0) return; + setTimeout(() => session.elicitations.authenticateMcpServers(names), 0); +} diff --git a/src/v2/stopped-work.ts b/src/v2/stopped-work.ts new file mode 100644 index 00000000..cbf14896 --- /dev/null +++ b/src/v2/stopped-work.ts @@ -0,0 +1,246 @@ +/** + * The work of an ACP v2 session that a `session/cancel` stopped before + * the client saw it, and the reach of the cancel's interrupt. + * + * A cancel answers every prompt that is not inserted yet with -32800, also + * one that went to Claude Code already. Claude Code can still run such a + * prompt, when it had taken it in before the interrupt arrived. The client + * was told the prompt never ran, so that run is dropped: its output, its + * states, and its permission requests, which are denied. So is a model cycle + * that Claude Code had started on its own (a `status: requesting` frame came) + * and that showed nothing yet when the cancel came while the session was + * idle. + * + * The interrupt stops whatever Claude Code runs when it arrives, which can be + * a cycle that the session reads only after the cancel. That cycle ends with + * an aborted result, which reports `cancelled`. Only the next result can be + * that cycle's after a cancel of an idle session, and the next two after a + * cancel of open work (the open cycle can end before the interrupt arrives). + * A prompt that a cancel did not abandon starting, or an idle that follows no + * result, ends that reach sooner: a later aborted result, such as of an + * interrupt from Claude Code's remote control, reports as it would without + * the cancel. + */ +import type { + SDKAssistantMessage, + SDKPartialAssistantMessage, + SDKResultMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { isAbortedResult } from "../acp-agent.js"; + +/** + * How long `canUseTool` waits, after a cancel, for the session to read the + * tool use that it asks about, as on v1 (`TOOL_USE_WAIT_MS`). + */ +const TOOL_USE_WAIT_MS = 2_000; + +export class StoppedWork { + /** The prompts that a cancel answered with -32800 after they went to Claude Code. */ + private readonly abandoned = new Set(); + /** Whether Claude Code runs dropped work: its output goes nowhere. */ + private dropping = false; + /** The abandoned prompt whose run is dropped, when a prompt's run is. */ + private droppingFor: string | undefined; + /** The prompts that Claude Code took in since the last result. */ + private readonly startedSinceResult = new Set(); + /** + * Whether the late result of a dead turn may still come: Claude Code sent + * `cancelled` for a prompt that it had started, before any result. The v1 + * core calls such a prompt a zombie. + */ + private deadTurnResult = false; + /** + * The tool uses of the main conversation that the session read since the + * last result, each with whether it was dropped work's. + */ + private readonly toolUses = new Map(); + /** The `canUseTool` checks that wait for the session to read their tool use. */ + private readonly toolUseWaiters = new Map void>>(); + /** + * How many of the next results a cancel's interrupt can end, until Claude + * Code shows what it stopped: 2 after a cancel of open work, whose result + * can come before the interrupt arrives and stops the next cycle, and 1 + * after a cancel of an idle session (the cycle that Claude Code runs when + * it arrives). + */ + private interruptReach = 0; + /** + * Whether a model request started since the last result: a `status: + * requesting` frame, which starts each one. + */ + modelRequestSinceResult = false; + + /** + * A cancel: `pushed` are the prompts it answered that Claude Code has, and + * `foreground` whether the client sees work running. A cycle that Claude + * Code started while the client saw none is dropped. + */ + cancelled(pushed: readonly string[], foreground: boolean): void { + for (const uuid of pushed) this.abandoned.add(uuid); + if (!foreground && this.modelRequestSinceResult) this.dropping = true; + this.interruptReach = foreground ? 2 : 1; + } + + /** The interrupt failed: Claude Code stops nothing. */ + interruptFailed(): void { + this.interruptReach = 0; + } + + /** A `status: requesting` frame, with the prompts that the request serves. */ + requesting(prompts: readonly string[]): void { + this.modelRequestSinceResult = true; + const abandoned = prompts.find((uuid) => this.abandoned.has(uuid)); + if (abandoned !== undefined) this.drop(abandoned); + } + + /** + * Claude Code takes in the prompt `uuid` (its `started` frame or echo). + * Returns whether it is one that a cancel abandoned, which then adds + * nothing: its run is dropped, or, when work is open (`foreground`), Claude + * Code folded it into the work that the cancel stops. Another prompt's run + * is not dropped. + */ + promptStarted(uuid: string, foreground: boolean): boolean { + this.startedSinceResult.add(uuid); + if (!this.abandoned.has(uuid)) { + // Claude Code takes its prompts in order: the earlier ones ran or were + // dropped, and a dead turn's late result came before this prompt started. + // A prompt that a cancel did not abandon went to Claude Code after the + // cancel's interrupt, on the same stdin: the interrupt reached no + // further either. + this.abandoned.clear(); + this.endDropping(); + this.deadTurnResult = false; + this.interruptReach = 0; + return false; + } + if (!foreground) this.drop(uuid); + return true; + } + + /** + * Claude Code ended the prompt `uuid` (`completed`, or `cancelled` when + * `cancelled`). Only the end of the prompt whose run is dropped ends the + * drop: another abandoned prompt's `cancelled` frame, which the interrupt's + * `cancelQueued` sends, does not. + */ + promptEnded(uuid: string, cancelled = false): void { + // `cancelled` after `started` and before a result: the turn died, and its + // result can still come after this frame. That result ends nothing. + if (cancelled && this.startedSinceResult.delete(uuid)) this.deadTurnResult = true; + if (this.abandoned.delete(uuid) && this.droppingFor === uuid) this.endDropping(); + } + + /** + * Whether `message`, output of the main conversation, is dropped work's. + * Records the tool uses it asks to run. + */ + drops(message: { type: string; message?: unknown; event?: unknown }): boolean { + for (const id of toolUseIds(message)) { + this.toolUses.set(id, this.dropping); + const waiters = this.toolUseWaiters.get(id); + this.toolUseWaiters.delete(id); + for (const waiter of waiters ?? []) waiter(); + } + return this.dropping; + } + + /** + * Whether `canUseTool` denies the tool use `toolUseId` of the agent + * `agentId` (undefined for the main conversation): dropped work asks to + * run it. Claude Code asks on its control channel after it streamed the + * tool use, and the session can read the stream later. So after a cancel, + * while dropped work may run, this waits until the session reads the tool + * use, for at most {@link TOOL_USE_WAIT_MS} or until `signal` aborts: the + * stream never shows some tool uses, such as SandboxNetworkAccess's. + */ + async dropsToolUse( + toolUseId: string, + agentId: string | undefined, + signal: AbortSignal, + ): Promise { + if (agentId !== undefined) return false; + const read = this.toolUses.get(toolUseId); + if (read !== undefined) return read; + if ((this.abandoned.size === 0 && !this.dropping) || signal.aborted) return false; + await new Promise((resolve) => { + const waiters = this.toolUseWaiters.get(toolUseId) ?? new Set<() => void>(); + this.toolUseWaiters.set(toolUseId, waiters); + const done = () => { + clearTimeout(timer); + signal.removeEventListener("abort", done); + waiters.delete(done); + if (waiters.size === 0 && this.toolUseWaiters.get(toolUseId) === waiters) { + this.toolUseWaiters.delete(toolUseId); + } + resolve(); + }; + const timer = setTimeout(done, TOOL_USE_WAIT_MS); + signal.addEventListener("abort", done, { once: true }); + waiters.add(done); + }); + return this.toolUses.get(toolUseId) ?? this.dropping; + } + + /** + * A result that is not a placeholder. Returns whether it ends dropped work, + * or is the late result of a dead turn (see {@link promptEnded}), and + * whether it ends work that the interrupt stopped (an aborted result after + * a cancel). + */ + result(result: SDKResultMessage): { dropped: boolean; interrupted: boolean } { + this.modelRequestSinceResult = false; + this.startedSinceResult.clear(); + // Claude Code asks for a tool use before the result that ends its turn. + this.toolUses.clear(); + // An aborted result shows what the interrupt stopped. Another one uses up + // one result of its reach (see `interruptReach`). + const aborted = this.interruptReach > 0 && isAbortedResult(result); + this.interruptReach = aborted ? 0 : Math.max(0, this.interruptReach - 1); + const dropped = this.dropping || this.deadTurnResult; + this.deadTurnResult = false; + this.endDropping(); + return { dropped, interrupted: aborted && !dropped }; + } + + /** + * Claude Code went idle, other than right after a result: what it ran has + * ended, and so has the interrupt's reach. + */ + idle(): void { + this.interruptReach = 0; + this.modelRequestSinceResult = false; + // A dead turn's result comes before Claude Code goes idle, or never. + this.deadTurnResult = false; + this.startedSinceResult.clear(); + this.endDropping(); + } + + /** Drops the run of the abandoned prompt `uuid`. */ + private drop(uuid: string): void { + this.dropping = true; + this.droppingFor = uuid; + } + + private endDropping(): void { + this.dropping = false; + this.droppingFor = undefined; + } +} + +/** The ids of the tool uses in an assistant message, or in a streamed tool_use block's start. */ +function toolUseIds(message: { type: string; message?: unknown; event?: unknown }): string[] { + if (message.type === "assistant") { + const content = (message as SDKAssistantMessage).message.content; + return Array.isArray(content) + ? content.flatMap((block) => (block.type === "tool_use" ? [block.id] : [])) + : []; + } + if (message.type === "stream_event") { + const event = (message as SDKPartialAssistantMessage).event; + if (event.type === "content_block_start" && event.content_block.type === "tool_use") { + return [event.content_block.id]; + } + } + return []; +} diff --git a/src/v2/stream.ts b/src/v2/stream.ts new file mode 100644 index 00000000..4c481445 --- /dev/null +++ b/src/v2/stream.ts @@ -0,0 +1,633 @@ +/** + * The output of one Claude Code session, as ACP session updates. + * + * {@link SessionOutput} maps the SDK's content messages (stream events, + * consolidated assistant messages, tool results) through the shared mapping + * of `acp-agent.ts` (`toAcpNotifications`), whose v1 updates the connection's + * {@link V2UpdateTranslator} turns into v2 updates. It knows nothing about + * prompts or turns: the session decides what the output belongs to. + */ +import type { + ClientCapabilities as AcpClientCapabilities, + ToolCallContent, +} from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { ContentBlock } from "@agentclientprotocol/sdk/unstable"; +import type { + SDKAssistantMessage, + SDKMemoryRecallMessage, + SDKPartialAssistantMessage, + SDKPermissionDeniedMessage, + SDKToolProgressMessage, + SDKUserMessage, + SessionMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import { + isReplayHiddenMetaMessage, + isSyntheticLoginMessage, + messageIdForGrouping, + stripLocalCommandMetadata, + streamEventToAcpNotifications, + toAcpNotifications, + type AcpClient, + type Logger, + type StreamedToolInputCache, + type ToolUseCache, +} from "../acp-agent.js"; +import type { AcpSessionNotification } from "../acp-subagents.js"; +import { isCompactSummaryMessage, replayCompactSummary } from "../context-compaction.js"; +import { ClientCapabilities } from "../tool-calls/client-capabilities.js"; +import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js"; +import { AcpToolCallRenderer, V2_CANCELLED } from "../tool-calls/renderer.js"; +import { changedTaskPlanEntries, unregisterHookCallback, type TaskState } from "../tools.js"; +import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js"; +import { replayClear, type V2UpdateTranslator } from "./update-translator.js"; + +/** Every v2 client gets the same tool call reports: v2 diffs and display terminals. */ +const V2_TOOL_CALL_CAPABILITIES = ClientCapabilities.from(undefined, { v2: true }); + +/** + * The v1 capabilities the shared mapping reads. Every v2 client takes + * `plan_update` with a markdown plan, so an approved ExitPlanMode's plan + * goes out as one. Every v2 client also gets a session per subagent + * (`tasks.ts`), so a subagent's TodoWrite checklist is that session's `plan` + * rather than `plan:` in its parent's session. + */ +const V2_CLIENT_CAPABILITIES = { plan: {}, subagents: {} } as AcpClientCapabilities; + +type AssistantContent = SDKAssistantMessage["message"]["content"]; +type UserContent = SDKUserMessage["message"]["content"]; + +/** + * Rewrites a v1 update of the output before it is translated: the updates it + * returns go out instead, such as none for the Agent call of a child session + * (see `tasks.ts`). + */ +export type OutputRoute = (notification: AcpSessionNotification) => AcpSessionNotification[]; + +/** What a replay asks of the session's subagents (see `subagent-replay.ts`). */ +export interface ReplayHooks { + /** Rewrites each replayed update, as {@link OutputRoute} does live. */ + route: OutputRoute; + /** A message of the history is about to replay, such as a child's response with its model. */ + replaying?(message: SessionMessage): void; + /** A message of the history was replayed, such as one that launched subagents. */ + replayed(message: SessionMessage): void; + /** + * The part of a user message's content to replay, or `null` for none. A + * child's prompt replays as a `session_message` instead. + */ + userContent?(message: SessionMessage, content: unknown): unknown; +} + +export class SessionOutput { + private readonly toolUseCache: ToolUseCache = {}; + private readonly emittedToolCalls = new Set(); + /** What the client holds of each open tool call: an update carries only what changed. */ + private readonly toolCallFields = new ToolCallFieldTracker(); + private readonly streamedToolInputs: StreamedToolInputCache = new Map(); + /** The session's Task* list (TaskCreate, TaskUpdate, TaskList), sent as plan updates. */ + readonly taskState: TaskState = new Map(); + /** The text and thinking that streamed as deltas since the last message started or ended. */ + private readonly streamedBlocks: StreamedBlock[] = []; + /** The id of the assistant message that is streaming. */ + private streamingMessageId: string | undefined; + /** Whether any agent text went out since {@link resetAgentText}. */ + private agentText = false; + /** During {@link replay}, the messages that it has cleared so far. */ + private replayCleared: Set | undefined; + /** The open tool calls whose tool use reached a complete assistant message: Claude Code runs them. */ + private readonly dispatched = new Set(); + /** + * Whether the output comes from work that a `session/cancel` stopped, until + * its idle. The session's streamed deltas still go out, + * and of its consolidated messages only the tool results of the calls that + * the client holds, where an error result ends a run that the cancel + * aborted (`cancelled`). On the root, this covers the main conversation's + * messages. A foreground subagent's session sets it at the root's cancel, + * until its run ends (see `SessionTasks.cancelForeground`). + */ + cancelled = false; + + /** Whether `message` is of the work that a cancel stopped (see {@link cancelled}). */ + private stoppedWork(message: { parent_tool_use_id: string | null }): boolean { + return this.cancelled && (this.subagent || message.parent_tool_use_id === null); + } + /** During {@link replay}, the replayed tool uses, apart from the live ones. */ + private replayToolUses: ToolUseCache | undefined; + /** During {@link replay}, what the session's subagents ask of it. */ + private replayHooks: ReplayHooks | undefined; + /** During {@link replay}, the replayed tool calls without a final status. */ + private replayUnfinished: Set | undefined; + /** Set by the session's tasks: rewrites the live updates (see {@link OutputRoute}). */ + route: OutputRoute | undefined; + /** Set by the session around one message: rewrites its updates before {@link route}. */ + rewrite: ((notification: AcpSessionNotification) => AcpSessionNotification) | undefined; + + constructor( + readonly sessionId: string, + private readonly cwd: string, + private readonly translator: V2UpdateTranslator, + private readonly send: (update: v2.SessionUpdate) => void, + private readonly logger: Logger, + /** + * Whether this is a subagent's session. Its messages keep the + * `parent_tool_use_id` of the Agent call that launched it, and its text + * streams (`forwardSubagentText`). + */ + private readonly subagent = false, + ) {} + + /** Tool updates that a hook callback sends outside the stream (an Edit's diff). */ + private readonly hookClient = { + sessionUpdate: async (notification: AcpSessionNotification) => this.emit([notification]), + } as unknown as AcpClient; + + /** A partial assistant message: one event of the streaming API response. */ + streamEvent(message: SDKPartialAssistantMessage): void { + // A subagent's stream goes to its own session (see `tasks.ts`). + if (message.parent_tool_use_id !== null && !this.subagent) return; + if (message.event.type === "message_start") { + this.streamingMessageId = message.event.message.id; + // A message that never reached its consolidated message, such as one an + // interrupt cut short, leaves its record behind. + this.streamedBlocks.length = 0; + } + recordStreamedDelta(this.streamedBlocks, message.event); + this.emit( + streamEventToAcpNotifications( + message, + this.sessionId, + this.toolUseCache, + this.hookClient, + this.logger, + { + toolCallCapabilities: V2_TOOL_CALL_CAPABILITIES, + cwd: this.cwd, + emittedToolCalls: this.emittedToolCalls, + toolCallFields: this.toolCallFields, + messageId: this.streamingMessageId, + streamedToolInputs: this.streamedToolInputs, + }, + ), + ); + } + + /** + * A consolidated assistant message. Of its text and thinking, only what did + * not stream as deltas goes out (`unstreamedRemainder`): + * the tail of a block whose stream was cut short, or the whole message when + * it never streamed (a gateway without streaming). Its tool uses then get + * their complete input. A subagent's message on the root session goes + * without its text, which belongs to the subagent's own session. After a + * cancel, the stopped work's message is dropped (see {@link cancelled}). + */ + assistant(message: SDKAssistantMessage): void { + const messageId = messageIdForGrouping(message); + for (const block of message.message.content) { + if (block.type === "tool_use") this.dispatched.add(block.id); + } + const ownText = message.parent_tool_use_id === null || this.subagent; + const content = ownText + ? unstreamedRemainder(message.message.content, this.streamedBlocks) + : message.message.content.filter( + (block) => block.type !== "text" && block.type !== "thinking", + ); + if (ownText) this.streamedBlocks.length = 0; + if (this.stoppedWork(message)) return; + this.content(content, "assistant", message.parent_tool_use_id, { messageId }); + } + + /** + * A user message that Claude Code adds: the results of tool uses. Its other + * blocks (command output, reminders, a prompt's echo) are not output. + * `transform` rewrites each update before it goes out, such as a background + * command's result, whose terminal keeps running. + */ + toolResults( + message: SDKUserMessage, + transform: (notification: AcpSessionNotification) => AcpSessionNotification = (n) => n, + ): void { + if (!Array.isArray(message.message.content)) return; + const all = message.message.content.filter((block) => block.type === "tool_result"); + // After a cancel, only the results of the calls that the client holds (see {@link cancelled}). + const results = this.stoppedWork(message) + ? all.filter((block) => + this.emittedToolCalls.has((block as { tool_use_id: string }).tool_use_id), + ) + : all; + if (results.length === 0) return; + this.content( + results, + "user", + message.parent_tool_use_id, + { + // `tool_use_result` describes the one result of its message. + toolUseResult: results.length === all.length ? message.tool_use_result : undefined, + toolResultMeta: (message as { tool_result_meta?: unknown }).tool_result_meta, + cancelled: this.cancelled, + }, + transform, + ); + } + + /** + * Shows `content`, the exact patch of an Edit or Write, on its tool call + * before the tool runs (see `file-preview.ts`). The streamed input's diff + * does not replace the patch. A hook can come before the streamed tool_use, + * and a workflow's tools run hooks without ever streaming their use or + * result: the patch then waits for the tool call's first publication (by the + * stream or a permission request), instead of showing a tool call that would + * never end. + */ + preview(toolCallId: string, content: ToolCallContent[]): void { + if (!this.emittedToolCalls.has(toolCallId)) { + this.toolCallFields.stagePreview(toolCallId, content); + return; + } + const update = { sessionUpdate: "tool_call_update" as const, toolCallId, content }; + if (!this.toolCallFields.apply(update)) return; + this.toolCallFields.pinContent(toolCallId, content); + this.emit([{ sessionId: this.sessionId, update }]); + } + + /** Whether the stream mapped the tool call `toolCallId`, shown or routed away. */ + holds(toolCallId: string): boolean { + return this.emittedToolCalls.has(toolCallId); + } + + /** The tool uses of the session's tool calls, by id. */ + toolUses(): Readonly { + return this.toolUseCache; + } + + /** Sends v1-shaped updates of the session, as the shared mapping makes them. */ + emitV1(notifications: AcpSessionNotification[]): void { + this.emit(notifications); + } + + /** + * A tool's progress beat, on its tool call: the beat's own `tool_use_id` + * when the client holds that call, else the call it runs in + * (`parent_tool_use_id`), such as the Agent call of a subagent's tool or + * a heartbeat's derived id. A beat for a call that returned is dropped. + */ + toolProgress(message: SDKToolProgressMessage): void { + const toolCallId = this.emittedToolCalls.has(message.tool_use_id) + ? message.tool_use_id + : message.parent_tool_use_id; + if (toolCallId === null || !this.emittedToolCalls.has(toolCallId)) return; + // A subagent's beat, on its own session, names the Agent call that runs + // it. A tool call is never its own parent. + const parentToolUseId = + this.subagent && message.parent_tool_use_id !== toolCallId + ? (message.parent_tool_use_id ?? undefined) + : undefined; + const beat = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).progress({ + toolCallId, + toolName: message.tool_name, + parentToolUseId, + elapsedTimeSeconds: message.elapsed_time_seconds, + subagentType: message.subagent_type, + subagentRetry: message.subagent_retry, + }); + if (this.toolCallFields.apply(beat)) this.emit([{ sessionId: this.sessionId, update: beat }]); + } + + /** Claude Code recalled memories: a completed read tool call. */ + memoryRecall(message: SDKMemoryRecallMessage): void { + this.emit([ + { + sessionId: this.sessionId, + update: new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).memoryRecall(message), + }, + ]); + } + + /** The Task* list changed outside a tool result (the TaskCreated and TaskCompleted hooks). */ + taskPlanChanged(): void { + const entries = changedTaskPlanEntries(this.taskState, false); + if (entries) + this.emit([{ sessionId: this.sessionId, update: { sessionUpdate: "plan", entries } }]); + } + + /** + * The tool call of a permission request that the stream never shows, such + * as SandboxNetworkAccess's, before the request: the client holds it, and + * {@link toolCallStatus} ends it once the request is answered, as on v1 + * (`ensureToolCallEmitted`). + */ + permissionToolCall(toolName: string, toolCallId: string, input: unknown): void { + if (this.emittedToolCalls.has(toolCallId)) return; + this.emittedToolCalls.add(toolCallId); + const update = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).toolCall( + { id: toolCallId, name: toolName, input }, + { cwd: this.cwd }, + ); + this.toolCallFields.apply(update); + this.emit([{ sessionId: this.sessionId, update }]); + } + + /** + * The tool call that the adapter starts for a background task that no tool + * call stands for (see `SessionTasks.adoptTasks`). It ends with the task. + */ + taskToolCall( + update: Extract, + ): void { + this.emittedToolCalls.add(update.toolCallId); + this.toolCallFields.apply(update); + this.emit([{ sessionId: this.sessionId, update }]); + } + + /** A workflow's latest progress on its tool call, as its title and body (`workflowCard`). */ + taskToolCallProgress( + toolCallId: string, + card: { title: string; content: ToolCallContent[] }, + ): void { + this.emit([ + { + sessionId: this.sessionId, + update: { sessionUpdate: "tool_call_update", toolCallId, ...card }, + }, + ]); + } + + /** + * Claude Code denied a tool use without asking (a rule, the classifier or + * the mode): its tool call fails with the reason. A tool + * call that the client does not hold, such as dropped work's, is left out. + */ + permissionDenied(message: SDKPermissionDeniedMessage, parentToolUseId?: string): void { + // Its PostToolUse hook never runs. + unregisterHookCallback(message.tool_use_id); + if (!this.emittedToolCalls.has(message.tool_use_id)) return; + const denied = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).permissionDenied({ + toolCallId: message.tool_use_id, + toolName: message.tool_name, + ...(parentToolUseId !== undefined ? { parentToolUseId } : {}), + decisionReasonType: message.decision_reason_type, + decisionReason: message.decision_reason, + message: message.message, + }); + // A denial is final, so it replaces a pinned approval patch. + if (this.toolCallFields.apply(denied, { replacePinnedContent: true })) { + this.emit([{ sessionId: this.sessionId, update: denied }]); + } + } + + /** Sets the status of a tool call, such as a background task's when it ends. */ + toolCallStatus(toolCallId: string, status: "in_progress" | "paused" | ToolCallFinalStatus): void { + if (FINAL_STATUSES.has(status)) this.unfinished.delete(toolCallId); + this.send({ sessionUpdate: "tool_call_update", toolCallId, status } as v2.SessionUpdate); + } + + /** + * Replays the stored history of the session: its prompts, Claude's + * messages and its tool calls, as the live session sent them. Subagent + * transcripts are left out, as live. The client may hold the replayed + * messages already, so each message is cleared before its first chunk. + * `hooks` replays the subagents' own histories to their sessions. + * + * A replayed tool call that the history gives no result, such as one that + * ran when Claude Code was killed, ends `failed` after the replay, as v2 + * asks of replayed work that no longer runs. The calls that the live + * session holds open, and those in `live` (its background work), go on. + * `running` rewrites the replayed results of that background work as live, + * so that they do not end it (see `SessionTasks.replayedBackground`). + */ + replay( + messages: SessionMessage[], + hooks?: ReplayHooks, + live: ReadonlySet = new Set(), + running?: (notification: AcpSessionNotification) => AcpSessionNotification, + ): void { + this.replayCleared = new Set(); + this.replayHooks = hooks; + const unfinished = (this.replayUnfinished = new Set()); + try { + for (const message of messages) { + if (message.parent_tool_use_id !== null || isReplayHiddenMetaMessage(message)) continue; + // Claude Code's sign-in message, which the live session did not show either. + if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) continue; + // A compaction's summary message replays as the completed compaction. + // `emit` sends at once, before the call returns, so the update keeps + // its place among the replayed messages. + if (isCompactSummaryMessage(message) && message.uuid) { + void replayCompactSummary(message, async (n) => this.emit([n]), { + sessionId: this.sessionId, + }); + continue; + } + hooks?.replaying?.(message); + const inner = message.message as { id?: string; content?: unknown }; + if (message.type === "assistant" && Array.isArray(inner.content)) { + this.content(inner.content as AssistantContent, "assistant", null, { + messageId: inner.id ?? message.uuid, + replay: true, + }); + } else if (message.type === "user" && inner.content !== undefined) { + // `null`: the message was command metadata alone. + let content = stripLocalCommandMetadata(inner.content); + if (content !== null && hooks?.userContent) content = hooks.userContent(message, content); + if (content === null) continue; + this.content( + content as UserContent, + "user", + null, + { messageId: message.uuid, replay: true }, + running, + ); + } + hooks?.replayed(message); + } + } finally { + this.replayCleared = undefined; + this.replayToolUses = undefined; + this.replayHooks = undefined; + this.replayUnfinished = undefined; + } + for (const toolCallId of unfinished) { + if (this.unfinished.has(toolCallId) || live.has(toolCallId)) continue; + this.send({ sessionUpdate: "tool_call_update", toolCallId, status: "failed" }); + } + } + + /** Agent text that is not a model message, such as a local command's result. */ + agentMessage(text: string, messageId: string): void { + this.content(text, "assistant", null, { messageId }); + } + + /** + * A user message that this session did not send, such as one typed in + * another client over Remote Control: rendered as a replayed one is, + * without its command metadata. + */ + userMessage(content: unknown, messageId: string): void { + const shown = stripLocalCommandMetadata(content); + if (shown !== null) this.content(shown as UserContent, "user", null, { messageId }); + } + + /** Whether agent text went out since the last {@link resetAgentText}. */ + hasAgentText(): boolean { + return this.agentText; + } + + resetAgentText(): void { + this.agentText = false; + } + + private content( + content: Parameters[0], + role: "assistant" | "user", + parentToolUseId: string | null, + options: { + messageId?: string; + toolUseResult?: unknown; + toolResultMeta?: unknown; + cancelled?: boolean; + replay?: boolean; + }, + transform: (notification: AcpSessionNotification) => AcpSessionNotification = (n) => n, + ): void { + // A replay renders the history on its own: each + // replayed tool call goes out whole, whatever the live session sent. + const live = !options.replay; + this.emit( + toAcpNotifications( + content, + role, + this.sessionId, + live ? this.toolUseCache : (this.replayToolUses ??= {}), + this.hookClient, + this.logger, + { + clientCapabilities: V2_CLIENT_CAPABILITIES, + toolCallCapabilities: V2_TOOL_CALL_CAPABILITIES, + cwd: this.cwd, + ...(live + ? { emittedToolCalls: this.emittedToolCalls, toolCallFields: this.toolCallFields } + : {}), + taskState: this.taskState, + parentToolUseId, + // A replayed tool use runs no hook that could report its result. + registerHooks: !options.replay, + ...options, + }, + ).map(transform), + ); + } + + /** + * Gives each tool call that has no final status yet the final `status`, + * except those in `keep` (background work, which goes on). For the end of + * stopped work: v2 asks for a final status of its tool calls before the idle. + * With `exitTerminals`, a command's terminal exits with its call, with no + * exit code. Returns the ids of the calls it ended. + */ + finishUnfinished( + status: "failed" | "cancelled", + keep: ReadonlySet = new Set(), + { exitTerminals = false }: { exitTerminals?: boolean } = {}, + ): string[] { + const finished: string[] = []; + for (const toolCallId of [...this.unfinished]) { + if (keep.has(toolCallId)) continue; + this.unfinished.delete(toolCallId); + finished.push(toolCallId); + if (!exitTerminals) { + this.send({ sessionUpdate: "tool_call_update", toolCallId, status }); + continue; + } + // The translator ignores `terminal_exit` for a call without a terminal. + this.emit([ + { + sessionId: this.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId, + status: status === "cancelled" ? V2_CANCELLED : status, + _meta: { terminal_exit: { terminal_id: toolCallId, exit_code: null, signal: null } }, + }, + }, + ]); + } + return finished; + } + + /** + * Ends the open tool calls that will get no result, for the end of the work + * (see `AcpToolCallRenderer.unfinished`), before its idle. After a cancel + * (`stopped`), every open call ends `cancelled`. Otherwise only the calls + * that Claude dropped before they ran end, `cancelled` too: a streamed + * tool_use that no complete message holds. A call that runs, such as one in + * the background, gets Claude Code's result. A command's report also ends + * its terminal. The call is forgotten, so a late result changes nothing. + * The calls in `keep` stay open, such as one whose task runs in the background. + */ + closeUnfinished(stopped: boolean, keep: ReadonlySet = new Set()): void { + const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES); + for (const toolCallId of [...this.unfinished]) { + if (keep.has(toolCallId)) continue; + const ran = this.dispatched.has(toolCallId); + if (ran && !stopped) continue; + const report = renderer.unfinished({ toolCallId, ran, cancelled: stopped }); + const toolUse = this.toolUseCache[toolCallId]; + const terminalExit = toolUse && renderer.terminalExit(toolUse); + if (terminalExit) report._meta = { ...report._meta, ...terminalExit }; + this.emittedToolCalls.delete(toolCallId); + delete this.toolUseCache[toolCallId]; + this.toolCallFields.delete(toolCallId); + this.emit([{ sessionId: this.sessionId, update: report }]); + } + } + + /** The tool calls that the client holds without a final status. */ + private readonly unfinished = new Set(); + + /** Called with each live agent text chunk, for the session's title. */ + onAgentText: ((content: ContentBlock) => void) | undefined; + + private emit(notifications: AcpSessionNotification[]): void { + // A replay routes only what its hooks ask: the live routing tracks live calls. + const route = this.replayCleared !== undefined ? this.replayHooks?.route : this.route; + // Each update is routed once the ones before it went out: a route can + // send updates of its own, such as a child's announcement or a + // hand-back's two messages, which then keep their place. + for (const original of notifications) { + const notification = this.rewrite ? this.rewrite(original) : original; + for (const { update } of route ? route(notification) : [notification]) this.sendOne(update); + } + } + + private sendOne(update: AcpSessionNotification["update"]): void { + const replaying = this.replayCleared !== undefined; + if (update.sessionUpdate === "agent_message_chunk") { + this.agentText = true; + if (!replaying) this.onAgentText?.(update.content); + } + for (const v2Update of this.translator.updates(this.sessionId, update, replaying)) { + const clear = this.replayCleared && replayClear(this.replayCleared, v2Update); + if (clear) this.send(clear); + if (v2Update.sessionUpdate === "tool_call_update") { + const { toolCallId, status } = v2Update as { toolCallId: string; status?: string | null }; + if (status != null && replaying) { + if (FINAL_STATUSES.has(status)) this.replayUnfinished?.delete(toolCallId); + else this.replayUnfinished?.add(toolCallId); + } else if (status != null) { + if (FINAL_STATUSES.has(status)) { + this.unfinished.delete(toolCallId); + this.dispatched.delete(toolCallId); + } else { + this.unfinished.add(toolCallId); + } + } + } + this.send(v2Update); + } + } +} + +type ToolCallFinalStatus = "completed" | "failed" | "cancelled"; + +const FINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); diff --git a/src/v2/subagent-replay.ts b/src/v2/subagent-replay.ts new file mode 100644 index 00000000..2fbd70f9 --- /dev/null +++ b/src/v2/subagent-replay.ts @@ -0,0 +1,615 @@ +/** + * The replay of a session's subagents, for `session/resume` with + * `replayFrom`, as the v1 core replays them (`RfdSubagentReplay`). + * + * Claude Code keeps each subagent's history in its own transcript, and its + * metadata file names the Agent or Task call that launched it (see + * `subagentHistory`). The replay announces each child right after that call, + * under its live session id (the agent id), with no capabilities: it cannot + * run again in this process. Then it sends the child's `model` (of its first + * response) and its history to the child session; a later response with + * another model sends it again. As live, the parent is not shown the launch + * call: its prompt is the parent's outgoing message, and a foreground child's + * answer its reply. A child's successful SubagentHandback call is its message + * to the parent instead. Unlike live, the replay does not tell runs apart, so + * a child that handed back any report gets no reply from its launch's result. + * + * A SendMessage call whose message the replay delivered to a child before it + * reached the call is that message too: the sender's outgoing view, at the + * call, and the child's incoming one, in the child's history. A child's + * history follows its launch, so a call to a child from its parent is always + * delivered first. Any other SendMessage call stays a tool call. + * + * Every child's end goes out on its parent at the end of the replay, and once + * on the child's own stream: v2 forbids replaying a child's past `running` as + * current activity. These states are history to the client, so each child's + * current state goes out again after the response ({@link afterResponse}). + * A child that the live session still tracks, on a resume of the active + * session, gets its current state from the resume snapshot instead + * (`SessionTasks.currentState`). After the response, a child whose + * configuration is not the `model` that the replay sent, such as a live child + * whose hooks reported its mode, gets it again. + * + * A launch without metadata has no known id, so it is not announced: the RFD + * forbids inventing one. Its Agent call stays an ordinary tool call of the + * parent, and a notice at the end says that subagent history is missing, as + * it does for a child whose transcript cannot be read. + */ +import * as fs from "node:fs"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable"; +import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import type { AcpSessionNotification, SubagentState } from "../acp-subagents.js"; +import { + childModelOption, + HANDBACK_TOOL, + handbackMessage, + isNativeSubagentControlTool, + launchReply, + linkedSessionMessages, + nativeSubagentState, + responseModelEntry, + rfdEndState, + rfdLabels, + type ModelEntry, + type SubagentModels, + type WorkState, +} from "../native-subagents.js"; +import { + readSubagentMessages, + subagentHistory, + subagentLaunchIds, + type SubagentHistory, + type SubagentRecord, +} from "../subagent-history.js"; +import { + deliveredCall, + firstResponseModel, + isDelivery, + missingHistoryNotice, + replaySubagentTerminalState, +} from "../subagent-replay.js"; +import { taskNotificationsOf } from "../task-notifications.js"; +import type { V2UpdateTranslator } from "./update-translator.js"; +import { SessionOutput, type ReplayHooks } from "./stream.js"; +import { sameDirectory } from "./tasks.js"; + +/** What the replay needs of its root session. */ +export interface ReplayHost { + readonly sessionId: string; + readonly cwd: string; + readonly logger: Logger; + readonly translator: V2UpdateTranslator; + /** Sends an update of the root session or one of its children. */ + send(notification: v2.UpdateSessionNotification): void; + models(): SubagentModels; +} + +/** What the live session knows of the replayed children. */ +export interface LiveChildren { + /** Whether the live session tracks the child session `sessionId`. */ + tracks(sessionId: string): boolean; + /** The configuration that the client last got for the live child `sessionId`, if any. */ + configuration(sessionId: string): SessionConfigOption[] | undefined; +} + +const NO_LIVE_CHILDREN: LiveChildren = { tracks: () => false, configuration: () => undefined }; + +type ReplayChild = { + launchId: string; + sessionId: string; + record: SubagentRecord; + parentSessionId: string; + input: Record; + /** How the launch's tool result ended a foreground child's run. */ + launchEnd?: "completed" | "failed" | "cancelled"; + /** The state of the child's last persisted task notification. */ + notified?: SubagentState; + announced: boolean; + promptSent: boolean; + /** Whether it handed a report to its parent with a SubagentHandback call. */ + handedBack?: boolean; + /** The `model` option entry that the replay sent. */ + model?: ModelEntry; + /** The model id of its latest replayed response. */ + responseModel?: string; + /** The `cwd` it was announced with, when not its parent's. */ + cwd?: string; + output?: SessionOutput; +}; + +type SendMessageCall = { + toolUseId: string; + to: string; + message: string; + senderSessionId: string; + /** The child the replay delivered the message to. */ + recipientSessionId?: string; + /** The replay reached the call before the delivery, so it stays a tool call. */ + shownAsToolCall: boolean; + /** The sender's view of the message went out, at the call. */ + outgoingSent: boolean; +}; + +export class SubagentReplay { + /** The children, by the id of the call that launched each. */ + private readonly children = new Map(); + private readonly sendMessages: SendMessageCall[] = []; + /** The SubagentHandback calls, with their report and whether their result failed. */ + private readonly handbacks = new Map< + string, + { message: string; failed: boolean; sent: boolean } + >(); + /** Launches without metadata, which the replay leaves out. */ + private readonly omitted = new Set(); + /** Children whose transcript could not be read: only their association is replayed. */ + private withoutHistory = 0; + private rootOutput: SessionOutput | undefined; + + private constructor( + private readonly host: ReplayHost, + private readonly history: SubagentHistory, + /** The transcript of each child, by agent id. */ + private readonly transcripts: Map, + private readonly live: LiveChildren, + ) {} + + /** + * Reads the subagent history that `messages`, the session's transcript, + * launched, with every child's transcript. Undefined when it launched none. + */ + static async load( + host: ReplayHost, + messages: SessionMessage[], + live: LiveChildren = NO_LIVE_CHILDREN, + ): Promise { + if (!messages.some((message) => launchIdsOf(message).length > 0)) return undefined; + const history = await subagentHistory(host.sessionId); + const transcripts = new Map(); + const queue = messages.flatMap(launchIdsOf); + while (queue.length > 0) { + const record = history.agents.get(queue.shift()!); + if (!record || transcripts.has(record.agentId)) continue; + const transcript = ( + await readSubagentMessages(host.sessionId, record.agentId, history, host.logger) + ).map((message) => ({ ...message, parent_tool_use_id: null })); + transcripts.set(record.agentId, transcript); + queue.push(...transcript.flatMap(launchIdsOf)); + } + return new SubagentReplay(host, history, transcripts, live); + } + + /** The hooks of the replay of the root session's history (`messages`). */ + hooks(output: SessionOutput, messages: SessionMessage[]): ReplayHooks { + this.rootOutput = output; + this.scan(messages, output.sessionId); + return this.ownerHooks(output, undefined); + } + + /** + * Ends the replay: every child's end, on its parent, then once on its own + * stream, and the notice when subagent history is missing. + */ + finish(): void { + const announced = [...this.children.values()].filter((child) => child.announced); + for (const child of [...announced].reverse()) { + this.host.send({ + sessionId: child.parentSessionId, + update: { + sessionUpdate: "subagent_update", + sessionId: child.sessionId, + state: endState(child) as v2.StateUpdate, + }, + }); + } + for (const child of announced) { + this.host.send({ + sessionId: child.sessionId, + update: { sessionUpdate: "state_update", ...endState(child) } as v2.SessionUpdate, + }); + } + const missing = this.omitted.size + this.withoutHistory; + if (missing > 0) { + this.rootOutput?.emitV1([ + { sessionId: this.host.sessionId, update: missingHistoryNotice(missing) }, + ]); + } + } + + /** + * After the response, as the v1 core sends them (`currentStates`): the + * current state of each replayed child that the live session does not + * track, since its process is gone, so its replayed end. A `cwd` that is + * gone, such as a worktree that Claude Code removed, is cleared. Then each + * replayed child's configuration, when it is not the `model` that the + * replay sent: the live child's, else the model of its latest response, by + * the session's model list. + */ + afterResponse(): void { + for (const child of this.children.values()) { + if (!child.announced) continue; + if (!this.live.tracks(child.sessionId)) this.sendEndState(child); + this.sendConfiguration(child); + } + } + + private sendConfiguration(child: ReplayChild): void { + const entry = responseModelEntry(child.responseModel, this.host.models()); + const current = + this.live.configuration(child.sessionId) ?? (entry ? [childModelOption(entry)] : undefined); + const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined; + if (!current || JSON.stringify(current) === replayed) return; + child.output!.emitV1([ + { + sessionId: child.sessionId, + update: { sessionUpdate: "config_option_update", configOptions: current }, + }, + ]); + } + + private sendEndState(child: ReplayChild): void { + const state = endState(child); + this.host.send({ + sessionId: child.parentSessionId, + update: { + sessionUpdate: "subagent_update", + sessionId: child.sessionId, + ...(child.cwd && !isDirectory(child.cwd) ? { cwd: null } : {}), + state: state as v2.StateUpdate, + }, + }); + this.host.send({ + sessionId: child.sessionId, + update: { sessionUpdate: "state_update", ...state } as v2.SessionUpdate, + }); + } + + private ownerHooks(output: SessionOutput, owner: ReplayChild | undefined): ReplayHooks { + return { + route: (notification) => this.route(notification, output, owner), + replaying: (message) => this.replaying(message, owner), + replayed: (message) => this.replayed(message, output), + ...(owner + ? { userContent: (message, content) => this.childContent(owner, message, content) } + : {}), + }; + } + + /** + * Registers the launches, the launch results, the SendMessage calls and the + * SubagentHandback calls in `messages`, of the session `ownerSessionId`. + */ + private scan(messages: readonly SessionMessage[], ownerSessionId: string): void { + for (const message of messages) { + for (const block of contentBlocks(message)) { + if (block.type === "tool_use" && typeof block.id === "string") { + if (isNativeSubagentControlTool(block.name)) { + this.registerLaunch(block.id, inputOf(block), ownerSessionId); + } else if (block.name === HANDBACK_TOOL) { + const report = handbackMessage(block.input); + if (report) + this.handbacks.set(block.id, { message: report, failed: false, sent: false }); + } else if (block.name === "SendMessage") { + const input = inputOf(block); + if (typeof input.to === "string" && typeof input.message === "string") { + this.sendMessages.push({ + toolUseId: block.id, + to: input.to, + message: input.message, + senderSessionId: ownerSessionId, + shownAsToolCall: false, + outgoingSent: false, + }); + } + } + } else if (block.type === "tool_result" && typeof block.tool_use_id === "string") { + const handback = this.handbacks.get(block.tool_use_id); + if (handback && block.is_error === true) handback.failed = true; + const child = this.children.get(block.tool_use_id); + // A background launch answers at once; its result is not the end. + if (child && !child.record.background) + child.launchEnd = replaySubagentTerminalState(block); + } + } + } + } + + private registerLaunch( + launchId: string, + input: Record, + parentSessionId: string, + ): void { + if (this.children.has(launchId)) return; + const record = this.history.agents.get(launchId); + if (!record) { + this.omitted.add(launchId); + return; + } + this.children.set(launchId, { + launchId, + sessionId: record.agentId, + record, + parentSessionId, + input, + announced: false, + promptSent: false, + }); + } + + /** + * A replayed update of a tool call of `output`, the session of `owner` + * when it is a child's. The client is not shown the launch of a replayed + * child, a SendMessage call whose message the replay delivered, or a + * successful SubagentHandback call: a foreground launch's completed result + * goes out as the child's reply, a delivered SendMessage as the sender's + * outgoing message, and a hand-back as the child's message to its parent. + */ + private route( + notification: AcpSessionNotification, + output: SessionOutput, + owner: ReplayChild | undefined, + ): AcpSessionNotification[] { + const { update } = notification; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return [notification]; + } + const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId); + if (sendMessage) { + if (sendMessage.recipientSessionId === undefined) { + sendMessage.shownAsToolCall = true; + return [notification]; + } + if (sendMessage.outgoingSent) return []; + sendMessage.outgoingSent = true; + const [outgoing] = linkedSessionMessages( + sendMessage.toolUseId, + sendMessage.senderSessionId, + sendMessage.recipientSessionId, + sendMessage.message, + ); + return [outgoing!]; + } + const handback = owner ? this.handbacks.get(update.toolCallId) : undefined; + if (owner && handback && !handback.failed) { + if (handback.sent) return []; + handback.sent = true; + owner.handedBack = true; + const [outgoing, incoming] = linkedSessionMessages( + update.toolCallId, + owner.sessionId, + owner.parentSessionId, + handback.message, + ); + // Sent here, in order: an output routes each update once the ones before it went out. + output.emitV1([outgoing!]); + this.outputOf(owner.parentSessionId)?.emitV1([incoming!]); + return []; + } + const child = this.children.get(update.toolCallId); + if (!child) return [notification]; + if ( + update.status === "completed" && + child.announced && + !child.record.background && + !child.handedBack + ) { + const reply = launchReply(child.launchId, child.sessionId, child.parentSessionId, update); + if (reply) return [reply]; + } + return []; + } + + /** + * Before a message of `owner`'s history (the root's when undefined): a + * child's response with another model sends its `model` again, and a + * persisted task notification of a child is the state its work ended in. + */ + private replaying(message: SessionMessage, owner: ReplayChild | undefined): void { + const inner = message.message as { model?: unknown; content?: unknown } | undefined; + if (message.type === "assistant") { + if (owner) this.sendModel(owner, typeof inner?.model === "string" ? inner.model : undefined); + return; + } + for (const notification of taskNotificationsOf(inner?.content)) { + const child = [...this.children.values()].find((c) => c.sessionId === notification.task_id); + if (child) child.notified = nativeSubagentState(notification.status) ?? child.notified; + } + } + + /** After a message that launched children: each is announced, and its history replays. */ + private replayed(message: SessionMessage, parent: SessionOutput): void { + if (message.type !== "assistant") return; + for (const launchId of launchIdsOf(message)) { + const child = this.children.get(launchId); + if (!child || child.announced) continue; + const messages = this.transcripts.get(child.sessionId) ?? []; + if (messages.length === 0) this.withoutHistory++; + this.announce(child); + this.sendModel(child, firstResponseModel(messages)); + this.scan(messages, child.sessionId); + child.output!.replay(messages, this.ownerHooks(child.output!, child)); + if (!child.promptSent && typeof child.input.prompt === "string") { + this.sendPrompt(child, parent, child.input.prompt); + } + } + } + + private announce(child: ReplayChild): void { + const labels = rfdLabels( + child.record.name ?? child.input.name, + child.record.agentType ?? child.input.subagent_type, + child.record.description ?? child.input.description, + ); + const parentCwd = this.cwdOf(child.parentSessionId); + child.cwd = + child.record.cwd && !sameDirectory(child.record.cwd, parentCwd) + ? child.record.cwd + : undefined; + child.announced = true; + this.host.send({ + sessionId: child.parentSessionId, + update: { + sessionUpdate: "subagent_update", + sessionId: child.sessionId, + ...(labels.title ? { title: labels.title } : {}), + ...(labels.description ? { description: labels.description } : {}), + ...(child.cwd ? { cwd: child.cwd } : {}), + state: { state: "running" }, + }, + }); + child.output = new SessionOutput( + child.sessionId, + child.cwd ?? parentCwd, + this.host.translator, + (update) => this.host.send({ sessionId: child.sessionId, update }), + this.host.logger, + true, + ); + } + + /** The `model` of a response of `child`, when it is not the one the client holds. */ + private sendModel(child: ReplayChild, model: string | undefined): void { + const entry = responseModelEntry(model, this.host.models()); + if (!entry || !child.output) return; + child.responseModel = model; + if (child.model?.value === entry.value && child.model.name === entry.name) return; + child.model = entry; + child.output.emitV1([ + { + sessionId: child.sessionId, + update: { sessionUpdate: "config_option_update", configOptions: [childModelOption(entry)] }, + }, + ]); + } + + /** + * The rest of a child's user message to replay, or `null` when nothing is + * left, after its text went out as the child's incoming `session_message`. + * The first text is the prompt of the launch. A message that a SendMessage + * call delivered (`origin.kind: "coordinator"`) is another incoming + * message. Any other text is Claude Code's, such as "[Request interrupted + * by user]", which the live child session does not show. Tool results stay. + */ + private childContent(child: ReplayChild, message: SessionMessage, content: unknown): unknown { + const text = textOf(content); + if (text.trim()) { + if (isDelivery(message)) this.sendDelivery(child, message, text); + else if (!child.promptSent) { + const parent = this.outputOf(child.parentSessionId); + if (parent) this.sendPrompt(child, parent, text); + } + } + if (!Array.isArray(content)) return null; + const rest = content.filter( + (block) => !(typeof block === "object" && block !== null && block.type === "text"), + ); + return rest.length > 0 ? rest : null; + } + + /** + * A message that a SendMessage call delivered to `child` (see + * `deliveredCall`). With the call, the message has the call's id and text + * and its sender, as live, and, unless the replay already showed the call + * as a tool call, it replaces the call: the sender's view goes out at the + * call (see {@link route}). Without the call, it has its transcript id and + * the delivered text. + */ + private sendDelivery(child: ReplayChild, message: SessionMessage, text: string): void { + const call = deliveredCall( + this.sendMessages, + [child.sessionId, child.record.name, child.input.name], + text, + ); + if (call) call.recipientSessionId = child.sessionId; + const messageId = call?.toolUseId ?? message.uuid; + const linked = call !== undefined && !call.shownAsToolCall; + child.output!.emitV1([ + { + sessionId: child.sessionId, + update: { + sessionUpdate: "session_message", + messageId, + ...(call ? { senderSessionId: call.senderSessionId } : {}), + recipientSessionId: child.sessionId, + ...(linked ? { senderMessageId: messageId } : {}), + content: [{ type: "text", text: call?.message ?? text }], + }, + }, + ]); + } + + private sendPrompt(child: ReplayChild, parent: SessionOutput, text: string): void { + child.promptSent = true; + const [outgoing, incoming] = linkedSessionMessages( + child.launchId, + child.parentSessionId, + child.sessionId, + text, + ); + parent.emitV1([outgoing!]); + child.output!.emitV1([incoming!]); + } + + private outputOf(sessionId: string): SessionOutput | undefined { + if (sessionId === this.host.sessionId) return this.rootOutput; + return [...this.children.values()].find((child) => child.sessionId === sessionId)?.output; + } + + private cwdOf(sessionId: string): string { + const child = [...this.children.values()].find((c) => c.sessionId === sessionId); + if (!child) return this.host.cwd; + return child.cwd ?? this.cwdOf(child.parentSessionId); + } +} + +/** + * How a replayed child's work ended: the state of its last task + * notification, else a stop by the user, else its foreground launch's result. + */ +function endState(child: ReplayChild): WorkState { + const end = + child.notified ?? + nativeSubagentState(child.record.stoppedByUser ? "cancelled" : child.launchEnd); + return end ? rfdEndState(end, undefined) : { state: "idle" }; +} + +function launchIdsOf(message: SessionMessage): string[] { + if (message.type !== "assistant") return []; + return subagentLaunchIds((message.message as { content?: unknown } | undefined)?.content); +} + +function contentBlocks(message: SessionMessage): Record[] { + const content = (message as { message?: { content?: unknown } }).message?.content; + return Array.isArray(content) + ? content.filter( + (block): block is Record => typeof block === "object" && block !== null, + ) + : []; +} + +function inputOf(block: Record): Record { + return typeof block.input === "object" && block.input !== null + ? (block.input as Record) + : {}; +} + +/** The text of user message content: a string, or its text blocks. */ +function textOf(content: unknown): string { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .flatMap((block) => + typeof block === "object" && block !== null && block.type === "text" ? [block.text] : [], + ) + .filter((text): text is string => typeof text === "string") + .join("\n"); +} + +function isDirectory(dir: string): boolean { + try { + return fs.statSync(dir).isDirectory(); + } catch { + return false; + } +} diff --git a/src/v2/subscription-guard.ts b/src/v2/subscription-guard.ts new file mode 100644 index 00000000..71108ed0 --- /dev/null +++ b/src/v2/subscription-guard.ts @@ -0,0 +1,127 @@ +/** + * The `--hide-claude-auth` subscription guard on the v2 core: this + * integration never bills a claude.ai subscription. The policy is the v1 + * core's (`hide-claude-auth.ts`): + * + * - It holds while the flag is on and no provider or gateway login routes + * the traffic away from the CLI's own login. + * - A session does not start on an account that a subscription would pay + * for (`authRequired` with the `claude_subscription_not_supported` + * reason), or that holds no credential this integration accepts + * (`authRequired`). + * - Each prompt reads the account that the query cached at its start, and is + * refused, before it is inserted, on the same terms. + * - The cached account goes stale when the user signs out or logs in + * elsewhere. A turn that fails for a missing login, or an auth status + * probe that finds another kind of identity than the session started on, + * restarts the query at the next prompt that finds the session idle (see + * `restarts.ts`), so its start judges the current account. + * + * The `/remote-control` command that the adapter runs is not guarded. + */ +import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import { type AuthStatusKind, fromAccountInfo } from "../auth-status.js"; +import { + billsClaudeSubscription, + claudeLoginRequiredError, + type ClaudeSubscriptionGuardState, + claudeSubscriptionNotSupportedError, + holdsNonSubscriptionCredential, + refuseClaudeSubscriptionTurn, + shouldHideClaudeAuth, + warnClaudeSubscriptionGuardDegraded, +} from "../hide-claude-auth.js"; + +/** What the guard needs of the connection's auth state (`V2Auth`). */ +export interface GuardAuth { + /** Whether a provider or a gateway login routes the traffic. */ + overridesRoute(): boolean; + /** Reports the account a session's query cached, as the identity. */ + publishAccount(sessionId: string, account: AccountInfo): void; +} + +type GuardedSession = { + state: ClaudeSubscriptionGuardState; + /** The kind of identity the session's query started on, if its account told. */ + kind: AuthStatusKind | undefined; +}; + +export class SubscriptionGuard { + private readonly sessions = new Map(); + + constructor( + private readonly auth: GuardAuth, + private readonly logger: Logger, + /** The session's account may have changed: restart it at its next prompt. */ + private readonly accountMayHaveChanged: (sessionId: string) => void, + ) {} + + /** Whether the guard holds now (see the module doc). */ + active(): boolean { + return shouldHideClaudeAuth() && !this.auth.overridesRoute(); + } + + /** + * A session's query started on `account`. Throws the refusal when the + * guard holds and the account is one it refuses. + */ + sessionStarted(sessionId: string, account: AccountInfo | undefined, apiKeyHelper?: string): void { + const session: GuardedSession = { + state: {}, + kind: fromAccountInfo(account, apiKeyHelper)?.kind, + }; + if (this.active()) { + if (!account) { + warnClaudeSubscriptionGuardDegraded({ + sessionId, + guardState: session.state, + logger: this.logger, + cause: "the CLI reported no account at initialize", + }); + } else if (billsClaudeSubscription(account)) { + throw claudeSubscriptionNotSupportedError(); + } else if (!holdsNonSubscriptionCredential(account)) { + throw claudeLoginRequiredError(); + } + } + this.sessions.set(sessionId, session); + } + + /** + * A prompt of `sessionId` is about to go to `query`: rejects with the + * refusal when the guard holds and refuses the cached account. `undefined` + * while the guard is off, so the prompt goes on in the same call. + */ + beforePrompt(sessionId: string, query: Pick): Promise | undefined { + if (!this.active()) return undefined; + let session = this.sessions.get(sessionId); + if (!session) this.sessions.set(sessionId, (session = { state: {}, kind: undefined })); + return refuseClaudeSubscriptionTurn({ + sessionId, + query, + guardState: session.state, + logger: this.logger, + // The guard reads the account anyway: it keeps the identity current. + onAccount: (account) => this.auth.publishAccount(sessionId, account), + }); + } + + /** A turn of `sessionId` failed for a missing login: the user signed out. */ + signedOut(sessionId: string): void { + if (shouldHideClaudeAuth()) this.accountMayHaveChanged(sessionId); + } + + /** An auth status probe read `kind`: a session started on another kind restarts. */ + probed(kind: AuthStatusKind): void { + if (!this.active()) return; + for (const [sessionId, session] of this.sessions) { + // No kind: the account said nothing that this read could contradict. + if (session.kind && session.kind !== kind) this.accountMayHaveChanged(sessionId); + } + } + + sessionEnded(sessionId: string): void { + this.sessions.delete(sessionId); + } +} diff --git a/src/v2/tasks.ts b/src/v2/tasks.ts new file mode 100644 index 00000000..31e35a00 --- /dev/null +++ b/src/v2/tasks.ts @@ -0,0 +1,2079 @@ +/** + * Claude Code's tasks of one root session, as ACP v2 reports them: its + * subagents as child sessions (the subagents RFD), and the work that runs in + * the background as the `background` list of each session's states. + * + * Claude Code reports each task with `task_started`, then `task_updated` + * patches, and its end with `task_notification` (or a terminal `task_updated` + * patch, the only one it promises per transition). `background_tasks_changed` + * is its live set of background tasks. From those: + * + * - A subagent (`local_agent`) is a child session whose id is its agent id, + * the same session for every delegation to it (a SendMessage resume starts + * it again under the same `task_id`). It is announced on the session that + * made the Agent or Task call that launched it, once both its `task_started` + * and that call came, with `subagent_update`, before any of its traffic. + * Its messages carry the `parent_tool_use_id` of that call, also after a + * resume, and go to its stream; those that come before the announcement + * wait for it. A child that ends before it is announced stays unexposed. + * Each run of it is the child's foreground work: `running` from its start + * to its end, on its stream and mirrored on the parent's `subagent_update`. + * - The parent is not shown the Agent call of an announced child: the call's + * prompt is the parent's outgoing `session_message` and the child's + * incoming one, and a foreground child's answer is its reply to the parent. + * The call of a child that was never exposed is shown with its final + * status. A SendMessage call whose message reaches an announced child, + * and a child's SubagentHandback call, are session messages too. + * - The work waits on a subagent that is not `is_backgrounded`, and on the + * ones that it waits on: their requests block it (`requires_action`), and + * a cancel of it withdraws them. A subagent in the background is listed in + * its parent's `background` while it works. + * - A command that went to the background is its display terminal, kept + * running from its Bash result until its task ends, with its output read + * from the file Claude Code writes it to. + * - A task that a tool call started, such as a `Monitor` or `Workflow` call, + * is that tool call, kept `in_progress` (or `paused`) until the task ends. + * A workflow's `task_progress` shows on its call (`workflowCard`). + * - Any other background task, such as one that a hook started or one that + * started after its tool call returned, gets a tool call that the adapter + * starts for it (`task_`), as v2 lists background work only by an + * entity the client knows. + * + * Claude Code's interrupt spares background tasks (`perTaskStopAffordance`), + * so `session/cancel` leaves listed work running, as v2 asks. A session + * started without it reports no lists. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { RequestError, type SessionModeState } from "@agentclientprotocol/sdk/unstable"; +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { + HookInput, + SDKMessage, + SDKUserMessage, + SessionMessage, +} from "@anthropic-ai/claude-agent-sdk"; +import type { Logger } from "../acp-agent.js"; +import type { AcpSessionNotification } from "../acp-subagents.js"; +import { backgroundBashTaskFromToolResult, taskProgressFields } from "../async-tasks.js"; +import { BackgroundOutputTail, type BackgroundOutput } from "../background-output.js"; +import { + childModelOption, + HANDBACK_TOOL, + handbackMessage, + isNativeSubagentControlTool, + launchReply, + linkedSessionMessages, + mergeSubagentIdentity, + responseModelEntry, + resumedNativeSubagentId, + rfdLabels, + sendMessageResume, + subagentIdentity, + unexposedControlFallback, + type HistoricalSubagent, + type SubagentIdentity, + type SubagentModels, +} from "../native-subagents.js"; +import { subagentLineage } from "../subagent-history.js"; +import { withRunningTerminal, withRunningToolCall } from "../tool-calls/background.js"; +import { ClientCapabilities } from "../tool-calls/client-capabilities.js"; +import { AcpToolCallRenderer } from "../tool-calls/renderer.js"; +import type { BackgroundItem, V2Background, WorkStateName } from "./background.js"; +import type { V2UpdateTranslator } from "./update-translator.js"; +import { type WorkflowCardProgress, workflowCard, workflowSnapshot } from "../workflow-progress.js"; +import { ChildConfigurations } from "./child-config.js"; +import { SessionOutput } from "./stream.js"; +import { SubagentReplay } from "./subagent-replay.js"; + +/** What the tasks need of their root session. */ +export interface TaskHost { + readonly sessionId: string; + readonly cwd: string; + readonly logger: Logger; + readonly translator: V2UpdateTranslator; + readonly background: V2Background; + /** + * Whether the session reports its background work: only when Claude Code's + * interrupt spares background tasks (`perTaskStopAffordance`). + */ + readonly reportsBackground: boolean; + /** Sends an update of the root session or one of its children. */ + send(notification: v2.UpdateSessionNotification): void; + /** The root session's foreground state now. */ + rootState(): WorkStateName; + /** A subagent's open requests changed, which can block the root's foreground work. */ + foregroundRequestsChanged(): void; + /** The entries of the root session's `model` option, for a child's `model`. */ + models(): SubagentModels; + /** The root session's modes, for a child's `mode`. */ + modes(): SessionModeState["availableModes"]; + stopTask(taskId: string): Promise; +} + +/** Where a permission request or elicitation goes: the root session or a child. */ +export interface RequestTarget { + readonly sessionId: string; + readonly cwd: string; + /** The output of the session, which holds its tool calls. */ + readonly output: SessionOutput; + /** For a subagent's request: the Agent or Task call that launched the subagent. */ + readonly parentToolUseId?: string; + /** Whether a cancel stops the work that the request belongs to. */ + cancelling(): boolean; + requestPermission( + request: v2.RequestPermissionRequest, + signal: AbortSignal, + ): Promise; + /** + * Runs `send` as an open request of the target (`requires_action`) until it + * settles. Its signal aborts with `signal`, or when a cancel or close + * withdraws the target's requests. + */ + openRequest(signal: AbortSignal, send: (signal: AbortSignal) => Promise): Promise; +} + +type ChildState = "idle" | "running" | "requires_action"; + +type IdleOutcome = + | { stopReason: "end_turn" } + | { stopReason: "cancelled" } + | { stopReason: "error"; error?: { code: number; message: string } }; + +/** An Agent or Task call, which launches or resumes a subagent. */ +type Launch = { + toolUseId: string; + /** The output of the session that made the call. */ + output: SessionOutput; + identity: SubagentIdentity | undefined; + /** Whether the call ran its child in the background (`run_in_background`). */ + background: boolean; + /** The call's first report, for the call of a child that is never exposed. */ + initial: AcpSessionNotification | undefined; + /** Whether the call reached its final status. */ + ended: boolean; +}; + +/** A live subagent task, as the work that waits on it needs it. */ +type AgentTask = { parentTaskId: string | undefined; background: boolean }; + +type Child = { + /** The child's session id: its agent id. */ + sessionId: string; + /** The session that made its launch call, once it is announced. */ + parentSessionId: string; + /** The Agent call of its current run, whose id its messages carry. */ + launchToolUseId: string | undefined; + /** What `task_started` said of it, or the subagent history of a restored child. */ + task: { name?: string; description?: string; prompt?: string; subagentType?: string }; + announced: boolean; + /** + * The subagent that launched it, when that one is not announced yet and no + * launch call names the parent: it is announced right after that parent. + */ + awaitedParent: string | undefined; + /** Its messages that came before its announcement. */ + pending: SDKMessage[]; + title: string | undefined; + description: string | undefined; + /** Whether a run of it is going on: its foreground work. */ + working: boolean; + /** The state its stream was told last. */ + reportedState: ChildState | undefined; + /** How its last run ended. */ + outcome: IdleOutcome | undefined; + openRequests: number; + /** The working directory it was announced with, when not its parent's. */ + cwd: string | undefined; + /** Its output, once it is announced. */ + output: SessionOutput | undefined; + /** The ids of the messages delegated to it. */ + promptIds: Set; + /** The calls that delegated to it, whose runs ended: a late SendMessage result resumes nothing. */ + endedRuns: Set; + /** The calls that delegated to it, whose runs handed back a report: their results are no reply. */ + handedBack: Set; + /** Its tool calls that ended with a stop of its run: Claude Code's later results are dropped. */ + stopped: Set; +}; + +type ToolResultBlock = { type: "tool_result"; tool_use_id: string; is_error?: boolean }; + +type ToolCallUpdate = Extract< + AcpSessionNotification["update"], + { sessionUpdate: "tool_call" | "tool_call_update" } +>; + +/** A task of `background_tasks_changed`, with the latest progress of a workflow. */ +type LevelTask = { + ambient: boolean; + taskType: string; + description: string; + parentTaskId?: string; + progress?: WorkflowCardProgress; +}; + +/** What `task_started` said of a task that is not a subagent. */ +type StartedTask = { toolUseId?: string; parentTaskId?: string; taskType?: string }; + +/** The tools whose structured result names the task that the call started. */ +const TASK_TOOLS = new Set(["Monitor", "Workflow"]); + +/** The most messages that wait for the announcement of one child. */ +const MAX_PENDING_MESSAGES = 256; + +/** The most children not announced yet whose hooks wait for them. */ +const MAX_PENDING_HOOKS = 64; + +const FINAL_STATUSES = new Set(["completed", "failed", "cancelled"]); + +const V2_RENDERER = new AcpToolCallRenderer(ClientCapabilities.from(undefined, { v2: true })); + +export class SessionTasks { + /** The subagents, by agent id. */ + private readonly children = new Map(); + /** The child of each Agent call that launched or resumed one. */ + private readonly childByToolUse = new Map(); + /** The Agent and Task calls, by tool use id. */ + private readonly launches = new Map(); + /** The messages of a launch whose child has not started, by launch id. */ + private readonly pendingByLaunch = new Map(); + /** The live subagent tasks, by agent id, announced or not. */ + private readonly agents = new Map(); + /** The open permission requests of each subagent, by agent id. */ + private readonly agentRequests = new Map>(); + /** The subagents whose runs a cancel stops, by agent id, until their runs end. */ + private readonly cancelledAgents = new Set(); + /** The subagents that the root's last cancel stopped (see {@link foregroundCancelFailed}). */ + private readonly foregroundCancelled = new Set(); + /** + * Whether the root's cancel is in effect: from {@link cancelForeground} + * until {@link foregroundCancelEnded} or {@link foregroundCancelFailed}. + */ + private rootCancelling = false; + /** Set when the root session closes: no request goes to the client after it. */ + private closed = false; + /** The session that made each SendMessage call: the sender of a resumed child's prompt. */ + private readonly senderByToolUse = new Map(); + /** The other tasks that `task_started` reported, until they end. */ + private readonly started = new Map(); + /** Claude Code's live background tasks, from its last `background_tasks_changed`. */ + private level = new Map(); + /** The terminals of the commands that went to the background, by task id. */ + private readonly terminals = new Map< + string, + { + toolCallId: string; + output: SessionOutput; + tail?: BackgroundOutputTail; + ending?: boolean; + /** Whether Claude Code's live background task set held its task. */ + leveled?: boolean; + } + >(); + /** + * The tool calls whose tasks go on in the background, by task id. A call + * that the adapter started for its task (see {@link adoptTasks}) has the + * task's description as its `title` instead of a tool use. + */ + private readonly toolCalls = new Map< + string, + { + toolCallId: string; + output: SessionOutput; + toolUse?: { name: string; input: unknown }; + title?: string; + paused: boolean; + } + >(); + /** The tool calls that returned: their tasks no longer hold their work. */ + private readonly returned = new Set(); + /** What a subagent's hooks reported before the child was announced, by agent id. */ + private readonly startedHooks = new Map(); + /** The children's configurations (see `child-config.ts`). */ + readonly configs: ChildConfigurations; + /** + * The SendMessage calls, which the client is not shown until their final + * update, with their first report and the session that made them. A message + * that reaches an announced child replaces its call. + */ + private readonly hiddenSendMessages = new Map< + string, + { initial: AcpSessionNotification; sender: string; delivered: boolean } + >(); + /** + * The SendMessage calls that a permission request showed before their tool + * use streamed: the client holds them, so they stay shown until their final + * update, as on v1 (its `eagerToolCallSessions`). + */ + private readonly shownSendMessages = new Set(); + /** + * The SubagentHandback calls of announced children, which the client is not + * shown until their final update, with their first report, the report from + * their input, and the call that delegated the run. + */ + private readonly handbacks = new Map< + string, + { initial: AcpSessionNotification; message?: string; delegationId?: string } + >(); + /** The restore of a subagent from the session's history, while it reads it (see {@link restore}). */ + private restoring: Promise | undefined; + + constructor( + private readonly host: TaskHost, + /** The root session's output, which owns its tool calls. */ + private readonly rootOutput: SessionOutput, + ) { + rootOutput.route = (notification) => this.route(rootOutput, notification, undefined); + this.configs = new ChildConfigurations({ + modes: () => host.modes(), + models: () => host.models(), + announced: (agentId) => this.hasChild(agentId), + }); + // Every state of a session that reports its background work carries a list. + if (host.reportsBackground) { + host.background.changed({ sessionId: host.sessionId, items: [], state: undefined }); + } + } + + // --- Claude Code's messages --------------------------------------------- + + /** + * Takes a message of the query that concerns the tasks. Returns whether it + * was a child's, which then goes to the child's stream only. + */ + handle(message: SDKMessage): boolean { + switch (message.type) { + case "system": + switch (message.subtype) { + case "task_started": + this.taskStarted(message); + return true; + case "task_updated": + this.taskUpdated(message); + return true; + case "task_notification": + this.taskNotification(message); + return true; + case "task_progress": + this.taskProgress(message); + return true; + case "background_tasks_changed": + this.levelChanged(message.tasks); + return true; + default: + return false; + } + case "stream_event": + case "assistant": + case "user": + case "tool_progress": + return this.childMessage(message); + default: + return false; + } + } + + /** + * The restore of a subagent that {@link handle} started, once: the session + * handles its next message after it, so the child's traffic finds it. + */ + restored(): Promise | undefined { + const restoring = this.restoring; + this.restoring = undefined; + return restoring; + } + + /** + * Restores the agent that a SendMessage result of `output` resumed + * (`resumedAgentId`), when this process does not know it, as the v1 core + * does (`sendMessageResumed`): Claude Code can resume an agent without a + * `task_started`. The caller sends the result's updates after it, so the + * restored child takes the call's message. Undefined when nothing is + * restored: the agent is known, or no SendMessage call of the session + * carried the result. + */ + restoreResumed(output: SessionOutput, message: SDKUserMessage): Promise | undefined { + const agentId = resumedNativeSubagentId(message.tool_use_result); + const content = message.message.content; + if (agentId === undefined || this.children.has(agentId) || !Array.isArray(content)) { + return undefined; + } + const resume = sendMessageResume( + output.toolUses(), + agentId, + content.flatMap((block) => (block.type === "tool_result" ? [block.tool_use_id] : [])), + ); + if (!resume || !this.senderByToolUse.has(resume.toolUseId)) return undefined; + return this.restore(agentId, resume.text, resume.toolUseId); + } + + /** + * The tool results of the root session, or of a child (`output`): a + * background command's terminal and a background task's tool call keep + * running past them. + */ + toolResults(output: SessionOutput, message: SDKUserMessage): void { + const content = message.message.content; + if (!Array.isArray(content)) return; + const results = content.filter( + (block) => block.type === "tool_result", + ) as unknown as ToolResultBlock[]; + for (const { tool_use_id } of results) this.returned.add(tool_use_id); + const toolUses = output.toolUses(); + // Before the result's own updates: a SendMessage call whose message + // reached its child is that message. + this.sendMessageResumed(message, results, toolUses); + + const command = backgroundBashTaskFromToolResult( + content, + message.tool_use_result, + toolUses, + (toolUseId) => + [...this.started].find( + ([, task]) => task.toolUseId === toolUseId && task.taskType === "local_bash", + )?.[0], + ); + // A fast command can end before its result: then its terminal exits with it. + const runningTerminal = + command && this.started.has(command.taskId) ? command.toolCallId : undefined; + if (command && runningTerminal) { + this.terminals.set(command.taskId, { + toolCallId: command.toolCallId, + output, + leveled: this.level.get(command.taskId)?.ambient === false, + }); + } + + const runningToolCalls = new Set(); + const succeeded = results.filter((block) => block.is_error !== true); + const structuredTaskId = + succeeded.length === 1 && + typeof message.tool_use_result === "object" && + message.tool_use_result !== null && + typeof (message.tool_use_result as { taskId?: unknown }).taskId === "string" + ? (message.tool_use_result as { taskId: string }).taskId + : undefined; + // A tool call's task is kept only for a session that lists it. + for (const { tool_use_id: toolCallId } of this.host.reportsBackground ? succeeded : []) { + const toolUse = toolUses[toolCallId]; + if (!toolUse || toolUse.name === "Bash" || isNativeSubagentControlTool(toolUse.name)) { + continue; + } + const taskId = + (TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ?? + [...this.started].find(([, task]) => task.toolUseId === toolCallId)?.[0]; + const task = taskId === undefined ? undefined : this.level.get(taskId); + if (!task || task.taskType === "local_bash" || task.taskType === "local_agent") continue; + this.toolCalls.set(taskId!, { + toolCallId, + output, + toolUse: { name: toolUse.name, input: toolUse.input }, + paused: false, + }); + runningToolCalls.add(toolCallId); + } + + // The helpers are typed for the SDK's notification; they only rewrite a tool call update. + type Notification = Parameters[0]; + output.toolResults( + message, + (notification) => + withRunningToolCall( + withRunningTerminal(notification as Notification, runningTerminal), + runningToolCalls, + ) as AcpSessionNotification, + ); + // A workflow can report progress before its launch call returns: it + // goes on the call after the result's own updates. + for (const [taskId, toolCall] of this.toolCalls) { + if (!runningToolCalls.has(toolCall.toolCallId)) continue; + const card = workflowCard(this.level.get(taskId)?.progress); + if (card) toolCall.output.taskToolCallProgress(toolCall.toolCallId, card); + } + // After the result's own updates: the output replaces their placeholder. + if (command && runningTerminal) { + this.followOutput( + command.taskId, + typeof command.outputFilePath === "string" ? command.outputFilePath : undefined, + ); + } + this.publish(); + } + + // --- Subagent sessions ---------------------------------------------------- + + /** Reads the subagents that the history `messages` launched, for its replay. */ + loadReplay(messages: SessionMessage[]): Promise { + return SubagentReplay.load(this.host, messages, { + tracks: (sessionId) => this.children.has(sessionId), + configuration: (sessionId) => + this.hasChild(sessionId) ? this.configs.published(sessionId) : undefined, + }); + } + + /** Whether a subagent task runs, announced or not, in the foreground or the background. */ + hasLiveSubagents(): boolean { + return this.agents.size > 0; + } + + /** Whether `sessionId` is an announced child session of this root. */ + hasChild(sessionId: string): boolean { + return this.children.get(sessionId)?.announced === true; + } + + /** + * The session that a permission request of the agent `agentId` goes to: + * its child session once it is announced, else the root. A request can + * come before its subagent's `task_started`: it blocks the root's work once + * that shows the subagent runs in the foreground. + */ + async target(agentId: string | undefined, root: RequestTarget): Promise { + if (agentId === undefined) return root; + const child = this.children.get(agentId); + const announced = child?.announced ? child : undefined; + return { + sessionId: announced?.sessionId ?? root.sessionId, + cwd: announced ? this.cwdOf(announced.sessionId) : root.cwd, + output: announced?.output ?? root.output, + ...(child?.launchToolUseId !== undefined ? { parentToolUseId: child.launchToolUseId } : {}), + cancelling: () => this.cancelledAgents.has(agentId), + // The request counts on the session it goes to, also when the child is + // announced while the request is built. + requestPermission: (request, toolSignal) => + this.agentRequest(agentId, announced, toolSignal, async (signal) => { + try { + return await this.hostRequest(request, signal); + } catch (error) { + if (signal.aborted) return { outcome: { outcome: "cancelled" } }; + throw error; + } + }), + openRequest: (signal, send) => this.agentRequest(agentId, announced, signal, send), + }; + } + + /** + * Whether the root's foreground work, through the subagents that it waits + * on (those it does not run in the background), waits on the user: each of + * them is blocked. Undefined when it waits on no subagent. A subagent is + * blocked when each subagent that it waits on is, or, waiting on none, when + * a request of it is open. So one that still makes progress keeps the work + * `running`, as v2 asks. + */ + foregroundBlocked(): boolean | undefined { + return this.subagentsBlocked(undefined, new Set()); + } + + private subagentsBlocked(parent: string | undefined, seen: Set): boolean | undefined { + const waited = [...this.agents].flatMap(([agentId, task]) => + !task.background && task.parentTaskId === parent && !seen.has(agentId) ? [agentId] : [], + ); + if (waited.length === 0) return undefined; + for (const agentId of waited) seen.add(agentId); + return waited.every( + (agentId) => + this.subagentsBlocked(agentId, seen) ?? (this.agentRequests.get(agentId)?.size ?? 0) > 0, + ); + } + + /** + * The root's foreground work is cancelled: so is the work of the subagents + * that it waits on. Their open requests are withdrawn. Claude Code's + * interrupt stops their runs, which then end `cancelled`. Until then, of + * their consolidated messages only the results of the tool calls that the + * client holds go out, as on the root (see `SessionOutput.cancelled`). A foreground subagent that Claude Code + * reports after the cancel, until {@link foregroundCancelEnded}, is + * stopped work too: Claude Code started it before the interrupt, and the + * session reads its `task_started` only after the cancel. + */ + cancelForeground(): void { + this.foregroundCancelled.clear(); + this.rootCancelling = true; + for (const agentId of this.agents.keys()) this.cancelIfForeground(agentId); + } + + /** + * The work that the root's cancel stopped ended: its result, Claude Code's + * idle, or the start of a later prompt. A subagent that comes after it is + * the later work's. + */ + foregroundCancelEnded(): void { + this.rootCancelling = false; + } + + /** + * The interrupt of the root's cancel failed: the subagents that it stopped + * go on. Their requests were withdrawn, and their later ones are theirs + * again, not a stopped run's. + */ + foregroundCancelFailed(): void { + this.rootCancelling = false; + this.cancelFailed(this.foregroundCancelled); + this.foregroundCancelled.clear(); + } + + /** + * A cancel failed to stop the agents `agentIds` that it withdrew: their + * runs go on, and their later requests are theirs again. Each reports + * `running` again, which the withdrawn requests did not. + */ + private cancelFailed(agentIds: Iterable): void { + for (const agentId of agentIds) { + this.cancelledAgents.delete(agentId); + const child = this.children.get(agentId); + if (child?.output) child.output.cancelled = false; + if (child) this.reportChild(child); + } + } + + /** + * The root session closes: every subagent's open request is withdrawn, and + * a later one never reaches the client. + */ + withdrawAll(): void { + this.closed = true; + for (const agentId of this.agentRequests.keys()) this.withdraw(agentId); + } + + /** + * `session/cancel` of a child: stops its run, and the work of the subagents + * that it waits on. Its idle reports `cancelled` once Claude Code reports + * the task stopped. A child that does not work has nothing to stop. + */ + async cancelChild(sessionId: string): Promise { + const child = this.children.get(sessionId); + if (!child?.announced || !child.working) return; + // The agents that this cancel stops, and not an earlier one still. + const withdrawn: string[] = []; + for (const agentId of this.agents.keys()) { + if (!this.waitsOn(agentId, sessionId)) continue; + if (!this.cancelledAgents.has(agentId)) withdrawn.push(agentId); + this.withdraw(agentId); + } + try { + await this.host.stopTask(child.sessionId); + } catch (error) { + // The run ended meanwhile, or Claude Code refused: it reports how it + // ends, and so do the subagents that it waits on. + this.cancelFailed(withdrawn); + this.host.logger.error(`Subagent ${sessionId}: stopping it failed`, error); + } + } + + /** + * Claude Code denied a tool use without asking: on the session of the agent + * `agent_id` names, where its tool call is. + */ + permissionDenied(message: Extract): void { + const child = message.agent_id !== undefined ? this.children.get(message.agent_id) : undefined; + if (child?.announced) child.output!.permissionDenied(message, child.launchToolUseId); + else this.rootOutput.permissionDenied(message); + } + + /** + * A permission request on the session of `output` shows the tool call + * `toolCallId` to the client. A SendMessage call whose tool use has not + * streamed yet then stays shown: its updates go out, and its message goes + * to the child only. One that already streamed stays hidden. + */ + requestShows(output: SessionOutput, toolName: string, toolCallId: string): void { + if (toolName === "SendMessage" && !output.holds(toolCallId)) { + this.shownSendMessages.add(toolCallId); + } + } + + /** + * A hook of the subagent `input.agent_id`, as the v1 core reads them + * (`subagentConfigurationHook`): its `cwd` is the child's working + * directory, such as its worktree, and its configuration goes to the + * child's session (see `child-config.ts`). What a hook reports before the + * child's announcement goes with it. + */ + subagentHook(input: HookInput): void { + const agentId = input.agent_id; + if (agentId === undefined) return; + const child = this.children.get(agentId); + if (child?.announced) { + if (path.isAbsolute(input.cwd)) this.reportCwd(child, input.cwd); + } else if (this.startedHooks.has(agentId) || this.startedHooks.size < MAX_PENDING_HOOKS) { + const started = this.startedHooks.get(agentId) ?? {}; + if (path.isAbsolute(input.cwd)) started.cwd = input.cwd; + if (input.agent_type) started.agentType = input.agent_type; + this.startedHooks.set(agentId, started); + } + if (this.configs.take(agentId, this.configs.hookOptions(input)) && child?.announced) { + this.publishConfiguration(child, undefined); + } + } + + // --- Background work ---------------------------------------------------------- + + /** + * Stops the listed terminal or tool call of `sessionId` (`terminal/cancel` + * and `tool_call/cancel`). It ends when Claude Code reports its task + * stopped. Returns false when the session does not list it, also when it + * ended while Claude Code tried to stop it. + */ + async cancelItem( + sessionId: string, + target: { terminalId: string } | { toolCallId: string }, + ): Promise { + const taskId = this.listedTask(sessionId, target); + if (taskId === undefined) return false; + try { + await this.host.stopTask(taskId); + } catch (error) { + if (this.listedTask(sessionId, target) === undefined) return false; + throw error; + } + return true; + } + + /** + * The current state of the session's work, for a resume of the active + * session: each subagent that a session lists, or that works in its + * foreground, with its labels and state (also on its own stream), and the + * current update of each item that a session lists. Every item comes + * before the state that lists it, and a child's announcement before any + * update of its session, so a child whose list names items is announced + * first and gets its state after them. + */ + currentState(): v2.UpdateSessionNotification[] { + const updates: v2.UpdateSessionNotification[] = []; + const lists = this.lists(); + const visit = (sessionId: string) => { + const listed = lists.get(sessionId) ?? []; + const children = this.parentsFirst().filter( + (child) => + child.parentSessionId === sessionId && + (child.working || + listed.some((item) => item.type === "subagent" && item.sessionId === child.sessionId)), + ); + for (const child of children) { + const state = stateObject(this.childState(child), undefined); + if ((lists.get(child.sessionId) ?? []).length === 0) { + updates.push( + this.host.background.stamp({ + sessionId: child.parentSessionId, + update: { ...this.announcement(child), state }, + }), + ); + } else { + updates.push({ sessionId: child.parentSessionId, update: this.announcement(child) }); + visit(child.sessionId); + updates.push( + this.host.background.stamp({ + sessionId: child.parentSessionId, + update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state }, + }), + ); + } + updates.push( + this.host.background.stamp({ + sessionId: child.sessionId, + update: { sessionUpdate: "state_update", ...state } as v2.SessionUpdate, + }), + ); + } + for (const item of listed) { + if (item.type !== "tool_call") continue; + const listed = [...this.toolCalls].find(([, t]) => t.toolCallId === item.toolCallId); + if (!listed) continue; + const [taskId, toolCall] = listed; + const update = toolCall.toolUse + ? V2_RENDERER.toolCall( + { id: toolCall.toolCallId, ...toolCall.toolUse }, + { cwd: this.cwdOf(sessionId) }, + ) + : taskToolCall(toolCall.toolCallId, toolCall.title); + const status = toolCall.paused ? "paused" : "in_progress"; + for (const v2Update of this.host.translator.updates(sessionId, { + ...update, + ...workflowCard(this.level.get(taskId)?.progress), + status, + } as AcpSessionNotification["update"])) { + updates.push({ sessionId, update: v2Update }); + } + } + updates.push(...this.host.background.currentItems(sessionId)); + }; + visit(this.host.sessionId); + return updates; + } + + /** The tool calls of `sessionId` whose tasks go on in the background: a cancel leaves them. */ + backgroundCalls(sessionId: string): Set { + return new Set( + [...this.toolCalls.values()] + .filter((toolCall) => toolCall.output.sessionId === sessionId) + .map((toolCall) => toolCall.toolCallId), + ); + } + + /** + * Claude Code's query ended: its tasks ended with its process. Terminals + * exit and background tool calls are cancelled; a child that worked ends + * `error`, as the root's work does. + */ + queryEnded(error: { code: number; message: string } | undefined): void { + for (const [taskId, terminal] of this.terminals) { + terminal.tail?.stop(); + if (error) this.exitTerminal(taskId); + } + if (!error) return; + for (const [taskId] of this.toolCalls) this.endToolCall(taskId, "stopped"); + for (const child of this.children.values()) { + if (child.announced && child.working) this.endRun(child, { stopReason: "error", error }); + } + this.level = new Map(); + this.publish(); + } + + /** + * Claude Code mounted a fresh conversation (`conversation_reset`). Its + * subagents end, deeper ones first, as on v1 (`finishAll`): each + * child that works goes `idle` with no stop reason, since nothing says how + * its run ended, after its unfinished tool calls end `failed`. Then every + * child is forgotten: a later event of one + * changes nothing, and a child not announced yet stays unexposed. Claude + * Code's tasks (the live subagents, background commands and other tasks) + * stay as its task events report them. + */ + conversationReset(): void { + for (const child of [...this.parentsFirst()].reverse()) { + if (child.working) this.endRun(child, undefined); + } + this.publish(); + for (const child of this.children.values()) this.configs.forget(child.sessionId); + this.children.clear(); + this.childByToolUse.clear(); + this.launches.clear(); + this.pendingByLaunch.clear(); + this.cancelledAgents.clear(); + this.foregroundCancelled.clear(); + this.senderByToolUse.clear(); + this.startedHooks.clear(); + this.hiddenSendMessages.clear(); + this.shownSendMessages.clear(); + this.handbacks.clear(); + } + + /** + * Publishes the `background` list of each session that changed, deeper + * sessions first, so a child's own list is current before its parent's + * list names or drops it. + */ + publish(): void { + if (!this.host.reportsBackground) return; + // A task that needs a tool call of its own gets it first, so the list can name it. + this.adoptTasks(); + const lists = this.lists(); + for (const child of [...this.parentsFirst()].reverse()) { + this.sendAll( + this.host.background.changed({ + sessionId: child.sessionId, + items: lists.get(child.sessionId) ?? [], + state: this.childState(child), + parentSessionId: child.parentSessionId, + }), + ); + } + this.sendAll( + this.host.background.changed({ + sessionId: this.host.sessionId, + items: lists.get(this.host.sessionId) ?? [], + state: this.host.rootState(), + }), + ); + } + + // --- Task events ---------------------------------------------------------------- + + private taskStarted(message: Extract): void { + const isAgent = message.task_type === "local_agent" || message.subagent_type !== undefined; + if (!isAgent) { + this.started.set(message.task_id, { + ...(message.tool_use_id !== undefined ? { toolUseId: message.tool_use_id } : {}), + ...(message.parent_task_id !== undefined ? { parentTaskId: message.parent_task_id } : {}), + ...(message.task_type !== undefined ? { taskType: message.task_type } : {}), + }); + this.publish(); + return; + } + // A subagent that Claude Code does not run in the background blocks the + // work that waits on it, also before it is announced. + this.agents.set(message.task_id, { + parentTaskId: message.parent_task_id, + background: message.is_backgrounded === true, + }); + const toolUseId = message.tool_use_id ?? undefined; + const prompt = nonBlank(message.prompt); + let child = this.children.get(message.task_id); + if (child) { + // A resume: the same child, again, under the call that resumed it. + if (toolUseId !== undefined) { + this.childByToolUse.set(toolUseId, child); + child.launchToolUseId = toolUseId; + } + this.startRun(child, prompt, toolUseId); + } else if (toolUseId !== undefined && this.senderByToolUse.has(toolUseId)) { + // A SendMessage call resumed an agent that this process does not know, + // such as one from before a restart. + this.restoring = this.restore(message.task_id, prompt, toolUseId); + return; + } else { + // A child's parent is the subagent that launched it. One Claude Code + // no longer tracks, such as one from before a restart, cannot be + // placed: the child stays unexposed, its traffic the root's. + if (message.parent_task_id !== undefined && !this.children.has(message.parent_task_id)) { + this.cancelIfForeground(message.task_id); + this.host.foregroundRequestsChanged(); + return; + } + child = this.newChild(message.task_id, toolUseId, { + ...(nonBlank(message.description) ? { description: message.description } : {}), + ...(prompt ? { prompt } : {}), + ...(nonBlank(message.subagent_type) ? { subagentType: message.subagent_type } : {}), + }); + if (toolUseId !== undefined) { + this.pendingByLaunch.delete(toolUseId); + this.childByToolUse.set(toolUseId, child); + // The launch call names the parent. Without one, it is the parent task. + const launch = this.launches.get(toolUseId); + if (launch) this.announce(child, launch.output.sessionId); + } else { + // No session update may carry a session id before its announcement. + const parent = message.parent_task_id ?? this.host.sessionId; + if (parent === this.host.sessionId || this.children.get(parent)?.announced) { + this.announce(child, parent); + } else { + child.awaitedParent = parent; + } + } + } + // After `startRun`, which clears the stop of the resumed child's last run. + this.cancelIfForeground(message.task_id); + this.host.foregroundRequestsChanged(); + this.publish(); + } + + private taskUpdated(message: Extract): void { + const { task_id: taskId, patch } = message; + const child = this.children.get(taskId); + const agent = this.agents.get(taskId); + if (patch.is_backgrounded !== undefined && agent) { + agent.background = patch.is_backgrounded; + this.host.foregroundRequestsChanged(); + this.publish(); + } + switch (patch.status) { + case "completed": + case "failed": + this.taskEnded(taskId, patch.status, patch.error); + return; + case "killed": + this.taskEnded(taskId, "stopped"); + return; + case "paused": + case "running": + case "pending": { + const paused = patch.status === "paused"; + const toolCall = this.toolCalls.get(taskId); + if (toolCall && toolCall.paused !== paused) { + toolCall.paused = paused; + toolCall.output.toolCallStatus(toolCall.toolCallId, paused ? "paused" : "in_progress"); + } + // Claude Code can resume a subagent that ended without a new `task_started`. + if (patch.status === "running" && child && !child.working) { + this.agents.set(taskId, { parentTaskId: agent?.parentTaskId, background: true }); + this.startRun(child, undefined, undefined); + } + return; + } + default: + return; + } + } + + private taskNotification(message: Extract): void { + const child = this.children.get(message.task_id); + // The end of an earlier delegation to a resumed child ends nothing: the + // child and its task belong to the new run. + const toolUseId = message.tool_use_id ?? undefined; + if ( + child && + toolUseId !== undefined && + toolUseId !== child.launchToolUseId && + this.childByToolUse.get(toolUseId) === child + ) { + return; + } + this.taskEnded(message.task_id, message.status); + } + + /** + * A task's progress report. A workflow's goes on its tool call, as its + * title and body (`workflowCard`), without changing its status. A report + * that comes before the call returns waits for the result (see + * {@link toolResults}), and a report without `workflow_progress` keeps the + * last phase and agent list. A report that changes nothing on the card + * sends nothing. + */ + private taskProgress(message: Extract): void { + const task = this.level.get(message.task_id); + if (!task || task.taskType !== "local_workflow") return; + // The SDK does not type `workflow_progress`; see workflow-progress.ts. + const workflow = workflowSnapshot( + (message as { workflow_progress?: unknown }).workflow_progress, + ); + const previous = workflowCard(task.progress); + task.progress = { + ...task.progress, + ...taskProgressFields(message), + ...(workflow ? { workflow } : {}), + }; + const card = workflowCard(task.progress); + if (!card || JSON.stringify(previous) === JSON.stringify(card)) return; + const toolCall = this.toolCalls.get(message.task_id); + toolCall?.output.taskToolCallProgress(toolCall.toolCallId, card); + } + + /** + * The task `taskId` ended. Claude Code can report one end twice (a + * terminal `task_updated` and a `task_notification`), so a second is a + * no-op, unless it gives the error text of a failure that the first lacked. + */ + private taskEnded( + taskId: string, + status: "completed" | "failed" | "stopped", + error?: string, + ): void { + const child = this.children.get(taskId); + if (this.agents.delete(taskId)) this.host.foregroundRequestsChanged(); + if (child && !child.announced) { + this.withdrawChild(child); + } else if (child) { + this.endRun( + child, + status === "completed" + ? { stopReason: "end_turn" } + : status === "stopped" + ? { stopReason: "cancelled" } + : { + stopReason: "error", + ...(error + ? { error: { code: RequestError.internalError().code, message: error } } + : {}), + }, + ); + } + this.started.delete(taskId); + const terminal = this.terminals.get(taskId); + if (terminal && !terminal.ending) { + terminal.ending = true; + // The rest of the output goes out before the exit, and the list drops + // the terminal only after its exit. + void (terminal.tail?.finish() ?? Promise.resolve()).then(() => { + this.exitTerminal(taskId); + this.publish(); + }); + } + if (this.toolCalls.has(taskId)) this.endToolCall(taskId, status); + this.publish(); + } + + /** + * Claude Code's live background task set changed. Terminals and tool calls + * whose tasks it no longer holds ended, without saying how. + */ + private levelChanged( + tasks: Array<{ + task_id: string; + task_type: string; + description: string; + parent_task_id?: string; + ambient?: boolean; + }>, + ): void { + this.level = new Map( + tasks.map((task) => [ + task.task_id, + { + ambient: task.ambient === true, + taskType: task.task_type, + description: task.description, + ...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}), + progress: this.level.get(task.task_id)?.progress, + }, + ]), + ); + for (const [taskId, terminal] of this.terminals) { + if (this.level.get(taskId)?.ambient === false) terminal.leveled = true; + } + for (const taskId of [...this.terminals.keys(), ...this.toolCalls.keys()]) { + if (!this.level.has(taskId)) this.taskEnded(taskId, "completed"); + } + this.publish(); + } + + // --- Children --------------------------------------------------------------------- + + /** A new child, not announced, which runs; the messages of its launch that came wait for it. */ + private newChild( + sessionId: string, + launchToolUseId: string | undefined, + task: Child["task"], + ): Child { + const child: Child = { + sessionId, + parentSessionId: this.host.sessionId, + launchToolUseId, + task, + announced: false, + awaitedParent: undefined, + pending: + launchToolUseId !== undefined ? (this.pendingByLaunch.get(launchToolUseId) ?? []) : [], + title: undefined, + description: undefined, + working: true, + reportedState: undefined, + outcome: undefined, + openRequests: 0, + cwd: undefined, + output: undefined, + promptIds: new Set(), + stopped: new Set(), + endedRuns: new Set(), + handedBack: new Set(), + }; + this.children.set(sessionId, child); + return child; + } + + /** + * Restores the agent `agentId`, which the SendMessage call `toolUseId` + * resumed with `prompt`, as the v1 core does (`NativeSubagentRuntime.restore`): + * the process does not know it, because it ran before the session was + * resumed. The session's subagent history (`subagentLineage`) gives the + * call that launched it and its ancestors. Claude Code sends the resumed + * run's messages under that launch call's id. Before the agent's traffic, + * the RFD requires its ancestors, parent before child, so each one that is + * not announced is, with no state, since nothing says that it runs. Then + * the agent, running, and the SendMessage call's message to it. The RFD + * forbids guessing a parent, so an agent that the history cannot place + * stays unexposed, its traffic the root's. + */ + private async restore( + agentId: string, + prompt: string | undefined, + toolUseId: string, + ): Promise { + const lineage: HistoricalSubagent[] = await subagentLineage( + this.host.sessionId, + agentId, + this.host.logger, + ).catch((error: unknown) => { + this.host.logger.error(`Subagent ${agentId}: reading its history failed`, error); + return []; + }); + // The session handles no message while the history is read (see `restored`). + if (this.closed || this.children.has(agentId) || lineage.at(-1)?.agentId !== agentId) { + this.host.foregroundRequestsChanged(); + return; + } + for (const entry of lineage) { + if (this.children.get(entry.agentId)?.announced) continue; + const child = this.newChild(entry.agentId, entry.launchId, { + ...(entry.name ? { name: entry.name } : {}), + ...(entry.description ? { description: entry.description } : {}), + ...(entry.agentType ? { subagentType: entry.agentType } : {}), + }); + this.childByToolUse.set(entry.launchId, child); + // A hook of the resumed agent may have reported a newer `cwd`. + if (entry.cwd && this.startedHooks.get(entry.agentId)?.cwd === undefined) { + this.startedHooks.set(entry.agentId, { + ...this.startedHooks.get(entry.agentId), + cwd: entry.cwd, + }); + } + const parent = entry.parentAgentId ?? this.host.sessionId; + if (entry.agentId === agentId) { + this.announce(child, parent); + } else { + child.working = false; + this.open(child, parent); + this.host.send({ sessionId: parent, update: this.announcement(child, false) }); + } + } + const child = this.children.get(agentId)!; + this.childByToolUse.set(toolUseId, child); + child.launchToolUseId = toolUseId; + this.startRun(child, prompt, toolUseId); + this.host.foregroundRequestsChanged(); + } + + /** + * A message of a subagent, by its `parent_tool_use_id`: it goes to the + * child's stream, or waits for the child's announcement. A message of an + * agent that no known call launched is the root's. + */ + private childMessage(message: SDKMessage & { parent_tool_use_id: string | null }): boolean { + const launchId = message.parent_tool_use_id; + if (launchId === null) return false; + const child = this.childByToolUse.get(launchId); + if (child?.announced) { + this.deliver(child, message); + return true; + } + if (!child && !this.launches.has(launchId)) return false; + const pending = child?.pending ?? this.pendingByLaunch.get(launchId) ?? []; + if (!child) this.pendingByLaunch.set(launchId, pending); + if (pending.length < MAX_PENDING_MESSAGES) pending.push(message); + else this.host.logger.error(`Subagent of ${launchId}: dropping a message before its start`); + return true; + } + + /** Sends a message of the announced `child` to its stream. */ + private deliver(child: Child, message: SDKMessage): void { + const output = child.output!; + switch (message.type) { + case "stream_event": + if (message.event.type === "message_start") { + this.reportModel(child, message.event.message.model); + } + output.streamEvent(message); + return; + case "assistant": + this.reportModel(child, message.message.model); + output.assistant(message); + return; + case "tool_progress": + output.toolProgress(message); + return; + case "user": { + // Its prompts are the delegations, which go out as session messages. + // Its other text is Claude Code's, such as "[Request interrupted by + // user]", which the child's session does not show. + const content = message.message.content; + if (Array.isArray(content) && content.some((block) => block.type === "tool_result")) { + // The session handles its next message after the restore (see `restored`). + const restoring = this.restoreResumed(output, message); + if (restoring) this.restoring = restoring.then(() => this.toolResults(output, message)); + else this.toolResults(output, message); + } + return; + } + default: + return; + } + } + + /** + * Announces `child` on `parentSessionId`, running, with its parent's + * labels for it, then sends its prompt as the parent's message to it, and + * the messages that came before. + */ + private announce(child: Child, parentSessionId: string): void { + const identity = this.open(child, parentSessionId); + child.reportedState = "running"; + const state = stateObject("running", undefined); + this.host.send({ sessionId: parentSessionId, update: { ...this.announcement(child), state } }); + this.host.send({ + sessionId: child.sessionId, + update: { sessionUpdate: "state_update", ...state } as v2.SessionUpdate, + }); + const prompt = child.task.prompt ?? identity?.prompt; + if (prompt) this.sendPrompt(child, prompt, child.launchToolUseId, parentSessionId, true); + // What its hooks reported before, after the prompt. + this.publishConfiguration(child, undefined); + const pending = child.pending; + child.pending = []; + for (const message of pending) this.deliver(child, message); + for (const waiting of [...this.children.values()]) { + if (!waiting.announced && waiting.awaitedParent === child.sessionId) { + waiting.awaitedParent = undefined; + this.announce(waiting, child.sessionId); + } + } + } + + /** + * Makes `child` a session of `parentSessionId`, with its parent's labels + * for it, its `cwd` and its output. Returns the identity of its launch. + */ + private open(child: Child, parentSessionId: string): SubagentIdentity | undefined { + const launch = + child.launchToolUseId !== undefined ? this.launches.get(child.launchToolUseId) : undefined; + const identity = launch?.identity; + const hook = this.startedHooks.get(child.sessionId); + this.startedHooks.delete(child.sessionId); + const labels = rfdLabels( + identity?.name ?? child.task.name, + identity?.subagentType ?? child.task.subagentType ?? hook?.agentType, + identity?.description ?? child.task.description, + ); + child.parentSessionId = parentSessionId; + child.title = labels.title; + child.description = labels.description; + const parentCwd = this.cwdOf(parentSessionId); + child.cwd = hook?.cwd && !sameDirectory(hook.cwd, parentCwd) ? hook.cwd : undefined; + const output = new SessionOutput( + child.sessionId, + child.cwd ?? parentCwd, + this.host.translator, + (update) => this.host.send({ sessionId: child.sessionId, update }), + this.host.logger, + true, + ); + output.route = (notification) => this.route(output, notification, child); + child.output = output; + child.announced = true; + // Before the messages that waited for the announcement go out. + this.cancelIfForeground(child.sessionId); + return identity; + } + + /** + * A message delegated to `child`, with the id of the call that sent it. A + * launch's prompt replaces its call (`linked`): it goes out as the + * sender's outgoing message and the child's incoming one. + */ + private sendPrompt( + child: Child, + text: string, + toolUseId: string | undefined, + sender: string | undefined, + linked: boolean, + ): void { + const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`; + if (child.promptIds.has(messageId)) return; + child.promptIds.add(messageId); + if (linked && toolUseId !== undefined && sender !== undefined) { + const [outgoing, incoming] = linkedSessionMessages(toolUseId, sender, child.sessionId, text); + this.outputOf(sender)?.emitV1([outgoing!]); + child.output!.emitV1([incoming!]); + return; + } + child.output!.emitV1([ + { + sessionId: child.sessionId, + update: { + sessionUpdate: "session_message", + messageId, + ...(sender !== undefined ? { senderSessionId: sender } : {}), + recipientSessionId: child.sessionId, + content: [{ type: "text", text }], + }, + }, + ]); + } + + /** + * Rewrites an update of `output`, the output of the root or of `child`: an + * update of an Agent or Task call goes out as {@link launchUpdate} says, + * and Claude Code's late result of a call that the child's stop ended + * goes nowhere. + */ + private route( + output: SessionOutput, + notification: AcpSessionNotification, + child: Child | undefined, + ): AcpSessionNotification[] { + const { update } = notification; + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + return [notification]; + } + if (child?.stopped.has(update.toolCallId)) return []; + const toolName = (update._meta?.claudeCode as { toolName?: unknown } | undefined)?.toolName; + if (toolName === "SendMessage") this.senderByToolUse.set(update.toolCallId, output.sessionId); + const message = + this.sendMessageUpdate(output, notification, toolName) ?? + (child ? this.handbackUpdate(child, notification, toolName) : undefined); + if (message) return message; + let launch = this.launches.get(update.toolCallId); + if (!launch) { + if (!isNativeSubagentControlTool(toolName)) return [notification]; + launch = { + toolUseId: update.toolCallId, + output, + identity: undefined, + background: false, + initial: undefined, + ended: false, + }; + this.launches.set(launch.toolUseId, launch); + } + return this.launchUpdate(launch, notification); + } + + /** + * An update of an Agent or Task call. Its child, if it started, is + * announced at the call's first update. The call goes out only once it + * ends without an announced child, except a background launch that + * completed: its child starts after it. A foreground child's answer, the + * completed result, is its reply to the parent. + */ + private launchUpdate( + launch: Launch, + notification: AcpSessionNotification, + ): AcpSessionNotification[] { + const update = notification.update as Extract< + AcpSessionNotification["update"], + { sessionUpdate: "tool_call" | "tool_call_update" } + >; + if (update.sessionUpdate === "tool_call") launch.initial ??= notification; + const identity = subagentIdentity(update.rawInput); + if (identity) launch.identity = mergeSubagentIdentity(launch.identity, identity); + if ((update.rawInput as { run_in_background?: unknown } | null)?.run_in_background === true) { + launch.background = true; + } + const child = this.childByToolUse.get(launch.toolUseId); + if (child && !child.announced) this.announce(child, launch.output.sessionId); + if (!FINAL_STATUSES.has(update.status) || launch.ended) return []; + launch.ended = true; + if (child?.announced) { + // A run that handed back its report has none in its result. + if ( + update.status !== "completed" || + launch.background || + child.handedBack.has(launch.toolUseId) + ) { + return []; + } + const reply = launchReply(launch.toolUseId, child.sessionId, child.parentSessionId, update); + return reply ? [reply] : []; + } + // No child came, or it ended unexposed: its messages are dropped with it. + this.pendingByLaunch.delete(launch.toolUseId); + if (launch.background && update.status === "completed") return []; + return [unexposedControlFallback(launch.initial, notification, launch.output.sessionId)]; + } + + /** + * An update of a SendMessage call, which its message replaces once it + * reaches an announced child (see {@link startRun}): the sender's outgoing + * view and the child's incoming one. Claude Code reports that delivery + * before the call's final update. A call whose message reached no announced + * child, such as one to the parent, is shown after all, with its final + * status. Undefined for any other update. + */ + private sendMessageUpdate( + output: SessionOutput, + notification: AcpSessionNotification, + toolName: unknown, + ): AcpSessionNotification[] | undefined { + const update = notification.update as ToolCallUpdate; + // A call that its permission request showed (see `requestShows`). + if (this.shownSendMessages.has(update.toolCallId)) { + if (FINAL_STATUSES.has(update.status)) this.shownSendMessages.delete(update.toolCallId); + return [notification]; + } + let call = this.hiddenSendMessages.get(update.toolCallId); + if (!call) { + if (update.sessionUpdate !== "tool_call" || toolName !== "SendMessage") return undefined; + call = { initial: notification, sender: output.sessionId, delivered: false }; + this.hiddenSendMessages.set(update.toolCallId, call); + } + if (!FINAL_STATUSES.has(update.status)) return []; + this.hiddenSendMessages.delete(update.toolCallId); + if (call.delivered) return []; + return [unexposedControlFallback(call.initial, notification, call.sender)]; + } + + /** + * An update of a SubagentHandback call of the announced `child`, which its + * report replaces: the child's message to its parent, the outgoing view on + * the child and the incoming one on the parent. Its run's result is then no + * reply. A call that failed delivered nothing, so the child is shown the + * call after all. Undefined for any other update. + */ + private handbackUpdate( + child: Child, + notification: AcpSessionNotification, + toolName: unknown, + ): AcpSessionNotification[] | undefined { + const update = notification.update as ToolCallUpdate; + let handback = this.handbacks.get(update.toolCallId); + if (!handback) { + if (toolName !== HANDBACK_TOOL) return undefined; + const parentToolUseId = ( + update._meta?.claudeCode as { parentToolUseId?: string | null } | undefined + )?.parentToolUseId; + const delegationId = parentToolUseId ?? child.launchToolUseId; + handback = { + initial: notification, + ...(delegationId !== undefined ? { delegationId } : {}), + }; + this.handbacks.set(update.toolCallId, handback); + } + handback.message = handbackMessage(update.rawInput) ?? handback.message; + if (!FINAL_STATUSES.has(update.status)) return []; + this.handbacks.delete(update.toolCallId); + if (update.status !== "completed" || !handback.message) { + return [unexposedControlFallback(handback.initial, notification, child.sessionId)]; + } + if (handback.delegationId !== undefined) child.handedBack.add(handback.delegationId); + const [outgoing, incoming] = linkedSessionMessages( + update.toolCallId, + child.sessionId, + child.parentSessionId, + handback.message, + ); + // Sent here, in order: an output routes each update once the ones before it went out. + child.output!.emitV1([outgoing!]); + this.outputOf(child.parentSessionId)?.emitV1([incoming!]); + return []; + } + + /** + * A SendMessage result that reports the agent it resumed (`resumedAgentId`): + * the call is the child's delegation, which it takes unless the run of that + * call ended already, as on v1 (`sendMessageResumed`). Claude Code + * can resume a child without a new `task_started`. + */ + private sendMessageResumed( + message: SDKUserMessage, + results: ToolResultBlock[], + toolUses: ReturnType, + ): void { + const agentId = resumedNativeSubagentId(message.tool_use_result); + const child = agentId !== undefined ? this.children.get(agentId) : undefined; + if (!child?.announced) return; + const resume = sendMessageResume( + toolUses, + child.sessionId, + results.map((block) => block.tool_use_id), + ); + if (resume && child.endedRuns.has(resume.toolUseId)) return; + if (!this.agents.has(child.sessionId)) { + this.agents.set(child.sessionId, { parentTaskId: undefined, background: true }); + } + if (resume) { + this.childByToolUse.set(resume.toolUseId, child); + child.launchToolUseId = resume.toolUseId; + } + this.startRun(child, resume?.text, resume?.toolUseId); + } + + /** A child that ended before its announcement stays unexposed, and is forgotten. */ + private withdrawChild(child: Child): void { + this.children.delete(child.sessionId); + this.configs.forget(child.sessionId); + this.startedHooks.delete(child.sessionId); + for (const [toolUseId, owner] of this.childByToolUse) { + if (owner === child) this.childByToolUse.delete(toolUseId); + } + this.cancelledAgents.delete(child.sessionId); + } + + /** A new run of the announced `child`, with the prompt that the call `toolUseId` sent it. */ + private startRun(child: Child, prompt: string | undefined, toolUseId: string | undefined): void { + if (!child.announced) return; + if (!child.working) { + child.working = true; + child.outcome = undefined; + this.cancelledAgents.delete(child.sessionId); + this.reportChild(child); + } + if (prompt && toolUseId !== undefined) { + // The message of a SendMessage call that the client is not shown replaces it. + const call = this.hiddenSendMessages.get(toolUseId); + this.sendPrompt( + child, + prompt, + toolUseId, + call?.sender ?? this.senderByToolUse.get(toolUseId), + call !== undefined, + ); + if (call) call.delivered = true; + } + this.publish(); + } + + /** + * A run of `child` ended: its unfinished tool calls get their final status, + * except its background work, then its idle goes out, as v2 asks. A stopped + * run's end `cancelled`, and a failed run's or an unexplained end's + * (`undefined`, an idle with no stop reason) `failed`. At a completed end, a + * tool call that Claude dropped before it ran ends `cancelled`, as on the + * root (see `SessionOutput.closeUnfinished`). + */ + private endRun(child: Child, outcome: IdleOutcome | undefined): void { + if (!child.working) { + // A later report of the same failure can give its error text. + if ( + outcome?.stopReason === "error" && + outcome.error && + child.outcome?.stopReason === "error" && + !child.outcome.error + ) { + child.outcome = outcome; + this.sendChildState(child, "idle"); + } + return; + } + const keep = this.backgroundWork(child.sessionId); + if (outcome?.stopReason === "end_turn") { + child.output!.closeUnfinished(false, keep); + } else { + const status = outcome?.stopReason === "cancelled" ? "cancelled" : "failed"; + const ended = child.output!.finishUnfinished(status, keep, { exitTerminals: true }); + for (const toolCallId of ended) child.stopped.add(toolCallId); + } + child.working = false; + child.outcome = outcome; + child.output!.cancelled = false; + if (child.launchToolUseId !== undefined) child.endedRuns.add(child.launchToolUseId); + this.reportChild(child); + this.cancelledAgents.delete(child.sessionId); + } + + /** The tool calls and terminals of `sessionId` that go on in the background. */ + backgroundWork(sessionId: string): Set { + const work = this.backgroundCalls(sessionId); + for (const terminal of this.terminals.values()) { + if (terminal.output.sessionId === sessionId) work.add(terminal.toolCallId); + } + return work; + } + + /** + * Rewrites a replayed update of `sessionId`'s background work as the live + * tool result was (see `toolResult`): a running command's result keeps its + * terminal running, and a running task's tool call stays `in_progress`. + */ + replayedBackground( + sessionId: string, + ): (notification: AcpSessionNotification) => AcpSessionNotification { + const terminals = [...this.terminals.values()] + .filter((terminal) => terminal.output.sessionId === sessionId) + .map((terminal) => terminal.toolCallId); + const toolCalls = this.backgroundCalls(sessionId); + type Notification = Parameters[0]; + return (notification) => + withRunningToolCall( + terminals.reduce(withRunningTerminal, notification as Notification), + toolCalls, + ) as AcpSessionNotification; + } + + private childState(child: Child): ChildState { + if (!child.working) return "idle"; + return child.openRequests > 0 ? "requires_action" : "running"; + } + + /** Sends the child's state on its stream and in its parent's mirror, when it changed. */ + private reportChild(child: Child): void { + if (!child.announced) return; + const state = this.childState(child); + if (state === child.reportedState) return; + // A cancel withdrew the open requests: the run is stopping, and its idle follows. + if ( + child.reportedState === "requires_action" && + state === "running" && + this.cancelledAgents.has(child.sessionId) + ) { + return; + } + this.sendChildState(child, state); + } + + private sendChildState(child: Child, state: ChildState): void { + child.reportedState = state; + const snapshot = stateObject(state, state === "idle" ? child.outcome : undefined); + this.host.send({ + sessionId: child.parentSessionId, + update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state: snapshot }, + }); + this.host.send({ + sessionId: child.sessionId, + update: { sessionUpdate: "state_update", ...snapshot } as v2.SessionUpdate, + }); + } + + /** + * Runs `send` as an open request of the agent `agentId` (see + * {@link RequestTarget.openRequest}). While it is open, `announced`, the + * child session it goes to, is `requires_action`, and the work that waits + * on the agent is too. After the root closed, or while a cancel stops the + * agent's run, `send` runs with an aborted signal and the request does not + * count as open. + */ + private async agentRequest( + agentId: string, + announced: Child | undefined, + toolSignal: AbortSignal, + send: (signal: AbortSignal) => Promise, + ): Promise { + if (this.closed || this.cancelledAgents.has(agentId)) return send(AbortSignal.abort()); + const abort = new AbortController(); + const signal = AbortSignal.any([toolSignal, abort.signal]); + const requests = this.agentRequests.get(agentId) ?? new Set(); + this.agentRequests.set(agentId, requests); + requests.add(abort); + if (announced) { + announced.openRequests++; + this.reportChild(announced); + } + this.host.foregroundRequestsChanged(); + try { + return await send(signal); + } finally { + requests.delete(abort); + if (requests.size === 0) this.agentRequests.delete(agentId); + this.host.foregroundRequestsChanged(); + if (announced) { + announced.openRequests--; + this.reportChild(announced); + } + } + } + + /** Set by the session: sends a permission request to the client. */ + hostRequest!: ( + request: v2.RequestPermissionRequest, + signal: AbortSignal, + ) => Promise; + + /** + * While the root's cancel is in effect, the agent `agentId`, when the root + * waits on it, is stopped with the root's work (see {@link cancelForeground}). + */ + private cancelIfForeground(agentId: string): void { + if (!this.rootCancelling || !this.waitsOn(agentId, undefined)) return; + this.withdraw(agentId); + this.foregroundCancelled.add(agentId); + const output = this.children.get(agentId)?.output; + if (output) output.cancelled = true; + } + + /** Withdraws the open requests of the agent `agentId`, whose run a cancel stops. */ + private withdraw(agentId: string): void { + this.cancelledAgents.add(agentId); + for (const abort of this.agentRequests.get(agentId) ?? []) abort.abort(); + } + + /** + * Whether the work of `sessionId` (the root when undefined) waits on the + * agent `agentId`: the agent is that session's, or each agent from it up to + * that session runs in the foreground of its parent. + */ + private waitsOn(agentId: string, sessionId: string | undefined): boolean { + const seen = new Set(); + for (let id: string | undefined = agentId; id !== undefined && !seen.has(id);) { + if (id === sessionId) return true; + seen.add(id); + const task = this.agents.get(id); + if (!task || task.background) return false; + id = task.parentTaskId; + } + return sessionId === undefined; + } + + /** + * Reports the model of a child response as the entry of the root's `model` + * option that matches it, else as the id, in the child's configuration. + * The child gets no model before its first response, and the client gets + * its configuration again only when it changes. + */ + private reportModel(child: Child, model: string | undefined): void { + const entry = responseModelEntry(model, this.host.models()); + if (!entry || !this.configs.take(child.sessionId, [childModelOption(entry)])) return; + this.publishConfiguration( + child, + child.launchToolUseId !== undefined + ? { claudeCode: { parentToolUseId: child.launchToolUseId } } + : undefined, + ); + } + + /** Sends the configuration of the announced `child`, when the client does not hold it. */ + private publishConfiguration(child: Child, meta: Record | undefined): void { + const configOptions = this.configs.unsent(child.sessionId); + if (!configOptions) return; + child.output!.emitV1([ + { + sessionId: child.sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions, + ...(meta ? { _meta: meta } : {}), + }, + }, + ]); + } + + private reportCwd(child: Child, cwd: string): void { + const parentCwd = this.cwdOf(child.parentSessionId); + const next = sameDirectory(cwd, parentCwd) ? undefined : cwd; + if (next === child.cwd) return; + child.cwd = next; + this.host.send({ + sessionId: child.parentSessionId, + update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, cwd: next ?? null }, + }); + } + + /** The working directory of the root or of an announced child. */ + private cwdOf(sessionId: string): string { + const child = this.children.get(sessionId); + if (!child?.announced) return this.host.cwd; + return child.cwd ?? this.cwdOf(child.parentSessionId); + } + + private outputOf(sessionId: string): SessionOutput | undefined { + if (sessionId === this.host.sessionId) return this.rootOutput; + return this.children.get(sessionId)?.output; + } + + /** + * The announcement of `child`, with its `cancel` capability unless + * `cancellable` is false: a restored ancestor, which nothing says runs. + */ + private announcement( + child: Child, + cancellable = true, + ): Extract { + return { + sessionUpdate: "subagent_update", + sessionId: child.sessionId, + ...(child.title !== undefined ? { title: child.title } : {}), + ...(child.description !== undefined ? { description: child.description } : {}), + ...(child.cwd !== undefined ? { cwd: child.cwd } : {}), + // `session/cancel` of the child stops its run (`stopTask`). + ...(cancellable ? { capabilities: { cancel: {} } } : {}), + }; + } + + /** The announced children, each after its parent. */ + private parentsFirst(): Child[] { + const ordered: Child[] = []; + const visit = (sessionId: string) => { + for (const child of this.children.values()) { + if (!child.announced || child.parentSessionId !== sessionId) continue; + ordered.push(child); + visit(child.sessionId); + } + }; + visit(this.host.sessionId); + return ordered; + } + + // --- Lists ------------------------------------------------------------------------ + + /** + * The `background` list of each session, from Claude Code's live background + * tasks: its background terminals, tool calls and other tasks, and its + * children that work in the background or have background work of their + * own. Claude Code marks its housekeeping `ambient`, which is left out, + * except a task that a tool call started. A task that a tool call started + * is part of that call until the call returns, and is not listed. + */ + private lists(): Map { + const lists = new Map([[this.host.sessionId, []]]); + const children = this.parentsFirst(); + for (const child of children) lists.set(child.sessionId, []); + for (const [taskId, task] of this.level) { + const toolCall = this.toolCalls.get(taskId); + if (toolCall) { + lists + .get(toolCall.output.sessionId) + ?.push({ type: "tool_call", toolCallId: toolCall.toolCallId }); + continue; + } + if (task.ambient) continue; + const terminal = this.terminals.get(taskId); + if (terminal) { + lists + .get(terminal.output.sessionId) + ?.push({ type: "terminal", terminalId: terminal.toolCallId }); + continue; + } + // Any other task is listed by the tool call that `adoptTasks` starts + // for it, or is not background work. A subagent is listed below, as + // its session. + } + // A terminal whose task left the level is listed until its exit, which + // waits for the rest of its output. + for (const [taskId, terminal] of this.terminals) { + if (terminal.ending && terminal.leveled && !this.level.has(taskId)) { + lists + .get(terminal.output.sessionId) + ?.push({ type: "terminal", terminalId: terminal.toolCallId }); + } + } + // Deeper children first: a child's own list is whole before its parent's reads it. + for (const child of [...children].reverse()) { + const task = this.level.get(child.sessionId); + const inBackground = child.working && task !== undefined && !task.ambient; + if (inBackground || (lists.get(child.sessionId)?.length ?? 0) > 0) { + lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId }); + } + } + return lists; + } + + /** + * Starts a tool call for each live task that no terminal or tool call + * stands for yet: v2 lists background work only by an entity the client + * knows. Such a task is one that no tool call started, such as one a hook + * started, or one whose tool call returned, with its final status, before + * the task started. The call (`task_`) has the task's description + * as its title, is `in_progress`, and ends with the task. A task of an + * announced subagent gets its call on the subagent's session. Ambient + * tasks are housekeeping and get no call. {@link publish} calls it before + * each list it publishes, so the call comes before the first list that + * names it, as v2 requires. + */ + private adoptTasks(): void { + for (const [taskId, task] of this.level) { + if (task.ambient || this.toolCalls.has(taskId) || this.terminals.has(taskId)) continue; + // A command without a terminal yet is one that foreground work waits on. + // A subagent is listed as its session. + if (task.taskType === "local_bash" || task.taskType === "local_agent") continue; + // A task joins the level before its `task_started`, and before the tool + // call that starts it returns. Until both, the task is unknown or part + // of that tool call. + const started = this.started.get(taskId); + if (!started || (started.toolUseId && !this.returned.has(started.toolUseId))) continue; + const parent = task.parentTaskId ? this.children.get(task.parentTaskId) : undefined; + const output = (parent?.announced ? parent.output : undefined) ?? this.rootOutput; + const toolCallId = `task_${taskId}`; + this.toolCalls.set(taskId, { toolCallId, output, title: task.description, paused: false }); + output.taskToolCall(taskToolCall(toolCallId, task.description)); + } + } + + /** The task of the terminal or tool call `target` that `sessionId` lists. */ + private listedTask( + sessionId: string, + target: { terminalId: string } | { toolCallId: string }, + ): string | undefined { + if (!this.host.reportsBackground) return undefined; + const listed = this.lists().get(sessionId) ?? []; + return "terminalId" in target + ? listed.some((item) => item.type === "terminal" && item.terminalId === target.terminalId) + ? [...this.terminals].find(([, t]) => t.toolCallId === target.terminalId)?.[0] + : undefined + : listed.some((item) => item.type === "tool_call" && item.toolCallId === target.toolCallId) + ? [...this.toolCalls].find(([, t]) => t.toolCallId === target.toolCallId)?.[0] + : undefined; + } + + private followOutput(taskId: string, outputFilePath: string | undefined): void { + const terminal = this.terminals.get(taskId); + if (!terminal || !outputFilePath || !this.host.reportsBackground) return; + terminal.tail = new BackgroundOutputTail(outputFilePath, async (output) => + terminal.output.emitV1([ + terminalOutput(terminal.output.sessionId, terminal.toolCallId, output), + ]), + ); + } + + private exitTerminal(taskId: string): void { + const terminal = this.terminals.get(taskId); + if (!terminal) return; + this.terminals.delete(taskId); + terminal.output.emitV1([ + { + sessionId: terminal.output.sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: terminal.toolCallId, + _meta: { + terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null }, + }, + }, + }, + ]); + } + + private endToolCall(taskId: string, status: "completed" | "failed" | "stopped"): void { + const toolCall = this.toolCalls.get(taskId); + if (!toolCall) return; + this.toolCalls.delete(taskId); + toolCall.output.toolCallStatus( + toolCall.toolCallId, + status === "stopped" ? "cancelled" : status, + ); + } + + private sendAll(notifications: v2.UpdateSessionNotification[]): void { + for (const notification of notifications) this.host.send(notification); + } +} + +/** + * The `tool_call` that the adapter starts for a task (see + * `SessionTasks.adoptTasks`). Claude Code's tool use ids start with `toolu_`, + * so `task_` names no other call. + */ +function taskToolCall( + toolCallId: string, + title: string | undefined, +): Extract { + return { + sessionUpdate: "tool_call", + toolCallId, + title: title || "Background task", + kind: "other", + status: "in_progress", + }; +} + +/** + * Whether two paths name one directory, also through a symlink: a hook's + * `cwd` is the real path, such as `/private/tmp/project` for `/tmp/project`. + */ +export function sameDirectory(left: string, right: string): boolean { + const real = (directory: string) => { + try { + return fs.realpathSync.native(directory); + } catch { + return path.resolve(directory); + } + }; + return path.resolve(left) === path.resolve(right) || real(left) === real(right); +} + +/** A child's state as a `StateUpdate`: an idle with how its last run ended. */ +function stateObject(state: ChildState, outcome: IdleOutcome | undefined): v2.StateUpdate { + switch (state) { + case "running": + return { state: "running" }; + case "requires_action": + return { state: "requires_action" }; + default: + return { state: "idle", ...(outcome ?? {}) } as v2.StateUpdate; + } +} + +function nonBlank(value: unknown): string | undefined { + return typeof value === "string" && value.trim().length > 0 ? value : undefined; +} + +/** The v1 update that gives a background command's terminal its output. */ +function terminalOutput( + sessionId: string, + terminalId: string, + output: BackgroundOutput, +): AcpSessionNotification { + const data = { terminal_id: terminalId, data: output.text }; + return { + sessionId, + update: { + sessionUpdate: "tool_call_update", + toolCallId: terminalId, + _meta: output.replaces ? { terminal_output: data } : { terminal_output_delta: data }, + }, + }; +} + +/** The error of a request on a child session: the client can only watch a child. */ +export function restrictedChild(sessionId: string, method: string): RequestError { + return RequestError.invalidParams( + { sessionId }, + `${sessionId} is a subagent session: the client cannot ${method} it. Its parent session runs it.`, + ); +} diff --git a/src/v2/terminal.ts b/src/v2/terminal.ts index 486d75b6..e018a138 100644 --- a/src/v2/terminal.ts +++ b/src/v2/terminal.ts @@ -56,7 +56,15 @@ export class V2Terminals { ): { terminal: v2.SessionUpdate[]; report: V1ToolCallReport | undefined } { const meta = (report._meta ?? {}) as TerminalMeta; const key = `${sessionId} ${report.toolCallId}`; - if (meta.terminal_info) this.running.set(key, { terminalId: meta.terminal_info.terminal_id }); + // A replay of a command that still runs, such as one in the background, + // names its terminal again: the running entry keeps its command and + // `cancellable`. + if ( + meta.terminal_info && + this.running.get(key)?.terminalId !== meta.terminal_info.terminal_id + ) { + this.running.set(key, { terminalId: meta.terminal_info.terminal_id }); + } const terminal: v2.SessionUpdate[] = []; const running = this.running.get(key); if (running) { diff --git a/src/v2/tool-call.ts b/src/v2/tool-call.ts index 27a76b0a..6138b332 100644 --- a/src/v2/tool-call.ts +++ b/src/v2/tool-call.ts @@ -1,6 +1,7 @@ /** - * The v2 form of the tool call reports that `ClaudeAcpAgent` sends as v1 - * `tool_call` and `tool_call_update` updates, and in permission requests. + * The v2 form of the tool call reports that the shared SDK-to-ACP mapping + * produces as v1 `tool_call` and `tool_call_update` updates, and of the tool + * calls of permission requests. */ import type { ToolCall, ToolCallContent, ToolCallUpdate } from "@agentclientprotocol/sdk/unstable"; import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; diff --git a/src/v2/update-translator.ts b/src/v2/update-translator.ts new file mode 100644 index 00000000..7cd828b4 --- /dev/null +++ b/src/v2/update-translator.ts @@ -0,0 +1,106 @@ +/** + * Turns the v1 session updates that the shared SDK-to-ACP mapping produces + * (`toAcpNotifications`) into the v2 updates of one connection. + * + * The v2 core uses it for every update that it maps from + * the SDK stream. It holds the per-connection state that the translation + * needs: the display terminals of commands and the `_meta` the client stores + * for each tool call. + */ +import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable"; +import type { AcpSessionNotification } from "../acp-subagents.js"; +import { v2SessionUpdate } from "./session-update.js"; +import { V2Terminals } from "./terminal.js"; +import { V2ToolCallMeta } from "./tool-call-meta.js"; +import { v2RestartedToolCall } from "./tool-call.js"; + +export class V2UpdateTranslator { + /** The display terminals of the commands that the agent runs. */ + readonly terminals = new V2Terminals(); + + /** The `_meta` that the client stores for each tool call. */ + readonly toolCallMeta = new V2ToolCallMeta(); + + /** + * The v2 updates of a v1 update: for a tool call report, the updates of its + * terminal first (see {@link V2Terminals}), then the report itself, with the + * `_meta` keys that it changes (see {@link V2ToolCallMeta}), and during a + * replay the `_meta` of a tool call that the report cleared. + */ + updates( + sessionId: string, + update: AcpSessionNotification["update"], + replaying = false, + ): v2.SessionUpdate[] { + if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") { + const v2Update = v2SessionUpdate(update); + return v2Update ? [v2Update] : []; + } + const { terminal, report } = this.terminals.split(sessionId, update); + if (!report) return terminal; + const starts = report.sessionUpdate === "tool_call"; + // A v1 `tool_call` starts the tool call afresh, but v2 only has updates, + // which keep each field they leave out. The client may already hold this + // id: from an earlier report on this connection, or, during a replay, from + // before it (a client that resumes a session it showed before). + const restarts = + starts && (replaying || this.toolCallMeta.reported(sessionId, report.toolCallId)); + const [reported, metaPatch] = this.toolCallMeta.apply(sessionId, report, starts, replaying); + const reportUpdate = restarts ? v2RestartedToolCall(reported) : v2SessionUpdate(reported); + const metaUpdate = metaPatch + ? v2SessionUpdate({ sessionUpdate: "tool_call_update", ...metaPatch }) + : undefined; + return [ + ...terminal, + ...(reportUpdate ? [reportUpdate] : []), + ...(metaUpdate ? [metaUpdate] : []), + ]; + } + + /** Forgets the terminals and tool calls of a session, when it closes. */ + forget(sessionId: string): void { + this.terminals.forget(sessionId); + this.toolCallMeta.forget(sessionId); + } +} + +/** + * The update that clears the message of a replayed chunk, when it is the + * first replayed chunk of that message: a v2 client may still hold a message + * that a replay sends again, and chunks append to it. `cleared` holds the + * messages that the replay has cleared so far, and gains this one. + */ +export function replayClear( + cleared: Set, + update: v2.SessionUpdate, +): v2.SessionUpdate | undefined { + const kind = MESSAGE_OF_CHUNK.get(update.sessionUpdate); + if (!kind) return undefined; + const { messageId } = update as { messageId: string }; + const key = `${kind} ${messageId}`; + if (cleared.has(key)) return undefined; + cleared.add(key); + return emptyMessage(kind, messageId); +} + +/** The message update of each message chunk. */ +const MESSAGE_OF_CHUNK = new Map([ + ["user_message_chunk", "user_message"], + ["agent_message_chunk", "agent_message"], + ["agent_thought_chunk", "agent_thought"], +]); + +function emptyMessage( + kind: "user_message" | "agent_message" | "agent_thought", + messageId: string, +): v2.SessionUpdate { + const message = { messageId, content: [] }; + switch (kind) { + case "user_message": + return { ...message, sessionUpdate: kind }; + case "agent_message": + return { ...message, sessionUpdate: kind }; + default: + return { ...message, sessionUpdate: kind }; + } +} diff --git a/src/workflow-progress.ts b/src/workflow-progress.ts index 6d276d72..a3262c90 100644 --- a/src/workflow-progress.ts +++ b/src/workflow-progress.ts @@ -9,6 +9,9 @@ * some progress reports and none on others. Entries that don't match it are * skipped, so a change in the CLI drops the list instead of the card. */ +import type { ToolCallContent } from "@agentclientprotocol/sdk/unstable"; +import type { TaskProgressFields } from "./async-tasks.js"; +import { textContent } from "./tool-calls/content.js"; export type WorkflowPhase = { index: number; title: string }; @@ -27,6 +30,44 @@ export type WorkflowAgent = { export type WorkflowSnapshot = { phases: WorkflowPhase[]; agents: WorkflowAgent[] }; +/** What a workflow card shows: the task's progress fields and its latest phase and agent list. */ +export type WorkflowCardProgress = TaskProgressFields & { workflow?: WorkflowSnapshot }; + +/** + * The latest workflow activity, as the title and the one replaceable body of + * the workflow's tool call. Undefined when no progress came. For example, the + * title `Workflow: Probe: probe` and the body: + * + * ``` + * Run a probe + * + * Last activity: probe + * + * 4,003 tokens · 2 tool calls · 1s + * ``` + */ +export function workflowCard( + progress: WorkflowCardProgress | undefined, +): { title: string; content: ToolCallContent[] } | undefined { + if (!progress || Object.keys(progress).length === 0) return undefined; + const activity = progress.description ?? progress.summary; + const usage = progress.usage; + const text = [ + progress.summary ?? progress.description, + progress.lastToolName ? `Last activity: ${progress.lastToolName}` : undefined, + usage + ? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s` + : undefined, + progress.workflow ? workflowSnapshotText(progress.workflow) : undefined, + ] + .filter((part) => part !== undefined) + .join("\n\n"); + return { + title: activity ? `Workflow: ${activity}` : "Workflow", + content: [textContent(text)], + }; +} + /** Reads a `workflow_progress` value. Undefined when it holds no phase or agent. */ export function workflowSnapshot(value: unknown): WorkflowSnapshot | undefined { if (!Array.isArray(value)) return undefined; diff --git a/vitest.config.ts b/vitest.config.ts index cafd3045..20b9f19e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,19 +4,26 @@ import { defineConfig } from "vitest/config"; const usesRealCli = Boolean(process.env.RUN_INTEGRATION_TESTS) || process.env.RUN_LIVE_CAPABILITY_AUDIT === "true"; +// The agent runs `claude auth status` in the background on every +// initialize and prompt. Pointing it at a CLI that exits at once keeps +// tests off the machine's real CLI and Claude config, and stops a run +// from outliving its test (it wrote into temp config dirs being deleted). +const env: Record = usesRealCli ? {} : { CLAUDE_CODE_EXECUTABLE: "/usr/bin/false" }; + export default defineConfig({ + resolve: { + alias: { + "@": "/src", + }, + }, test: { watch: false, globals: true, environment: "node", - // The agent runs `claude auth status` in the background on every - // initialize and prompt. Pointing it at a CLI that exits at once keeps - // tests off the machine's real CLI and Claude config, and stops a run - // from outliving its test (it wrote into temp config dirs being deleted). - env: usesRealCli ? {} : { CLAUDE_CODE_EXECUTABLE: "/usr/bin/false" }, // Applies to real-CLI runs too: the gateway listing runs in the test process, // and tests point `providers/set` at fake URLs like gateway.example.com. setupFiles: ["src/tests/setup-no-network.ts"], + env, include: [ "src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}", // Release tooling lives in scripts/ rather than src/ so it stays outside tsc's @@ -28,9 +35,4 @@ export default defineConfig({ reporter: ["text", "json", "html"], }, }, - resolve: { - alias: { - "@": "/src", - }, - }, });