From 190a00f68f773315e93630464397944e645616ff Mon Sep 17 00:00:00 2001 From: nikita-ashihmin Date: Fri, 2 Oct 2026 17:37:59 +0400 Subject: [PATCH] feat: answer /mcp in chat with the MCP server status and a reconnect (#1218) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary In an ACP client, `/mcp` showed the CLI text "… Use `/mcp` in the terminal for details." The user of an ACP client has no terminal. In SDK mode, Claude Code also refuses `/mcp reconnect|enable|disable` with "Reconnect, enable, and disable aren't available in this session." and changes nothing. - `/mcp` and `/mcp reconnect|enable|disable [|all]` still run as a normal Claude Code turn. The adapter replaces the text of the local command, through the same hook that `/usage` uses. Prompt order, cancel, and the provider update stay with the turn. - For an action, the adapter runs it in that turn through the SDK control API (`reconnectMcpServer`, `toggleMcpServer`). It shows one result line for each server, then the status list. - The status list comes from `query.mcpServerStatus()`. It has a summary line and groups by status: failed, needs authentication, connecting, connected, and disabled on one line. A failed server shows a short cleaned error. A list is used and not a table, because a table breaks words in narrow columns. - Useful CLI sentences stay, for example "There's no MCP server named …". Sentences that point to the terminal are removed. - After an explicit reconnect, a server from the ACP session that needs authentication starts the existing MCP OAuth flow, when the client supports URL elicitation. - `/mcp` is advertised with the input hint `[reconnect|enable|disable [|all]]` and is hidden on history replay. ## Other commits in this branch This branch also carries commits of other work: skill command metadata for AIR, a faster `session/load` replay, and the resumed permission mode. They can move to separate PRs if preferred. ## Tests - `src/tests/mcp-command.test.ts` (61 tests). The fake CLI answers with the real SDK-mode texts and message shapes (synthetic assistant + result) and does not change any status. - A live run through the adapter with a broken and a working stdio server: `/mcp`, `/mcp disable good`, `/mcp enable good`, `/mcp reconnect broken`, `/mcp reconnect nosuch`. - `compare.ts` allows one new difference against the origin/main baseline: the `mcp` command in `available_commands_update`. The baseline files are unchanged. - `npm run check` and `npm run test:run` pass. --- README.md | 1 + docs/air-extensions.md | 40 +- src/acp-agent.ts | 452 +++++++--- src/air-extension.ts | 2 + src/mcp-command.ts | 388 ++++++++ src/resumed-session.ts | 133 ++- src/session-mode.ts | 32 + src/tests/acp-agent-settings.test.ts | 136 +++ src/tests/acp-agent.test.ts | 136 ++- src/tests/acp-scenarios-compare.test.ts | 35 + .../__snapshots__/air/ask-user-question.jsonl | 2 +- .../air/async-task-ends-before-tool-id.jsonl | 2 +- .../air/async-task-held-until-tool-id.jsonl | 2 +- .../air/async-task-released-at-turn-end.jsonl | 2 +- .../__snapshots__/air/bash-background.jsonl | 2 +- .../__snapshots__/air/bash-error.jsonl | 2 +- .../__snapshots__/air/bash-foreground.jsonl | 2 +- .../air/compaction-failed-legacy.jsonl | 2 +- .../__snapshots__/air/compaction-legacy.jsonl | 2 +- .../__snapshots__/air/compaction-update.jsonl | 2 +- .../__snapshots__/air/edit-rejected.jsonl | 2 +- .../air/edit-with-permission.jsonl | 2 +- .../__snapshots__/air/exit-plan-approve.jsonl | 2 +- .../__snapshots__/air/exit-plan-reject.jsonl | 2 +- .../__snapshots__/air/goal.jsonl | 2 +- .../__snapshots__/air/grep-and-glob.jsonl | 2 +- .../__snapshots__/air/mcp-tool.jsonl | 2 +- .../__snapshots__/air/memory-recall.jsonl | 2 +- .../air/network-permission.jsonl | 2 +- .../__snapshots__/air/notebook-edit.jsonl | 2 +- .../__snapshots__/air/permission-denied.jsonl | 2 +- .../air/rate-limit-and-origin.jsonl | 2 +- .../__snapshots__/air/read.jsonl | 2 +- .../air/session-load-replay.jsonl | 2 +- .../__snapshots__/air/session-setup.jsonl | 2 +- .../__snapshots__/air/skill.jsonl | 2 +- .../air/subagent-agent-async.jsonl | 2 +- .../air/subagent-late-child-update.jsonl | 2 +- .../air/subagent-native-sessions.jsonl | 2 +- .../__snapshots__/air/subagent-nested.jsonl | 2 +- .../air/subagent-task-legacy.jsonl | 2 +- .../air/subagent-transcript-extension.jsonl | 2 +- .../air/task-create-update.jsonl | 2 +- .../air/task-output-and-stop.jsonl | 2 +- .../__snapshots__/air/text-and-thinking.jsonl | 2 +- .../__snapshots__/air/todo-write.jsonl | 2 +- .../__snapshots__/air/tool-progress.jsonl | 2 +- .../air/web-fetch-and-search.jsonl | 2 +- .../__snapshots__/air/write-existing.jsonl | 2 +- .../__snapshots__/air/write-new.jsonl | 2 +- src/tests/acp-scenarios/compare.ts | 25 + src/tests/acp-v2.test.ts | 6 + src/tests/create-session-options.test.ts | 174 +++- src/tests/mcp-command.test.ts | 846 ++++++++++++++++++ src/tests/resumed-session.test.ts | 97 +- src/tests/session-mode.test.ts | 53 +- src/usage-markdown.ts | 3 +- src/utils.ts | 31 + 58 files changed, 2454 insertions(+), 216 deletions(-) create mode 100644 src/mcp-command.ts create mode 100644 src/tests/mcp-command.test.ts diff --git a/README.md b/README.md index 704c7f6d..a2021abd 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ This tool implements an ACP agent by using the official [Claude Agent SDK](https - Interactive (and background) terminals - Custom [Slash commands](https://docs.anthropic.com/en/docs/claude-code/slash-commands) - Client MCP servers +- `/mcp` in the chat: the MCP server status as a list. The adapter runs `/mcp reconnect`, `/mcp enable`, and `/mcp disable` through the SDK control API, because Claude Code refuses them in SDK mode. A reconnect of an ACP server that needs authentication starts MCP OAuth through URL elicitation - Session-scoped long-running goals for AIR through the [goal extension](docs/air-extensions.md#goal) under `_meta.jetbrains.air.goal` - Structured errors, recovery, and warnings through the opt-in [session failure extension](docs/air-extensions.md#session-failure) - Concrete model and effort defaults through the opt-in [recommended config value extension](docs/air-extensions.md#recommended-config-values) diff --git a/docs/air-extensions.md b/docs/air-extensions.md index 387aad85..c65d33d4 100644 --- a/docs/air-extensions.md +++ b/docs/air-extensions.md @@ -164,25 +164,27 @@ Every payload goes into `_meta.jetbrains.air`, next to `version: 1`. The adapter merges a payload into an existing `_meta` and keeps the other namespaces. The adapter sends these keys only to an AIR client. "AIR" in the Gate column means an AIR client without a further capability. -| Key | Message and field path | Shape | Gate | -| ------------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | ---------------------------------------------- | -| `capabilities` | `initialize` response `_meta.jetbrains.air` | string array | AIR | -| `goal` | `initialize` response `_meta.jetbrains.air` | `{version: 1, controlMethod, actions}` | AIR | -| `goal` | `session_info_update._meta.jetbrains.air` | goal snapshot or `null` | AIR | -| `diffPatch` | tool call and permission request `content[]` of type `diff`, `_meta.jetbrains.air` | `{version: 1, format: "git_patch", text}` | `diffPatch` | -| `permission` | `session/request_permission` request `_meta.jetbrains.air` | `{version: 1, title, description?, defaultToNo?}` | AIR | -| `recommendedValue` | `model` and effort config options, `_meta.jetbrains.air` | option value string | `recommendedValue` | -| `asyncTasks` | Bash `tool_call_update._meta.jetbrains.air` | `{backgrounded: true}` | `asyncTasks` | -| `agentFileChangeReportRequest` | `session/prompt` request `_meta.jetbrains.air` (client to agent) | `{version: 1, requestId}` | `agentFileChangeReport` | -| `agentFileChangeReport` | `session_info_update._meta.jetbrains.air` | report object | `agentFileChangeReport` | -| `sessionFailure` | `session_info_update._meta.jetbrains.air` or `PromptResponse._meta.jetbrains.air` | failure record | `sessionFailure` | -| `commandTitle` | Bash and PowerShell tool call `_meta.jetbrains.air` | the `description` input string | AIR | -| `subagent` | Agent and Task tool call `_meta.jetbrains.air` | `true` | AIR | -| `skill` | Skill tool call `_meta.jetbrains.air` | `{name, path?}` | AIR | -| `contextCompaction` | compaction tool call or `compaction_update`, `_meta.jetbrains.air` | `{version: 1, trigger?, preTokens?, postTokens?, durationMs?, error?}` | AIR | -| `customAnswer` | elicitation schema property `_meta.jetbrains.air` | `{questionId, isCustomAnswer: true}` | AIR, when the client supports form elicitation | -| `kind` | session mode `_meta.jetbrains.air` and mode config option value `_meta.jetbrains.air` | `standard`, `plan`, `auto_review`, or `full_access` | AIR | -| `fork` | `session/fork` request `_meta.jetbrains.air` (client to agent) | `{version: 1, messageId, messageFingerprint?, messageOccurrence?}` | none | +| Key | Message and field path | Shape | Gate | +| ------------------------------ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| `capabilities` | `initialize` response `_meta.jetbrains.air` | string array | AIR | +| `goal` | `initialize` response `_meta.jetbrains.air` | `{version: 1, controlMethod, actions}` | AIR | +| `goal` | `session_info_update._meta.jetbrains.air` | goal snapshot or `null` | AIR | +| `diffPatch` | tool call and permission request `content[]` of type `diff`, `_meta.jetbrains.air` | `{version: 1, format: "git_patch", text}` | `diffPatch` | +| `permission` | `session/request_permission` request `_meta.jetbrains.air` | `{version: 1, title, description?, defaultToNo?}` | AIR | +| `recommendedValue` | `model` and effort config options, `_meta.jetbrains.air` | option value string | `recommendedValue` | +| `asyncTasks` | Bash `tool_call_update._meta.jetbrains.air` | `{backgrounded: true}` | `asyncTasks` | +| `agentFileChangeReportRequest` | `session/prompt` request `_meta.jetbrains.air` (client to agent) | `{version: 1, requestId}` | `agentFileChangeReport` | +| `agentFileChangeReport` | `session_info_update._meta.jetbrains.air` | report object | `agentFileChangeReport` | +| `sessionFailure` | `session_info_update._meta.jetbrains.air` or `PromptResponse._meta.jetbrains.air` | failure record | `sessionFailure` | +| `commandTitle` | Bash and PowerShell tool call `_meta.jetbrains.air` | the `description` input string | AIR | +| `subagent` | Agent and Task tool call `_meta.jetbrains.air` | `true` | AIR | +| `skill` | Skill tool call `_meta.jetbrains.air` | `{name, path?}` | AIR | +| `contextCompaction` | compaction tool call or `compaction_update`, `_meta.jetbrains.air` | `{version: 1, trigger?, preTokens?, postTokens?, durationMs?, error?}` | AIR | +| `customAnswer` | elicitation schema property `_meta.jetbrains.air` | `{questionId, isCustomAnswer: true}` | AIR, when the client supports form elicitation | +| `kind` | session mode `_meta.jetbrains.air` and mode config option value `_meta.jetbrains.air` | `standard`, `plan`, `auto_review`, or `full_access` | AIR | +| `kind` | available command `_meta.jetbrains.air` of a skill | `skill` | AIR, when the skill's `SKILL.md` is found; not for a built-in command or an MCP prompt | +| `skillPath` | available command `_meta.jetbrains.air` of a skill | absolute path of the skill's `SKILL.md` | AIR, together with `kind: "skill"` | +| `fork` | `session/fork` request `_meta.jetbrains.air` (client to agent) | `{version: 1, messageId, messageFingerprint?, messageOccurrence?}` | none | ## JetBrains shared keys diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 7f76ecb7..35a45c27 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -122,7 +122,9 @@ import { AIR_DIFF_PATCH_CAPABILITY, AIR_PLAN_FILE_CAPABILITY, AIR_GOAL_KEY, + AIR_KIND_KEY, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, + AIR_SKILL_PATH_KEY, clientSupportsAirCapability, withAirMeta, } from "./air-extension.js"; @@ -169,12 +171,12 @@ import { import { forkSession } from "./fork-session.js"; import { subagentHistory } from "./subagent-history.js"; import { - readResumedModel, readResumedSession, + readResumedTail, type ResumedSessionSnapshot, } from "./resumed-session.js"; import { SessionTiming } from "./session-timing.js"; -import { ALLOW_BYPASS, resolvePermissionMode } from "./permissions/modes.js"; +import { ALLOW_BYPASS } from "./permissions/modes.js"; import { normalizeDurablePermissionChangeSet } from "./permissions/normalization.js"; import { buildClaudePermissionOptions } from "./permissions/options.js"; import { buildClaudePermissionPresentation } from "./permissions/presentation.js"; @@ -253,7 +255,14 @@ 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"; import { AcpToolCallRenderer, type ToolUpdateMeta } from "./tool-calls/renderer.js"; -import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js"; +import { resolveSkillPath } from "./tool-calls/reporters/interaction.js"; +import { + nodeToWebReadable, + nodeToWebWritable, + Pushable, + raceTimeoutAndAbort, + unreachable, +} from "./utils.js"; import { acceptedPlanToolResult, ExitPlanCoordinator, @@ -262,7 +271,12 @@ import { } from "./exit-plan.js"; import { parseToolResultMeta } from "./tool-result-meta.js"; import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "./usage-markdown.js"; -import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js"; +import { MCP_AVAILABLE_COMMAND, parseMcpCommand, runMcpCommand } from "./mcp-command.js"; +import { + MODE_CONFIG_ID, + resolveInitialPermissionMode, + SessionModeManager, +} from "./session-mode.js"; import { applyAvailableModelsAllowlist, buildModelConfigOption, @@ -376,6 +390,20 @@ function settleLiveBackgroundTask(session: Session, taskId: string): void { } } +/** The structured replacement for the text of a local command that Claude + * Code runs itself, such as `/usage` or `/mcp`. */ +type LocalCommandMarkdown = { + /** True when `produce` starts at turn activation. Otherwise it starts when + * the command output arrives, so Claude Code has finished the command. */ + startsAtActivation: boolean; + /** The replacement, or null to keep the original text of Claude Code. + * `originalOutput` is that text. It is empty when `produce` starts at + * turn activation. */ + produce(query: Query, signal: AbortSignal, originalOutput: string): Promise; + /** Runs once when the turn ends after it delivered the replacement. */ + afterDelivery?(): void; +}; + /** Best-effort structured presentation for a local `/usage` turn. The command * itself always runs through Claude Code; null tells the consumer to forward * its original output unchanged. The timeout prevents an unstable control @@ -386,30 +414,21 @@ async function structuredUsageMarkdown( logger: Logger, ): Promise { if (signal.aborted) return null; - let timeout: ReturnType | undefined; - let onAbort: (() => void) | undefined; try { - const response = await Promise.race([ + const outcome = await raceTimeoutAndAbort( // Keeping the deliberately unstable method name visible makes an SDK // upgrade fail at compile time if Anthropic removes or renames it. query.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET(), - new Promise((resolve) => { - timeout = setTimeout(() => resolve(null), STRUCTURED_USAGE_TIMEOUT_MS); - timeout.unref?.(); - }), - new Promise((resolve) => { - onAbort = () => resolve(null); - if (signal.aborted) onAbort(); - else signal.addEventListener("abort", onAbort, { once: true }); - }), - ]); - if (response === null) { - if (!signal.aborted) { + STRUCTURED_USAGE_TIMEOUT_MS, + signal, + ); + if (outcome.type !== "done") { + if (outcome.type === "timeout") { logger.error("Structured /usage timed out; preserving Claude Code output"); } return null; } - const usage = parseUsageResponse(response); + const usage = parseUsageResponse(outcome.value); if (!usage) { logger.error( "Structured /usage returned an incompatible response; preserving Claude Code output", @@ -420,9 +439,6 @@ async function structuredUsageMarkdown( } catch (error) { logger.error(`Structured /usage failed; preserving Claude Code output: ${error}`); return null; - } finally { - if (timeout) clearTimeout(timeout); - if (onAbort) signal.removeEventListener("abort", onAbort); } } @@ -620,17 +636,17 @@ type Turn = { * queue head, not `activeTurn`: a UserPromptSubmit block arrives before * any echo, so the turn is only promoted when its result lands. */ noticeTexts?: string[]; - /** Structured presentation for an exact /usage command. The command still - * runs through the normal SDK turn so ordering, cancellation, persistence, - * and replay remain unchanged. Null means the experimental API failed and - * every output path must preserve Claude Code's original text. */ - isUsageCommand?: boolean; - usageMarkdown?: Promise; - usageMarkdownAbort?: AbortController; + /** Structured presentation for an exact /usage or /mcp command. The command + * still runs through the normal SDK turn so ordering, cancellation, + * persistence, and replay remain unchanged. A null result means the request + * failed and every output path must preserve Claude Code's original text. */ + localCommand?: LocalCommandMarkdown; + localCommandMarkdown?: Promise; + localCommandAbort?: AbortController; /** The SDK can expose a local command through more than one message shape; * publish the structured replacement at most once. */ - usageMarkdownDelivered?: boolean; - usageOriginalOutput?: string; + localCommandDelivered?: boolean; + localCommandOriginalOutput?: string; /** Optional native checkpoint preview requested by the ACP client for this * turn. The state is turn-owned so a late control response can never be * rebound to a newer prompt. */ @@ -775,6 +791,21 @@ type Turn = { completion?: Promise; }; +/** The part of a {@link Session} that a history replay reads and writes. + * A session/load creates it before the CLI starts, so that the replay runs + * while the CLI starts. The session record then takes the same objects. */ +type ReplayState = Pick< + Session, + "cwd" | "taskState" | "forwardSubagentText" | "messageIdToUuid" | "sessionFailureState" +>; + +/** A replay that runs before its session record exists. */ +type PendingReplay = { + state: ReplayState; + /** Set when the session creation fails. The replay then sends no more updates. */ + stopped: boolean; +}; + export type Session = { query: Query; input: Pushable; @@ -855,6 +886,10 @@ export type Session = { * terminal (e.g. /doctor, /color). ACP clients aren't that terminal, so * these are filtered out of `available_commands_update` payloads. */ terminalSlashCommands?: string[]; + /** The resolved SKILL.md paths of the skill commands, keyed by the cwd and + * the command name. A `commands_changed` clears it, because the skill + * files can have changed then. */ + skillPaths?: Map; /** Serialized `system`/init `plugin_errors` last logged, so the per-turn * init re-emit logs a plugin load failure once, not every turn. */ loggedPluginErrors?: string; @@ -1800,6 +1835,7 @@ const REPLAY_HIDDEN_COMMANDS = new Set([ ...LOCAL_ONLY_COMMANDS, "/compact", "/model", + "/mcp", "/status", "/usage", ]); @@ -2236,12 +2272,23 @@ function startMcpAuthentication( sessionId: string, mcpServers: NewSessionRequest["mcpServers"], ): void { - if (!host.clientCapabilities?.elicitation?.url || mcpServers.length === 0) return; + if (mcpServers.length === 0) return; + startMcpServerAuthentication(host, sessionId, new Set(mcpServers.map((server) => server.name))); +} + +/** 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. */ +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 requestedServers = new Set(mcpServers.map((server) => server.name)); const authentication = authenticateMcpServers(host, sessionId, session.query, requestedServers) .catch((error) => { if (!session.abortController.signal.aborted) { @@ -2678,6 +2725,64 @@ export class ClaudeAcpAgent { return response; } + /** + * Start the CLI of a loaded session, and replay the history while the CLI + * starts. The CLI start takes most of the load time, and the replay needs + * only the transcript and the {@link ReplayState}. An invalid `cwd` fails + * before the first update. A failed CLI start stops the replay. + */ + private async createSessionWhileReplaying( + params: LoadSessionRequest, + timing: SessionTiming, + ): Promise { + await this.validateCwd(params.cwd); + const pending: PendingReplay = { + state: { + cwd: params.cwd, + taskState: new Map(), + forwardSubagentText: this.forwardsSubagentText(params._meta), + messageIdToUuid: new Map(), + sessionFailureState: createSessionFailureState(), + }, + stopped: false, + }; + const transcript = readResumedSession(params.sessionId, this.logger); + const replay = transcript + .then(({ messages }) => this.replaySessionHistory(params.sessionId, messages, pending)) + .then( + () => ({ ok: true as const }), + (error: unknown) => ({ ok: false as const, error }), + ); + let response: NewSessionResponse; + try { + response = await this.createSession( + { + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + additionalDirectories: params.additionalDirectories, + _meta: params._meta, + }, + { + resume: params.sessionId, + resumedModelHint: transcript.then(({ model }) => model), + replayState: pending.state, + }, + ); + } catch (error) { + pending.stopped = true; + await replay; + throw error; + } + timing.phase("session-ready"); + const replayed = await replay; + if (!replayed.ok) throw replayed.error; + return { + sessionId: response.sessionId, + modes: response.modes, + configOptions: response.configOptions, + }; + } + async unstable_forkSession(params: ForkSessionRequest): Promise { if (this.providerUpdate) await this.providerUpdate; return forkSession(params, { @@ -2702,11 +2807,15 @@ export class ClaudeAcpAgent { async loadSession(params: LoadSessionRequest): Promise { const timing = new SessionTiming(this.logger, "load", params.sessionId); if (this.providerUpdate) await this.providerUpdate; - const resumedSession = await readResumedSession(params.sessionId, this.logger); - const result = await this.getOrCreateSession(params, resumedSession); - timing.phase("session-ready"); - - await this.replaySessionHistory(params.sessionId, resumedSession.messages); + let result: NewSessionResponse; + if (this.sessions[params.sessionId]) { + const resumedSession = await readResumedSession(params.sessionId, this.logger); + result = await this.getOrCreateSession(params, resumedSession); + timing.phase("session-ready"); + await this.replaySessionHistory(params.sessionId, resumedSession.messages); + } else { + result = await this.createSessionWhileReplaying(params, timing); + } timing.phase("replay"); // Send available commands after replay so it doesn't interleave with history @@ -3143,6 +3252,49 @@ export class ClaudeAcpAgent { } } + /** The structured replacement for a prompt that is exactly `/usage` or a + * `/mcp` command. Claude Code runs the command, and the replacement takes + * the place of its text. Claude Code refuses a `/mcp` reconnect, enable, + * or disable in SDK mode. The `/mcp` producer runs them through the SDK + * control API when the text arrives, so the action stays in the turn. */ + private localCommandMarkdown(params: PromptRequest): LocalCommandMarkdown | undefined { + const text = + params.prompt.length === 1 && params.prompt[0]?.type === "text" + ? params.prompt[0].text + : undefined; + if (text === undefined) return undefined; + if (isUsageCommandText(text)) { + return { + startsAtActivation: true, + produce: (query, signal) => structuredUsageMarkdown(query, signal, this.logger), + }; + } + const command = parseMcpCommand(text); + if (!command) return undefined; + let reconnected: string[] = []; + return { + startsAtActivation: false, + produce: async (query, signal, originalOutput) => { + const outcome = await runMcpCommand(query, command, originalOutput, signal, (message) => + this.logger.error(message), + ); + reconnected = outcome.reconnected; + return outcome.markdown; + }, + // A reconnect starts OAuth for the ACP servers that it tried, as the + // session start does. A plain `/mcp` and a cancelled turn start none. + afterDelivery: () => { + const session = this.sessions[params.sessionId]; + if (!session || session.cancelled) return; + const acpServers = new Set( + (session.creationParams?.mcpServers ?? []).map((server) => server.name), + ); + const servers = new Set(reconnected.filter((server) => acpServers.has(server))); + if (servers.size > 0) startMcpServerAuthentication(this, params.sessionId, servers); + }, + }; + } + /** * Serves an ACP v1 `session/prompt`, which answers when the turn ends. The * outcome of a turn is field for field a v1 prompt response. @@ -3215,11 +3367,8 @@ export class ClaudeAcpAgent { const fileChangeReport = session.fileChangeReporter?.request(params._meta); - const isUsageCommand = - params.prompt.length === 1 && - params.prompt[0]?.type === "text" && - isUsageCommandText(params.prompt[0].text); - const usageMarkdownAbort = isUsageCommand ? new AbortController() : undefined; + const localCommand = this.localCommandMarkdown(params); + const localCommandAbort = localCommand ? new AbortController() : undefined; session.titles.onPrompt(params.prompt); @@ -3232,8 +3381,8 @@ export class ClaudeAcpAgent { promptUuid, events, isLocalOnlyCommand, - ...(isUsageCommand ? { isUsageCommand: true } : {}), - ...(usageMarkdownAbort ? { usageMarkdownAbort } : {}), + ...(localCommand ? { localCommand } : {}), + ...(localCommandAbort ? { localCommandAbort } : {}), ...(fileChangeReport ? { fileChangeReport } : {}), settled: false, completion: new Promise((resolve) => { @@ -3377,7 +3526,10 @@ export class ClaudeAcpAgent { try { // `resume` names the Claude session, which shares the ACP session id, so // the new query continues the same conversation under the same id. - await this.createSession(creationParams, { resume: sessionId }); + await this.createSession(creationParams, { + resume: sessionId, + permissionMode: session.modes.currentModeId as PermissionMode, + }); } catch (error) { if (error instanceof RequestError && error.code === RequestError.resourceNotFound().code) { // The CLI never wrote this conversation, because the very first turn @@ -3388,7 +3540,10 @@ export class ClaudeAcpAgent { `session ${sessionId} was never persisted; starting a fresh query under the same id`, ); try { - await this.createSession(creationParams, { reuseSessionId: sessionId }); + await this.createSession(creationParams, { + reuseSessionId: sessionId, + permissionMode: session.modes.currentModeId as PermissionMode, + }); return; } catch (fallbackError) { if (!this.sessions[sessionId]) { @@ -3948,14 +4103,17 @@ export class ClaudeAcpAgent { supportsAirSessionFailures(this.clientCapabilities) ? undefined : rawDetail, ); - const ensureUsageMarkdown = (turn: Turn): Promise | undefined => { - if (!turn.isUsageCommand || !turn.usageMarkdownAbort) return undefined; - turn.usageMarkdown ??= structuredUsageMarkdown( + const ensureLocalCommandMarkdown = ( + turn: Turn, + originalOutput = "", + ): Promise | undefined => { + if (!turn.localCommand || !turn.localCommandAbort) return undefined; + turn.localCommandMarkdown ??= turn.localCommand.produce( session.query, - turn.usageMarkdownAbort.signal, - this.logger, + turn.localCommandAbort.signal, + originalOutput, ); - return turn.usageMarkdown; + return turn.localCommandMarkdown; }; const resetTurnScratch = () => { @@ -4000,7 +4158,7 @@ export class ClaudeAcpAgent { session.activeTurn = turn; session.cancelled = false; compaction.resume(); - ensureUsageMarkdown(turn); + if (turn.localCommand?.startsAtActivation) ensureLocalCommandMarkdown(turn); session.pendingOrphanResults = 0; session.orphanCommands?.clear(); // Two-phase sweep of registry entries the level signal ended (see @@ -4202,26 +4360,27 @@ export class ClaudeAcpAgent { const firstUnsettledQueuedTurn = () => (session.turnQueue ?? []).find((t) => !t.settled); /** Claim the structured replacement for the turn currently producing a - * local-command output. Undefined means this is not a structured usage - * turn (or its request failed), null means another SDK message shape - * already delivered it, and string is the one replacement to publish. */ - const takeUsageMarkdown = async ( + * local-command output. Undefined means this is not a structured + * local-command turn (or its request failed), null means another SDK + * message shape already delivered it, and string is the one replacement + * to publish. */ + const takeLocalCommandMarkdown = async ( originalOutput: string, ): Promise => { const turn = session.activeTurn ?? firstUnsettledQueuedTurn(); if (!turn) return undefined; - const usageMarkdown = ensureUsageMarkdown(turn); - if (!usageMarkdown) return undefined; - const markdown = await usageMarkdown; + const pending = ensureLocalCommandMarkdown(turn, originalOutput); + if (!pending) return undefined; + const markdown = await pending; if (markdown === null) return undefined; - if (turn.usageMarkdownDelivered) { + if (turn.localCommandDelivered) { // Different SDK message shapes can mirror the same local-command // output. Suppress an exact mirror, but let a later, distinct frame // (for example an interruption diagnostic) follow the normal path. - return turn.usageOriginalOutput === originalOutput ? null : undefined; + return turn.localCommandOriginalOutput === originalOutput ? null : undefined; } - turn.usageMarkdownDelivered = true; - turn.usageOriginalOutput = originalOutput; + turn.localCommandDelivered = true; + turn.localCommandOriginalOutput = originalOutput; return markdown; }; @@ -4394,7 +4553,8 @@ export class ClaudeAcpAgent { // Captured before the settled flip below (isHeldOpen tests !settled). const wasHeld = isHeldOpen(turn); turn.settled = true; - turn.usageMarkdownAbort?.abort(); + turn.localCommandAbort?.abort(); + if (turn.localCommandDelivered) turn.localCommand?.afterDelivery?.(); disarmForceCancel(session); session.turnQueue = (session.turnQueue ?? []).filter((t) => t !== turn); session.activeTurn = null; @@ -4900,15 +5060,15 @@ export class ClaudeAcpAgent { if (compaction.consumeDuplicateErrorOutput(message.content)) { break; } - const usageTurn = session.activeTurn ?? firstUnsettledQueuedTurn(); - const usageMarkdown = await takeUsageMarkdown(message.content); - if (usageTurn?.isUsageCommand && session.cancelled) break; - if (usageMarkdown === null) break; + const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn(); + const commandMarkdown = await takeLocalCommandMarkdown(message.content); + if (commandTurn?.localCommand && session.cancelled) break; + if (commandMarkdown === null) break; await sendUpdate({ sessionId: params.sessionId, update: { sessionUpdate: "agent_message_chunk", - content: { type: "text", text: usageMarkdown ?? message.content }, + content: { type: "text", text: commandMarkdown ?? message.content }, }, }); break; @@ -5077,6 +5237,7 @@ export class ClaudeAcpAgent { // list with this payload. Forward message.commands directly — // it's authoritative, and re-querying supportedCommands() // would just return the same list with an extra round-trip. + session.skillPaths = new Map(); await sendUpdate({ sessionId: params.sessionId, update: { @@ -5084,6 +5245,8 @@ export class ClaudeAcpAgent { availableCommands: getAvailableSlashCommands( message.commands, session.terminalSlashCommands, + this.toolCallCapabilities.air.client ? session.cwd : undefined, + session.skillPaths, ), }, }); @@ -6041,10 +6204,12 @@ export class ClaudeAcpAgent { normalizeNoticeText(message.result), )); if (shouldForwardResult) { - const usageMarkdown = await takeUsageMarkdown(message.result); - if (usageMarkdown === null) break; + const commandTurn = session.activeTurn ?? firstUnsettledQueuedTurn(); + const commandMarkdown = await takeLocalCommandMarkdown(message.result); + if (commandTurn?.localCommand && session.cancelled) break; + if (commandMarkdown === null) break; for (const notification of toAcpNotifications( - usageMarkdown ?? message.result, + commandMarkdown ?? message.result, "assistant", params.sessionId, session.toolUseCache, @@ -6494,20 +6659,20 @@ export class ClaudeAcpAgent { // Depending on the Claude Code build, a local command can arrive // as the dedicated system message above or as a synthetic // assistant message. Replace only the output owned by the exact - // /usage turn; no content signatures or text parsing are involved. + // /usage or /mcp turn; no content signatures or text parsing are involved. if ( message.type === "assistant" && message.parent_tool_use_id === null && message.message.model === "" ) { - const usageMarkdown = await takeUsageMarkdown( + const commandMarkdown = await takeLocalCommandMarkdown( assistantMessageText(message.message) ?? "", ); if (session.cancelled) break; - if (usageMarkdown !== undefined) { - if (usageMarkdown !== null) { + if (commandMarkdown !== undefined) { + if (commandMarkdown !== null) { for (const notification of toAcpNotifications( - usageMarkdown, + commandMarkdown, "assistant", params.sessionId, session.toolUseCache, @@ -6893,7 +7058,7 @@ export class ClaudeAcpAgent { return; } session.cancelled = true; - for (const turn of session.turnQueue ?? []) turn.usageMarkdownAbort?.abort(); + for (const turn of session.turnQueue ?? []) turn.localCommandAbort?.abort(); session.pendingExitPlanModeInterruption = undefined; session.pendingExitPlanContextReset = undefined; // The stream already ended (see closeQueryStream): every in-flight turn was @@ -7388,6 +7553,7 @@ export class ClaudeAcpAgent { private async replaySessionHistory( sessionId: string, resumedMessages?: SessionMessage[], + pending?: PendingReplay, ): Promise { const replayStartedAt = performance.now(); const toolUseCache: ToolUseCache = {}; @@ -7396,7 +7562,9 @@ export class ClaudeAcpAgent { this.logger.log( `[session/replay] sessionId=${sessionId} phase=read durationMs=${Math.round(historyLoadedAt - replayStartedAt)} messages=${messages.length}`, ); - const session = this.sessions[sessionId]; + // A pending replay has no session record yet. It uses the state that the record takes later. + const replayState = (): ReplayState | undefined => pending?.state ?? this.sessions[sessionId]; + const session = replayState(); // A replay rebuilds the client view, so its plan goes out again. if (session?.taskState) forgetPublishedTaskPlan(session.taskState); const forwardSubagentText = @@ -7409,7 +7577,11 @@ export class ClaudeAcpAgent { sessionId, state: session.sessionFailureState, capabilities: this.clientCapabilities, - isCurrent: () => this.sessions[sessionId] === session, + isCurrent: () => { + const live = this.sessions[sessionId]; + if (!live) return pending !== undefined && !pending.stopped; + return live.sessionFailureState === session.sessionFailureState; + }, sendUpdate: (notification) => this.client.sessionUpdate(notification), logger: this.logger, }) @@ -7601,7 +7773,7 @@ export class ClaudeAcpAgent { }; const replayMessage = async (message: SessionMessage): Promise => { - if (isReplayHiddenMetaMessage(message)) { + if (pending?.stopped || isReplayHiddenMetaMessage(message)) { return; } if ( @@ -7617,7 +7789,7 @@ export class ClaudeAcpAgent { // a client-supplied id without an extra getSessionMessages read. Not read // yet (see Session.messageIdToUuid). const replayMessageId = messageIdForGrouping(message); - const replaySession = this.sessions[sessionId]; + const replaySession = replayState(); if (replaySession && replayMessageId && message.uuid) { replaySession.messageIdToUuid.set(replayMessageId, message.uuid); } @@ -7736,8 +7908,8 @@ export class ClaudeAcpAgent { registerHooks: false, replay: true, clientCapabilities: this.clientCapabilities, - cwd: this.sessions[sessionId]?.cwd, - taskState: this.sessions[sessionId]?.taskState, + cwd: replayState()?.cwd, + taskState: replayState()?.taskState, messageId: replayMessageId, parentToolUseId, }, @@ -7785,7 +7957,7 @@ export class ClaudeAcpAgent { }; for (const message of messages) await replayMessage(message); - if (nativeReplayEnabled) { + if (nativeReplayEnabled && !pending?.stopped) { // Claude history persists sidechain messages and Agent/Task tool uses, // but not task_started/task_updated lifecycle frames. Recover terminal // state from the launch tool_result. Missing results and malformed or @@ -8333,7 +8505,12 @@ export class ClaudeAcpAgent { sessionId, update: { sessionUpdate: "available_commands_update", - availableCommands: getAvailableSlashCommands(commands, session.terminalSlashCommands), + availableCommands: getAvailableSlashCommands( + commands, + session.terminalSlashCommands, + this.toolCallCapabilities.air.client ? session.cwd : undefined, + (session.skillPaths ??= new Map()), + ), }, }); } @@ -8746,6 +8923,8 @@ export class ClaudeAcpAgent { resumedSession?: ResumedSessionSnapshot, ): Promise { const existingSession = this.sessions[params.sessionId]; + // A recreated live session keeps its mode, not the mode of the transcript. + const livePermissionMode = existingSession?.modes.currentModeId as PermissionMode | undefined; if (existingSession) { const fingerprint = computeSessionFingerprint(params); if (fingerprint === existingSession.sessionFingerprint) { @@ -8763,10 +8942,6 @@ export class ClaudeAcpAgent { await this.teardownSession(params.sessionId); } - const resumedModelHint = resumedSession - ? resumedSession.model - : await readResumedModel(params.sessionId, this.logger); - const response = await this.createSession( { cwd: params.cwd, @@ -8776,7 +8951,8 @@ export class ClaudeAcpAgent { }, { resume: params.sessionId, - resumedModelHint, + permissionMode: livePermissionMode, + ...(resumedSession ? { resumedModelHint: resumedSession.model } : {}), }, ); @@ -8816,6 +8992,15 @@ export class ClaudeAcpAgent { } } + /** Whether the session forwards the text and the thinking of a subagent. */ + private forwardsSubagentText(meta: NewSessionRequest["_meta"]): boolean { + return ( + clientSupportsSubagents(this.clientCapabilities) || + supportsSubagentTranscript(this.clientCapabilities) || + (meta as NewSessionMeta | undefined)?.claudeCode?.options?.forwardSubagentText === true + ); + } + private async createSession( params: NewSessionRequest, creationOpts: { @@ -8831,7 +9016,9 @@ export class ClaudeAcpAgent { /** Concrete model id from the resumed transcript's last real assistant * message. Claude Code restores from this same record, so it lets us * report the live model without a slow getContextUsage control request. */ - resumedModelHint?: string; + resumedModelHint?: string | Promise; + /** The state that a replay on the same session uses before this record exists. */ + replayState?: ReplayState; } = {}, ): Promise { const createStartedAt = performance.now(); @@ -8868,12 +9055,14 @@ export class ClaudeAcpAgent { // sign-out respawn, provider rerouting), so fill the same fast local hint // here when the caller did not provide it. Never fall back to the slow // getContextUsage control request. + // The same tail read gives the permission mode of the resumed session. let resumedModelHint = creationOpts.resumedModelHint; - if ( - creationOpts.resume !== undefined && - !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint") - ) { - resumedModelHint = await readResumedModel(creationOpts.resume, this.logger); + let resumedPermissionMode: string | undefined; + if (creationOpts.resume !== undefined) { + const withModel = !Object.prototype.hasOwnProperty.call(creationOpts, "resumedModelHint"); + const tail = await readResumedTail(creationOpts.resume, this.logger, withModel); + if (withModel) resumedModelHint = tail.model; + resumedPermissionMode = tail.permissionMode; timing.phase("resume-transcript"); } @@ -8942,8 +9131,12 @@ export class ClaudeAcpAgent { settingsManager.getSettings().permissions?.disableBypassPermissionsMode !== "disable" && sessionMeta?.claudeCode?.options?.allowDangerouslySkipPermissions !== false; - const initialPermissionMode = resolvePermissionMode( - creationOpts.permissionMode ?? settingsManager.getSettings().permissions?.defaultMode, + const initialPermissionMode = resolveInitialPermissionMode( + { + explicit: creationOpts.permissionMode, + resumed: resumedPermissionMode, + defaultMode: settingsManager.getSettings().permissions?.defaultMode, + }, this.logger, allowBypass, ); @@ -8955,10 +9148,7 @@ export class ClaudeAcpAgent { // ACP contract. Ignore the provider-specific option instead of forwarding // hidden state that the Client cannot inspect or change. if (userProvidedOptions) delete userProvidedOptions.agent; - const forwardSubagentText = - clientSupportsSubagents(this.clientCapabilities) || - supportsSubagentTranscript(this.clientCapabilities) || - userProvidedOptions?.forwardSubagentText === true; + const forwardSubagentText = this.forwardsSubagentText(params._meta); // Configure thinking behavior from environment variable const thinking = resolveThinkingConfig(process.env.MAX_THINKING_TOKENS, this.logger); @@ -8992,7 +9182,7 @@ export class ClaudeAcpAgent { // Per-session task state. Created here (rather than in the session record // below) so the TaskCreated/TaskCompleted hook callbacks can close over // the same Map that the streaming message handler will read from. - const taskState: TaskState = new Map(); + const taskState: TaskState = creationOpts.replayState?.taskState ?? new Map(); // Resolve every workspace root once. The native checkpoint report uses // this same set for lexical path validation, and the SDK receives it below. @@ -9318,7 +9508,7 @@ export class ClaudeAcpAgent { this.logger, creationOpts.resume !== undefined, sessionId, - resumedModelHint, + await resumedModelHint, ); timing.phase("models"); @@ -9489,8 +9679,9 @@ export class ClaudeAcpAgent { nativeSubagentParentByToolUseId: new Map(), emittedAssistantText: false, owedTrailingIdles: 0, - messageIdToUuid: new Map(), - sessionFailureState: createSessionFailureState(), + messageIdToUuid: creationOpts.replayState?.messageIdToUuid ?? new Map(), + sessionFailureState: + creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(), claudeSubscriptionGuard, accountKind: fromAccountInfo(initializationResult.account)?.kind, fileChangeReporter, @@ -9537,7 +9728,10 @@ export class ClaudeAcpAgent { this.closeQueryStream(session); delete this.sessions[sessionId]; try { - await this.createSession(session.creationParams, { resume: sessionId }); + await this.createSession(session.creationParams, { + resume: sessionId, + permissionMode: session.modes.currentModeId as PermissionMode, + }); } catch (error) { // One session that cannot come back must not abort the switch. The // `--hide-claude-auth` guard makes this a normal outcome of @@ -10136,7 +10330,20 @@ function getAvailableSlashCommands( // the CLI's own terminal, which ACP clients aren't) — filtered alongside // the static list. Raw CLI names, matched before the MCP rename. terminalCommands?: readonly string[], + // The session cwd of an AIR client, or undefined for every other client. + // AIR gets the kind and the SKILL.md path of each skill command. + airSkillCwd?: string, + // The resolved SKILL.md paths of the session, keyed by the cwd and the name. + skillPaths?: Map, ): AvailableCommand[] { + const skillPath = (name: string, cwd: string): string | undefined => { + const key = `${cwd}\0${name}`; + if (skillPaths?.has(key)) return skillPaths.get(key); + const resolved = resolveSkillPath(name, cwd); + skillPaths?.set(key, resolved); + return resolved; + }; + const UNSUPPORTED_COMMANDS = [ "clear", "cost", @@ -10148,8 +10355,10 @@ function getAvailableSlashCommands( "todos", ]; - return commands - .filter((command) => !terminalCommands?.includes(command.name)) + // The adapter replaces the terminal text of `/mcp` and runs its actions, + // even when Claude Code tags the command terminal-bound. + const advertised = commands + .filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name)) .map((command) => { const input = command.argumentHint ? { @@ -10159,16 +10368,31 @@ function getAvailableSlashCommands( } : null; let name = command.name; - if (command.name.endsWith(" (MCP)")) { + const mcpPrompt = command.name.endsWith(" (MCP)"); + if (mcpPrompt) { name = `mcp:${name.replace(" (MCP)", "")}`; } + const skillMdPath = + airSkillCwd && !command.builtin && !mcpPrompt + ? skillPath(command.name, airSkillCwd) + : undefined; return { name, description: command.description || "", input, + ...(skillMdPath + ? { + _meta: withAirMeta( + withAirMeta(undefined, AIR_KIND_KEY, "skill"), + AIR_SKILL_PATH_KEY, + skillMdPath, + ), + } + : {}), }; }) .filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name)); + return [...advertised, MCP_AVAILABLE_COMMAND]; } function formatUriAsLink(uri: string): string { diff --git a/src/air-extension.ts b/src/air-extension.ts index 7194f17b..2517442f 100644 --- a/src/air-extension.ts +++ b/src/air-extension.ts @@ -18,6 +18,8 @@ export const AIR_SKILL_KEY = "skill"; export const AIR_CONTEXT_COMPACTION_KEY = "contextCompaction"; export const AIR_GOAL_KEY = "goal"; export const AIR_KIND_KEY = "kind"; +/** The absolute path of the `SKILL.md` of a skill command in `available_commands_update`. */ +export const AIR_SKILL_PATH_KEY = "skillPath"; export const AIR_PERMISSION_KEY = "permission"; export const AIR_CUSTOM_ANSWER_KEY = "customAnswer"; /** diff --git a/src/mcp-command.ts b/src/mcp-command.ts new file mode 100644 index 00000000..f1014e5e --- /dev/null +++ b/src/mcp-command.ts @@ -0,0 +1,388 @@ +import type { AvailableCommand } from "@agentclientprotocol/sdk"; +import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk"; +import { escapeMarkdown } from "./usage-markdown.js"; +import { raceTimeoutAndAbort } from "./utils.js"; + +/** The `/mcp` entry of `available_commands_update`. The adapter shows the + * servers as a list, and runs a reconnect, an enable, or a disable through + * the SDK control API. */ +export const MCP_AVAILABLE_COMMAND: AvailableCommand = { + name: "mcp", + description: "Show the MCP servers and their status, or reconnect, enable, or disable a server", + input: { hint: "[reconnect|enable|disable [|all]]" }, +}; + +/** The time that the status read after a `/mcp` command can take. */ +const MCP_STATUS_TIMEOUT_MS = 5_000; + +/** The time that one reconnect, enable, or disable can take. */ +const MCP_ACTION_TIMEOUT_MS = 30_000; + +/** A `/mcp` prompt that gets a replacement for its Claude Code text. `all` is + * true for `all` and for a reconnect without a server name, as in Claude + * Code. `server` is undefined for an enable or a disable without a name. */ +export type McpCommand = + | { action: "status" } + | { action: "reconnect" | "enable" | "disable"; all: boolean; server?: string }; + +/** Parse a prompt that is exactly `/mcp`, or `/mcp reconnect`, `/mcp enable`, + * or `/mcp disable` with an optional server name or `all`. Any other `/mcp` + * argument gives null, and the prompt gets no replacement. */ +export function parseMcpCommand(text: string): McpCommand | null { + const words = text.trim().split(/\s+/); + if (words[0] !== "/mcp") return null; + if (words.length === 1) return { action: "status" }; + const action = words[1]?.toLowerCase(); + if (action !== "reconnect" && action !== "enable" && action !== "disable") return null; + const server = words.slice(2).join(" "); + if (server === "all" || (server === "" && action === "reconnect")) { + return { action, all: true }; + } + return server === "" ? { action, all: false } : { action, all: false, server }; +} + +/** Read the MCP server status for the list after a `/mcp` command. Resolves + * to null when the read fails, takes too long, or `signal` aborts. Then the + * turn keeps the text of Claude Code. */ +export async function readMcpServerStatus( + query: Pick, + signal: AbortSignal, + logError: (message: string) => void, +): Promise { + if (signal.aborted) return null; + try { + const outcome = await raceTimeoutAndAbort( + query.mcpServerStatus(), + MCP_STATUS_TIMEOUT_MS, + signal, + ); + if (outcome.type === "timeout") { + logError("The MCP server status read timed out; keeping the Claude Code text of /mcp"); + } + return outcome.type === "done" ? outcome.value : null; + } catch (error) { + if (!signal.aborted) { + logError(`The MCP server status read failed; keeping the Claude Code text of /mcp: ${error}`); + } + return null; + } +} + +/** The known statuses in the order of the groups and of the summary counts. + * A disabled server is not a group. It is on one line at the end. */ +const GROUP_ORDER: McpServerStatus["status"][] = ["failed", "needs-auth", "pending", "connected"]; + +const GROUP_TITLES: Partial> = { + failed: "Failed", + "needs-auth": "Needs authentication", + pending: "Connecting", + connected: "Connected", +}; + +const SUMMARY_LABELS: Record = { + connected: "connected", + failed: "failed", + "needs-auth": "need authentication", + pending: "connecting", + disabled: "disabled", +}; + +const KNOWN_STATUSES: string[] = [...GROUP_ORDER, "disabled"]; + +const MAX_ERROR_LENGTH = 160; + +/** The longest segment that can be a wrapper. A longer segment is content. */ +const MAX_WRAPPER_LENGTH = 60; + +/** True for a server that a `/mcp reconnect` without a name must retry. */ +function needsReconnect(status: McpServerStatus): boolean { + return ( + status.status === "failed" || status.status === "pending" || status.status === "needs-auth" + ); +} + +/** The text as a Markdown code span. The fence is longer than the longest + * backtick run in the text, so the span shows the text unchanged. The user + * can copy a server name from it into `/mcp reconnect`. */ +function codeSpan(text: string): string { + const longestRun = Math.max(0, ...(text.match(/`+/g) ?? []).map((run) => run.length)); + const fence = "`".repeat(longestRun + 1); + // CommonMark strips one space from each end of a span, so a pad keeps the text unchanged. + const pad = text.startsWith("`") || text.endsWith("`") || /^ .*[^ ].* $/.test(text) ? " " : ""; + return `${fence}${pad}${text}${pad}${fence}`; +} + +function isErrorCode(segment: string): boolean { + return /^-?\d+$/.test(segment); +} + +/** True for a segment that only wraps the real error, such as + * `MCP startup failed`, `McpError`, `MCP error -32001`, an error code, or a + * Rust or TypeScript type path. */ +function isWrapperSegment(segment: string): boolean { + return ( + isErrorCode(segment) || + segment.includes("::") || + (segment.length <= MAX_WRAPPER_LENGTH && + !/[.!?]/.test(segment) && + /(failed|error|exception)(\s+-?\d+)?$/i.test(segment)) + ); +} + +/** Cut the text to `MAX_ERROR_LENGTH` code points, so a surrogate pair stays whole. */ +function truncate(text: string): string { + const chars = Array.from(text); + if (chars.length <= MAX_ERROR_LENGTH) return text; + const cut = chars.slice(0, MAX_ERROR_LENGTH - 1).join(""); + return `${cut.trimEnd()}…`; +} + +/** Make an MCP error short and readable for one list item. The result has + * no leading wrapper segments, at most two sentences, and at most + * `MAX_ERROR_LENGTH` code points. A wrapper before an error code stays, so + * an error code is never alone. The result is empty for an error with only + * whitespace. */ +export function cleanMcpError(error: string): string { + const segments = error.replace(/\s+/g, " ").trim().split(": "); + let start = 0; + while ( + start < segments.length - 1 && + isWrapperSegment(segments[start]!) && + !segments.slice(start + 1).every(isErrorCode) + ) { + start++; + } + const content = segments.slice(start).join(": "); + return truncate( + content + .split(/(?<=[.!?]) /) + .slice(0, 2) + .join(" "), + ); +} + +function toolCount(status: McpServerStatus): string | undefined { + if (!status.tools) return undefined; + return status.tools.length === 1 ? "1 tool" : `${status.tools.length} tools`; +} + +/** The server name as inline code. Claude Code keys the servers by name, so + * two servers have the same name only from different scopes. Then the + * scope tells them apart. */ +function serverName(status: McpServerStatus, duplicates: Set): string { + const origin = status.scope ?? status.source; + return duplicates.has(status.name) && origin + ? `${codeSpan(status.name)} (${escapeMarkdown(origin)})` + : codeSpan(status.name); +} + +function serverItem(status: McpServerStatus, duplicates: Set): string { + const error = status.error ? cleanMcpError(status.error) : ""; + const details = [error === "" ? undefined : escapeMarkdown(error), toolCount(status)] + .filter((detail) => detail !== undefined) + .join("; "); + const name = serverName(status, duplicates); + return details === "" ? `- ${name}` : `- ${name}: ${details}`; +} + +function summaryLine(statuses: McpServerStatus[]): string { + // The counts follow the group order. An unknown status also counts, so the counts add up to the total. + const counts = [...GROUP_ORDER, ...unknownStatuses(statuses), "disabled"] + .map((state) => ({ + state, + count: statuses.filter((status) => status.status === state).length, + })) + .filter(({ count }) => count > 0); + const parts = counts.map(({ state, count }) => + count === 1 && state === "needs-auth" + ? "1 needs authentication" + : `${count} ${SUMMARY_LABELS[state as McpServerStatus["status"]] ?? escapeMarkdown(state)}`, + ); + return `**MCP servers:** ${statuses.length} (${parts.join(", ")})`; +} + +/** The statuses that this adapter does not know, in the SDK order. The SDK + * can report a new status before the adapter knows it. */ +function unknownStatuses(statuses: McpServerStatus[]): string[] { + return [ + ...new Set( + statuses + .map((status) => status.status as string) + .filter((state) => !KNOWN_STATUSES.includes(state)), + ), + ]; +} + +/** The group title of a status that the adapter does not know. It starts with a capital letter, as a known title does. */ +function unknownTitle(state: string): string { + return escapeMarkdown(state.charAt(0).toUpperCase() + state.slice(1)); +} + +/** Markdown for `/mcp`: a summary line, one group of servers for each + * status, and the disabled servers on one line. A list reads better than a + * table in a narrow chat. */ +export function formatMcpStatus(statuses: McpServerStatus[]): string { + if (statuses.length === 0) return "No MCP servers are configured."; + const blocks: string[] = []; + const names = statuses.map((status) => status.name); + const duplicates = new Set(names.filter((name, index) => names.indexOf(name) !== index)); + blocks.push(summaryLine(statuses)); + const groups: [string, string][] = [ + ...GROUP_ORDER.map((state): [string, string] => [state, GROUP_TITLES[state]!]), + ...unknownStatuses(statuses).map((state): [string, string] => [state, unknownTitle(state)]), + ]; + for (const [state, title] of groups) { + const members = statuses.filter((status) => status.status === state); + if (members.length === 0) continue; + blocks.push( + [`**${title}**`, ...members.map((status) => serverItem(status, duplicates))].join("\n"), + ); + } + const disabled = statuses.filter((status) => status.status === "disabled"); + if (disabled.length > 0) { + blocks.push( + `**Disabled:** ${disabled.map((status) => serverName(status, duplicates)).join(", ")}`, + ); + } + if (statuses.some(needsReconnect)) { + blocks.push( + "Run `/mcp reconnect ` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.", + ); + } + return blocks.join("\n\n"); +} + +/** The SDK query methods that `/mcp` uses. */ +export type McpControlQuery = Pick< + Query, + "mcpServerStatus" | "reconnectMcpServer" | "toggleMcpServer" +>; + +/** The result of {@link runMcpCommand}. `markdown` is null when the turn + * keeps the text of Claude Code. `reconnected` holds the servers that the + * adapter tried to reconnect. */ +export type McpCommandOutcome = { markdown: string | null; reconnected: string[] }; + +type CallResult = { type: "done" } | { type: "failed"; error: string } | { type: "aborted" }; + +/** Wait for one control call. The wait stops when `signal` aborts or the + * call takes more than {@link MCP_ACTION_TIMEOUT_MS}. */ +async function awaitCall(call: () => Promise, signal: AbortSignal): Promise { + if (signal.aborted) return { type: "aborted" }; + try { + const outcome = await raceTimeoutAndAbort(call(), MCP_ACTION_TIMEOUT_MS, signal); + if (outcome.type === "timeout") return { type: "failed", error: "The request timed out." }; + return outcome.type === "done" ? { type: "done" } : outcome; + } catch (error) { + return signal.aborted + ? { type: "aborted" } + : { type: "failed", error: error instanceof Error ? error.message : String(error) }; + } +} + +const ACTION_DONE = { reconnect: "Reconnected", enable: "Enabled", disable: "Disabled" }; + +/** The Claude Code sentence that refuses an action in SDK mode. */ +const UNAVAILABLE_SENTENCE = /aren't available in this session/i; + +/** A Claude Code sentence that points to a terminal, which an ACP client + * does not have. */ +function isTerminalSentence(sentence: string): boolean { + return /in the terminal/i.test(sentence) || sentence.includes("`/mcp reconnect all` here"); +} + +function sentences(text: string): string[] { + return text + .trim() + .split(/(?<=[.!?])\s+/) + .filter((sentence) => sentence !== ""); +} + +/** The servers that the action changes. A named server is always a target, + * so the control API reports its own error. */ +function actionTargets( + command: Extract, + statuses: McpServerStatus[], +): string[] { + if (!command.all) return command.server === undefined ? [] : [command.server]; + const names = statuses + .filter((status) => { + if (command.action === "reconnect") return needsReconnect(status); + if (command.action === "enable") return status.status === "disabled"; + return status.status !== "disabled"; + }) + .map((status) => status.name); + return [...new Set(names)]; +} + +function nothingToDoLine(action: McpCommand["action"]): string { + if (action === "enable") return "Every MCP server is already enabled."; + if (action === "disable") return "Every MCP server is already disabled."; + return "Every MCP server is connected or disabled. There is nothing to reconnect."; +} + +/** Run a `/mcp` command after Claude Code sent `originalOutput`, its text for + * the command. Claude Code refuses a reconnect, an enable, and a disable in + * SDK mode, so the adapter runs them through the SDK control API. The result + * of each server goes above the server list. An error of one server does + * not stop the others. When the first status read fails or `signal` + * aborts, the turn keeps the text of Claude Code. */ +export async function runMcpCommand( + query: McpControlQuery, + command: McpCommand, + originalOutput: string, + signal: AbortSignal, + logError: (message: string) => void, +): Promise { + const reconnected: string[] = []; + const keep: McpCommandOutcome = { markdown: null, reconnected }; + if (command.action === "status") { + // The list replaces the Claude Code summary line. + const statuses = await readMcpServerStatus(query, signal, logError); + return { markdown: statuses ? formatMcpStatus(statuses) : null, reconnected }; + } + const before = await readMcpServerStatus(query, signal, logError); + if (!before) return keep; + + const cliSentences = sentences(originalOutput).filter( + (sentence) => !isTerminalSentence(sentence), + ); + const cliNamesUnknown = cliSentences.some((sentence) => /no MCP server named/i.test(sentence)); + const results: string[] = []; + if (!command.all && command.server === undefined) { + results.push(`Name a server or \`all\`, for example \`/mcp ${command.action} \`.`); + } + const known = new Set(before.map((status) => status.name)); + const targets = actionTargets(command, before); + if (command.all && targets.length === 0) results.push(nothingToDoLine(command.action)); + for (const server of targets) { + if (!known.has(server)) { + // Claude Code checks the name first. Its sentence then stays. + if (!cliNamesUnknown) results.push(`There's no MCP server named ${codeSpan(server)}.`); + continue; + } + if (command.action === "reconnect") reconnected.push(server); + const call = + command.action === "reconnect" + ? () => query.reconnectMcpServer(server) + : () => query.toggleMcpServer(server, command.action === "enable"); + const result = await awaitCall(call, signal); + if (result.type === "aborted") return keep; + if (result.type === "done") { + results.push(`- ${ACTION_DONE[command.action]} ${codeSpan(server)}`); + } else { + const error = cleanMcpError(result.error); + const failure = `- Couldn't ${command.action} ${codeSpan(server)}`; + results.push(error === "" ? failure : `${failure}: ${escapeMarkdown(error)}`); + } + } + + const blocks: string[] = []; + const kept = cliSentences.filter((sentence) => !UNAVAILABLE_SENTENCE.test(sentence)); + if (kept.length > 0) blocks.push(kept.join(" ")); + if (results.length > 0) blocks.push(results.join("\n")); + const after = await readMcpServerStatus(query, signal, logError); + if (signal.aborted) return keep; + if (after) blocks.push(formatMcpStatus(after)); + return { markdown: blocks.join("\n\n"), reconnected }; +} diff --git a/src/resumed-session.ts b/src/resumed-session.ts index 5231e7ee..bc50df27 100644 --- a/src/resumed-session.ts +++ b/src/resumed-session.ts @@ -68,31 +68,46 @@ export async function readResumedSession( } } +/** What a resume restores from the end of the local transcript. */ +export type ResumedTail = { + model?: string; + /** The last permission mode of the main thread. See {@link TailScan}. */ + permissionMode?: string; +}; + /** - * Read the resume model of a session from the end of its local transcript. + * Read the resume model and the permission mode of a session from the end + * of its local transcript. * - * A resume needs only the model, not the messages. The transcript file is - * read backwards until the last real assistant record of the main thread, so - * the cost does not grow with the length of the session. A session without a - * local transcript file falls back to {@link readResumedSession}. + * A resume needs only these two values, not the messages. The transcript file + * is read backwards until both are known, so the cost does not grow with the + * length of the session. A session without a local transcript file falls back + * to {@link readResumedSession} for the model. + * + * A caller that already has the model passes `withModel: false`. The read then + * looks for the permission mode only and has no fallback. */ -export async function readResumedModel( +export async function readResumedTail( sessionId: string, logger?: ResumeLogger, -): Promise { + withModel = true, +): Promise { const timing = new SessionTiming(logger, "models", sessionId); try { const filePath = await findTranscript(sessionId); if (filePath) { - const model = await lastAssistantModel(filePath); - timing.phase("read-transcript-tail", ` model=${model ?? "unknown"}`); - return model; + const tail = await lastTailRecords(filePath, withModel); + timing.phase( + "read-transcript-tail", + ` model=${tail.model ?? "unknown"} permissionMode=${tail.permissionMode ?? "unknown"}`, + ); + return tail; } } catch (error) { timing.phase("read-transcript-tail", " outcome=error"); logger?.error(`Failed to read the transcript tail of resumed session ${sessionId}:`, error); } - return (await readResumedSession(sessionId, logger)).model; + return withModel ? { model: (await readResumedSession(sessionId, logger)).model } : {}; } /** The local transcript of a session in any project directory, as the SDK looks it up. */ @@ -116,9 +131,11 @@ export async function findTranscript(sessionId: string): Promise { +/** The model of the last real assistant record and the last permission mode + * of the main thread in a JSONL transcript. */ +async function lastTailRecords(filePath: string, withModel: boolean): Promise { const handle = await open(filePath, "r"); + const scan = new TailScan(withModel); try { let end = (await handle.stat()).size; // The bytes of the line whose start is not read yet, in file order. @@ -128,31 +145,99 @@ async function lastAssistantModel(filePath: string): Promise let chunk = Buffer.alloc(end - start); await handle.read(chunk, 0, chunk.length, start); for (let at = chunk.lastIndexOf(0x0a); at >= 0; at = chunk.lastIndexOf(0x0a)) { - const model = assistantModelOfLine(Buffer.concat([chunk.subarray(at + 1), ...pending])); - if (model) return model; + if (scan.visit(Buffer.concat([chunk.subarray(at + 1), ...pending]))) return scan.tail; pending = []; chunk = chunk.subarray(0, at); } pending.unshift(chunk); end = start; } - return assistantModelOfLine(Buffer.concat(pending)); + scan.visit(Buffer.concat(pending)); + return scan.tail; } finally { await handle.close(); } } -function assistantModelOfLine(line: Buffer): string | undefined { - // Most lines are not assistant records. The check skips their JSON parse. - if (line.indexOf('"assistant"') < 0) return undefined; +type TranscriptRecord = { + type?: unknown; + isSidechain?: unknown; + message?: { model?: unknown }; + permissionMode?: unknown; + origin?: { kind?: unknown }; + attachment?: { type?: unknown }; +}; + +/** + * Reads the lines of a transcript from the end and keeps what a resume needs. + * + * Claude Code records the permission mode on each prompt record of type `user`. + * The `permission-mode` records of the CLI metadata block are not used: they + * can disagree with the prompts, and the SDK does not write them. + * The SDK writes no record when it leaves plan mode through ExitPlanMode. + * It writes a `plan_mode_exit` attachment only, without the new mode. Thus a + * `plan` mode before a later plan exit is not the current mode, and the scan + * gives no mode. + * + * The mode search stops at the first mode record or at the last human prompt. + * Each human prompt of a current Claude Code has the mode, so a prompt without + * it means that the transcript does not record modes. A compaction does not + * stop the search: the mode of the prompt before it is still current. + */ +class TailScan { + readonly tail: ResumedTail = {}; + private modeDone = false; + private planExited = false; + + constructor(private readonly withModel: boolean) {} + + /** Visit the previous line. Return true when the scan has all it needs. */ + visit(line: Buffer): boolean { + let parsed: TranscriptRecord | null | undefined; + // Most lines have no marker of interest. The check skips their JSON parse. + const record = (marker: string): TranscriptRecord | undefined => { + if (line.indexOf(marker) < 0) return undefined; + if (parsed === undefined) parsed = parseMainThreadRecord(line); + return parsed ?? undefined; + }; + if (this.withModel && this.tail.model === undefined) { + const entry = record('"assistant"'); + if (entry?.type === "assistant") this.tail.model = concreteModel(entry.message?.model); + } + if (!this.modeDone) this.visitForMode(record); + return (!this.withModel || this.tail.model !== undefined) && this.modeDone; + } + + private visitForMode(record: (marker: string) => TranscriptRecord | undefined): void { + const modeEntry = record('"permissionMode"'); + if ( + modeEntry?.type === "user" && + typeof modeEntry.permissionMode === "string" && + modeEntry.permissionMode.trim() !== "" + ) { + const mode = modeEntry.permissionMode.trim(); + this.modeDone = true; + if (!(this.planExited && mode === "plan")) this.tail.permissionMode = mode; + return; + } + if (record('"plan_mode_exit"')?.attachment?.type === "plan_mode_exit") { + this.planExited = true; + return; + } + const human = record('"human"'); + if (human?.type === "user" && human.origin?.kind === "human") this.modeDone = true; + } +} + +/** The parsed record of one line, or null for a subagent record or a bad line. */ +function parseMainThreadRecord(line: Buffer): TranscriptRecord | null { let entry: unknown; try { entry = JSON.parse(line.toString("utf8")); } catch { - return undefined; + return null; } - if (!entry || typeof entry !== "object") return undefined; - const record = entry as { type?: unknown; isSidechain?: unknown; message?: { model?: unknown } }; - if (record.type !== "assistant" || record.isSidechain === true) return undefined; - return concreteModel(record.message?.model); + if (!entry || typeof entry !== "object") return null; + const record = entry as TranscriptRecord; + return record.isSidechain === true ? null : record; } diff --git a/src/session-mode.ts b/src/session-mode.ts index 892c7aaf..5f5cf314 100644 --- a/src/session-mode.ts +++ b/src/session-mode.ts @@ -13,6 +13,7 @@ import type { Query, } from "@anthropic-ai/claude-agent-sdk"; import { AIR_KIND_KEY, airOnlyMeta } from "./air-extension.js"; +import { resolvePermissionMode } from "./permissions/modes.js"; import { noticeOrTranscriptUpdate, noticeTranscriptText, @@ -74,6 +75,37 @@ type InitializeSessionModeParams = { allowBypass: boolean; }; +/** + * The permission mode a new Claude query starts in. + * + * An explicit mode wins. A resume or a load continues in the mode of the last + * user record of the transcript. If that mode is unknown or not available now, + * for example bypass, the session starts in `defaultMode` of the settings. + */ +export function resolveInitialPermissionMode( + sources: { explicit?: PermissionMode; resumed?: string; defaultMode?: unknown }, + logger: { error: (...args: unknown[]) => void }, + allowBypass: boolean, +): PermissionMode { + if (sources.explicit !== undefined) { + return resolvePermissionMode(sources.explicit, logger, allowBypass); + } + if (sources.resumed !== undefined) { + let rejected = false; + const mode = resolvePermissionMode( + sources.resumed, + { error: () => (rejected = true) }, + allowBypass, + ); + if (!rejected) return mode; + logger.error( + `Ignoring permission mode "${sources.resumed}" of the resumed transcript: ` + + `not available in this session.`, + ); + } + return resolvePermissionMode(sources.defaultMode, logger, allowBypass); +} + /** Owns session-mode policy and the ACP/SDK synchronization it requires. */ export class SessionModeManager { constructor(private readonly options: SessionModeManagerOptions) {} diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index c4041394..1488a1f4 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -399,6 +399,142 @@ describe("ClaudeAcpAgent settings", () => { expect(response.modes.currentModeId).toBe("default"); }); + describe("on resume", () => { + const sessionId = "resumed-mode-session"; + + async function setup(records: unknown[]) { + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ permissions: { defaultMode: "plan" } }), + ); + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + const transcriptDir = path.join(tempDir, "projects", "-project"); + await fs.promises.mkdir(transcriptDir, { recursive: true }); + await fs.promises.writeFile( + path.join(transcriptDir, `${sessionId}.jsonl`), + records.map((record) => JSON.stringify(record)).join("\n") + "\n", + ); + + const { getCapturedOptions } = mockQuery(); + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + (agent as any).logger = { log: () => {}, error: () => {} }; + return { agent, projectDir, getCapturedOptions }; + } + + async function resume(records: unknown[], meta: object = {}) { + const { agent, projectDir, getCapturedOptions } = await setup(records); + const response = await (agent as any).createSession( + { cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true, ...meta } }, + { resume: sessionId }, + ); + return { permissionMode: getCapturedOptions().permissionMode, response }; + } + + let previousUuid: string | null = null; + const user = (permissionMode?: string) => { + const uuid = crypto.randomUUID(); + const record = { + type: "user", + uuid, + parentUuid: previousUuid, + sessionId, + timestamp: new Date().toISOString(), + isSidechain: false, + origin: { kind: "human" }, + message: { role: "user", content: "hello" }, + ...(permissionMode ? { permissionMode } : {}), + }; + previousUuid = uuid; + return record; + }; + + it("continues in the last permission mode of the transcript", async () => { + const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]); + + expect(permissionMode).toBe("acceptEdits"); + expect(response.modes.currentModeId).toBe("acceptEdits"); + }); + + it("uses permissions.defaultMode when the transcript has no mode", async () => { + const { permissionMode, response } = await resume([user()]); + + expect(permissionMode).toBe("plan"); + expect(response.modes.currentModeId).toBe("plan"); + }); + + it("uses permissions.defaultMode when bypass is not available now", async () => { + const { permissionMode, response } = await resume([user("bypassPermissions")], { + claudeCode: { options: { allowDangerouslySkipPermissions: false } }, + }); + + expect(permissionMode).toBe("plan"); + expect(response.modes.currentModeId).toBe("plan"); + }); + + it("continues in the last permission mode on session/load", async () => { + const { agent, projectDir, getCapturedOptions } = await setup([ + user("plan"), + user("acceptEdits"), + ]); + + const response = await agent.loadSession({ + sessionId, + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + expect(getCapturedOptions().permissionMode).toBe("acceptEdits"); + expect(response.modes?.currentModeId).toBe("acceptEdits"); + }); + + it("keeps the live mode when it recreates the session", async () => { + const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]); + const params = { sessionId, cwd: projectDir, _meta: { disableBuiltInTools: true } }; + await agent.resumeSession({ ...params, mcpServers: [] }); + await agent.setSessionMode({ sessionId, modeId: "default" }); + + // Other MCP servers change the fingerprint, so the agent recreates the query. + const response = await agent.resumeSession({ + ...params, + mcpServers: [{ name: "other", command: "true", args: [], env: [] }], + }); + + expect(getCapturedOptions().permissionMode).toBe("default"); + expect(response.modes?.currentModeId).toBe("default"); + }); + + it("keeps the live mode when a sign-out respawn starts a fresh conversation", async () => { + const { agent, projectDir, getCapturedOptions } = await setup([user("acceptEdits")]); + await agent.resumeSession({ + sessionId, + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + await agent.setSessionMode({ sessionId, modeId: "default" }); + const { RequestError } = await import("@agentclientprotocol/sdk"); + const createSession = (agent as any).createSession.bind(agent); + const creations: object[] = []; + (agent as any).createSession = async (params: object, options: { resume?: string }) => { + creations.push(options); + // The CLI never wrote the conversation, so the resume fails. + if (options.resume) throw RequestError.resourceNotFound(sessionId); + return createSession(params, options); + }; + + await (agent as any).recreateSignedOutQuery(sessionId, (agent as any).sessions[sessionId]); + + expect(creations).toEqual([ + { resume: sessionId, permissionMode: "default" }, + { reuseSessionId: sessionId, permissionMode: "default" }, + ]); + expect(getCapturedOptions().permissionMode).toBe("default"); + }); + }); + it("falls back to 'default' when permissions.defaultMode is invalid", async () => { await fs.promises.writeFile( path.join(tempDir, "settings.json"), diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 88919868..7f8e009a 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest"; +import { describe, it, expect, beforeAll, beforeEach, afterAll, afterEach, vi } from "vitest"; import { spawn, spawnSync } from "child_process"; import { AvailableCommand, @@ -9751,8 +9751,10 @@ describe("terminal slash command filtering", () => { const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update); const commandsUpdate = updates.find((u) => u.sessionUpdate === "available_commands_update"); expect(commandsUpdate).toBeDefined(); + // `mcp` is the adapter's own entry, advertised after the CLI's commands. expect(commandsUpdate.availableCommands.map((c: { name: string }) => c.name)).toEqual([ "compact", + "mcp", ]); }); @@ -9821,6 +9823,138 @@ describe("terminal slash command filtering", () => { }); }); +describe("skill commands in available_commands_update", () => { + let cwd: string; + let skillPath: string; + + beforeEach(async () => { + cwd = await mkdtemp(path.join(os.tmpdir(), "acp-skill-commands-")); + skillPath = path.join(cwd, ".claude", "skills", "review", "SKILL.md"); + await mkdir(path.dirname(skillPath), { recursive: true }); + await writeFile(skillPath, "# review\n"); + }); + + afterEach(async () => { + await rm(cwd, { recursive: true, force: true }); + }); + + /** The commands of the last available_commands_update. With `change`, the + * CLI sends a second commands_changed after `change` runs. */ + async function advertisedCommands( + clientCapabilities: Record, + change?: () => Promise, + ) { + const sessionUpdate = vi.fn(async () => {}); + const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, { + log: () => {}, + error: () => {}, + }); + await agent.initialize({ protocolVersion: 1, clientCapabilities }); + async function* generator(input: Pushable) { + const iter = input[Symbol.asyncIterator](); + const { value: userMessage } = await iter.next(); + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + const commandsChanged = { + type: "system", + subtype: "commands_changed", + session_id: "test-session", + commands: [ + { name: "review", description: "Review the diff", argumentHint: "" }, + { + name: "compact", + description: "Compact the conversation", + argumentHint: "", + builtin: true, + }, + { name: "missing", description: "No SKILL.md", argumentHint: "" }, + { name: "review (MCP)", description: "An MCP prompt", argumentHint: "" }, + ], + }; + yield commandsChanged; + if (change) { + await change(); + yield commandsChanged; + } + yield { + type: "result", + subtype: "success", + stop_reason: null, + is_error: false, + result: "", + errors: [], + duration_ms: 0, + duration_api_ms: 0, + num_turns: 1, + total_cost_usd: 0, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: randomUUID(), + session_id: "test-session", + }; + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + injectGeneratorSession(agent, generator, { cwd }); + + await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] }); + + const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update); + const commandsUpdate = updates.findLast((u) => u.sessionUpdate === "available_commands_update"); + expect(commandsUpdate).toBeDefined(); + return commandsUpdate.availableCommands as Array<{ name: string; _meta?: unknown }>; + } + + it("marks a skill with a SKILL.md on disk for an AIR client", async () => { + const commands = await advertisedCommands(AIR_CLIENT_CAPABILITIES); + + expect(commands.find((c) => c.name === "review")?._meta).toEqual({ + jetbrains: { air: { version: 1, kind: "skill", skillPath } }, + }); + expect(commands.filter((c) => c.name !== "review").map((c) => [c.name, c._meta])).toEqual([ + ["compact", undefined], + ["missing", undefined], + ["mcp:review", undefined], + ["mcp", undefined], + ]); + }); + + it("resolves the SKILL.md paths again after a commands_changed", async () => { + const missingPath = path.join(cwd, ".claude", "skills", "missing", "SKILL.md"); + const commands = await advertisedCommands(AIR_CLIENT_CAPABILITIES, async () => { + await mkdir(path.dirname(missingPath), { recursive: true }); + await writeFile(missingPath, "# missing\n"); + }); + + expect(commands.find((c) => c.name === "missing")?._meta).toEqual({ + jetbrains: { air: { version: 1, kind: "skill", skillPath: missingPath } }, + }); + }); + + it("gives a client that is not AIR no _meta", async () => { + const commands = await advertisedCommands({}); + + expect(commands.map((c) => [c.name, c._meta])).toEqual([ + ["review", undefined], + ["compact", undefined], + ["missing", undefined], + ["mcp:review", undefined], + ["mcp", undefined], + ]); + }); +}); + describe("logout", () => { function createMockAgent() { const mockClient = { diff --git a/src/tests/acp-scenarios-compare.test.ts b/src/tests/acp-scenarios-compare.test.ts index 0f0a1a6f..75fd8d3c 100644 --- a/src/tests/acp-scenarios-compare.test.ts +++ b/src/tests/acp-scenarios-compare.test.ts @@ -60,4 +60,39 @@ describe("compareWithBaseline", () => { expect.stringMatching(/^origin\/main did not send /u), ]); }); + + describe("available_commands_update", () => { + const commands = (...names: string[]) => + update({ + sessionUpdate: "available_commands_update", + availableCommands: names.map((name) => ({ name, description: name })), + }); + + it("accepts the mcp command of the adapter", () => { + expect(compareWithBaseline([commands("compact")], [commands("compact", "mcp")])).toEqual([]); + expect(compareWithBaseline([commands()], [commands("mcp")])).toEqual([]); + }); + + it("reports any other extra command", () => { + expect(compareWithBaseline([commands()], [commands("mcp", "other")])).toContainEqual( + expect.stringMatching(/but the adapter sent .*"name":"other"/u), + ); + }); + + it("reports a dropped command", () => { + expect(compareWithBaseline([commands("compact")], [commands("mcp")])).toContainEqual( + expect.stringMatching(/^origin\/main sent .*"name":"compact"/u), + ); + }); + + it("reports a changed mcp command that origin/main listed", () => { + const changed = update({ + sessionUpdate: "available_commands_update", + availableCommands: [{ name: "mcp", description: "other" }], + }); + expect(compareWithBaseline([commands("mcp")], [changed])).toContainEqual( + expect.stringMatching(/but the adapter sent .*"description":"other"/u), + ); + }); + }); }); diff --git a/src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl b/src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl index 03071230..5a8a5629 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/ask-user-question.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"AskUserQuestion"}},"content":[],"kind":"other","name":"AskUserQuestion","sessionUpdate":"tool_call","status":"pending","title":"Asking for your input","toolCallId":"toolu_ask"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl b/src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl index add13670..2077c9c4 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/async-task-ends-before-tool-id.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"asyncTaskId":"workflow_1","canStop":true,"description":"Build assets","name":"Build assets","outputFilePath":"/tmp/tasks/workflow_1.output","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"asyncTaskId":"workflow_1","sessionUpdate":"async_task_state_update","state":"completed","summary":"done"}}} {"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl b/src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl index 678ed11a..a288edcd 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/async-task-held-until-tool-id.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"asyncTaskId":"monitor_1","canStop":true,"description":"Watch the logs","name":"Watch the logs","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"monitor","toolCallId":"toolu_monitor"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"asyncTaskId":"monitor_1","sessionUpdate":"async_task_progress","summary":"first line","usage":{"durationMs":5,"toolUses":0,"totalTokens":0}}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"asyncTaskId":"monitor_1","sessionUpdate":"async_task_progress","summary":"second line","usage":{"durationMs":6,"toolUses":0,"totalTokens":0}}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl b/src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl index 44706df1..22c289c0 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/async-task-released-at-turn-end.jsonl @@ -1,3 +1,3 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"asyncTaskId":"workflow_2","canStop":true,"description":"Build assets","name":"Build assets","sessionUpdate":"async_task_spawned","showInTranscript":true,"taskType":"workflow"}}} {"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl b/src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl index 49c3ea7a..0ae2fcbc 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/bash-background.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bg"}},"content":[{"terminalId":"toolu_bg","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bg"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"sessionUpdate":"tool_call_update","title":"sleep 100","toolCallId":"toolu_bg"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl b/src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl index 4a915c07..371ca7da 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/bash-error.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"sessionUpdate":"tool_call_update","title":"false","toolCallId":"toolu_bash"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl b/src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl index f5fb155e..268ed1df 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/bash-foreground.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_bash"}},"content":[{"terminalId":"toolu_bash","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_bash"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"sessionUpdate":"tool_call_update","title":"ls -la","toolCallId":"toolu_bash"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl b/src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl index bc956dab..16db2c3f 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/compaction-failed-legacy.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"error":"Context too small","version":1}}}},"content":[{"content":{"text":"Compaction failed: Context too small","type":"text"},"type":"content"}],"sessionUpdate":"tool_call_update","status":"failed","toolCallId":"00000000-0000-4000-8000-000000000001"}}} {"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl b/src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl index b3c84304..6d0199a7 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/compaction-legacy.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"kind":"think","sessionUpdate":"tool_call","status":"in_progress","title":"Compact conversation","toolCallId":"00000000-0000-4000-8000-000000000001"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"sessionUpdate":"tool_call_update","status":"in_progress","toolCallId":"00000000-0000-4000-8000-000000000001"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1}}}},"sessionUpdate":"tool_call_update","status":"completed","toolCallId":"00000000-0000-4000-8000-000000000001"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl b/src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl index f66a7d56..2b279571 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/compaction-update.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"in_progress"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"compactionId":"00000000-0000-4000-8000-000000000001","content":{"text":"The summary.","type":"text"},"sessionUpdate":"compaction_summary_chunk"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"jetbrains":{"air":{"contextCompaction":{"durationMs":7,"postTokens":100,"preTokens":1000,"trigger":"automatic","version":1},"version":1}}},"compactionId":"00000000-0000-4000-8000-000000000001","sessionUpdate":"compaction_update","status":"completed"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl b/src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl index 5ce5e8c6..d536ed31 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/edit-rejected.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"locations":[{"path":"/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl b/src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl index f18aa1ee..ed759668 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/edit-with-permission.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Edit"}},"content":[],"kind":"edit","locations":[],"name":"Edit","sessionUpdate":"tool_call","status":"pending","title":"Edit","toolCallId":"toolu_edit"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"locations":[{"path":"/src/app.ts"}],"sessionUpdate":"tool_call_update","title":"Edit src/app.ts","toolCallId":"toolu_edit"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl b/src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl index 096c5c14..08a6a1bf 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/exit-plan-approve.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl b/src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl index 00bda7ef..1443eeb3 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/exit-plan-reject.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"ExitPlanMode"}},"content":[],"kind":"switch_mode","name":"ExitPlanMode","sessionUpdate":"tool_call","status":"pending","title":"Approve Plan","toolCallId":"toolu_plan"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/goal.jsonl b/src/tests/acp-scenarios/__snapshots__/air/goal.jsonl index caf7f1af..dad79ce5 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/goal.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/goal.jsonl @@ -1,3 +1,3 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"jetbrains":{"air":{"goal":{"controlMethod":"_session/goal","objective":"Ship the feature","status":"active"},"version":1}}},"sessionUpdate":"session_info_update"}}} {"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl b/src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl index de92ba27..16709f08 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/grep-and-glob.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Grep"}},"content":[],"kind":"search","name":"Grep","sessionUpdate":"tool_call","status":"pending","title":"grep","toolCallId":"toolu_grep"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"sessionUpdate":"tool_call_update","title":"grep \"value\"","toolCallId":"toolu_grep"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl b/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl index 8eab4979..03806e4e 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/mcp-tool.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"mcp__docs__search"},"is_mcp_tool_call":true},"content":[],"kind":"other","name":"mcp__docs__search","sessionUpdate":"tool_call","status":"pending","title":"mcp__docs__search","toolCallId":"toolu_mcp"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl b/src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl index 48496a79..5977679e 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/memory-recall.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"select"}}},"kind":"read","locations":[{"path":"/memory/a.md"},{"path":"/memory/b.md"}],"sessionUpdate":"tool_call","status":"completed","title":"Recalled 2 memories","toolCallId":"00000000-0000-4000-8000-000000000001"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"memory_recall","toolResponse":{"mode":"synthesize"}}},"content":[{"content":{"text":"The user prefers tabs.","type":"text"},"type":"content"}],"kind":"read","sessionUpdate":"tool_call","status":"completed","title":"Recalled synthesized memory","toolCallId":"00000000-0000-4000-8000-000000000002"}}} {"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl b/src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl index d6a73180..421fb755 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/network-permission.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"SandboxNetworkAccess"}},"content":[],"kind":"other","name":"SandboxNetworkAccess","rawInput":{"host":"example.com"},"sessionUpdate":"tool_call","status":"pending","title":"SandboxNetworkAccess","toolCallId":"toolu_net"}}} {"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"description":"Reason: Sandbox blocks network","title":"example.com","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"","toolCall":{"rawInput":{"host":"example.com"},"title":"example.com","toolCallId":"toolu_net"}}} {"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl b/src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl index cebbc81c..7ff1cacf 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/notebook-edit.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"NotebookEdit"}},"content":[],"kind":"edit","locations":[],"name":"NotebookEdit","sessionUpdate":"tool_call","status":"pending","title":"Edit notebook","toolCallId":"toolu_nb"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"locations":[{"path":"/n.ipynb"}],"sessionUpdate":"tool_call_update","title":"Edit cell in n.ipynb","toolCallId":"toolu_nb"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl b/src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl index 11c72926..6ad545ba 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/permission-denied.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_denied"}},"content":[{"terminalId":"toolu_denied","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_denied"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl b/src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl index 5dd5dbe1..25e4c34f 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/rate-limit-and-origin.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"Work","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"ing.","type":"text"},"messageId":"msg_rate","sessionUpdate":"agent_message_chunk"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/read.jsonl b/src/tests/acp-scenarios/__snapshots__/air/read.jsonl index 9791d307..4e692a62 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/read.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/read.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Read"}},"content":[],"kind":"read","locations":[],"name":"Read","sessionUpdate":"tool_call","status":"pending","title":"Read File","toolCallId":"toolu_read"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl b/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl index da6747e2..6b1115d6 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/session-load-replay.jsonl @@ -12,4 +12,4 @@ {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"Fixed.","type":"text"},"messageId":"msg_r2","sessionUpdate":"agent_message_chunk"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":":replay-subagent:toolu_r_task"}}} {"kind":"loadSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":""}} -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl b/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl index 1e59642f..4ef918aa 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/session-setup.jsonl @@ -1,3 +1,3 @@ {"kind":"initialize","payload":{"_meta":{"jetbrains":{"air":{"capabilities":["sessionFailure","agentFileChangeReport","nativeSubagentSessions","asyncTasks","recommendedValue","diffPatch","planFile"],"goal":{"actions":["set","clear"],"controlMethod":"_session/goal","version":1},"version":1}},"steering":{"supported":true}},"agentCapabilities":{"_meta":{"authStatus":{},"claudeCode":{"promptQueueing":true}},"auth":{"logout":{}},"loadSession":true,"mcpCapabilities":{"http":true,"sse":true},"promptCapabilities":{"embeddedContext":true,"image":true},"providers":{},"sessionCapabilities":{"additionalDirectories":{},"close":{},"delete":{},"fork":{},"list":{},"resume":{},"subagents":{}}},"agentInfo":{"name":"@agentclientprotocol/claude-agent-acp","title":"Claude Agent","version":""},"authMethods":[],"protocolVersion":1}} {"kind":"newSession","payload":{"configOptions":[{"category":"mode","currentValue":"default","description":"Session permission mode","id":"mode","name":"Mode","options":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","name":"Manual","value":"default"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","name":"Accept edits","value":"acceptEdits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","name":"Plan","value":"plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","name":"Auto","value":"auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","name":"Bypass permissions","value":"bypassPermissions"}],"type":"select"},{"category":"model","currentValue":"claude-sonnet-4-6","description":"AI model to use","id":"model","name":"Model","options":[{"description":"Fast","name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"modes":{"availableModes":[{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Always ask before making changes","id":"default","name":"Manual"},{"_meta":{"jetbrains":{"air":{"kind":"standard","version":1}}},"description":"Automatically accept all file edits","id":"acceptEdits","name":"Accept edits"},{"_meta":{"jetbrains":{"air":{"kind":"plan","version":1}}},"description":"Create a plan before making changes","id":"plan","name":"Plan"},{"_meta":{"jetbrains":{"air":{"kind":"auto_review","version":1}}},"description":"Claude handles permission decisions","id":"auto","name":"Auto"},{"_meta":{"jetbrains":{"air":{"kind":"full_access","version":1}}},"description":"Accepts all permissions","id":"bypassPermissions","name":"Bypass permissions"}],"currentModeId":"default"},"sessionId":""}} -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/skill.jsonl b/src/tests/acp-scenarios/__snapshots__/air/skill.jsonl index d8402397..838c930f 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/skill.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/skill.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Skill"}},"content":[],"kind":"other","name":"Skill","sessionUpdate":"tool_call","status":"pending","title":"Load skill","toolCallId":"toolu_skill"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl b/src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl index a06b35e7..df27f0e3 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/subagent-agent-async.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Agent","toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl b/src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl index 4e3253c0..67aba59d 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/subagent-late-child-update.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"capabilities":{},"name":"Check the logs","prompt":"Check logs","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_late","task":"Check logs"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl b/src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl index 44705d55..f8ca9334 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/subagent-native-sessions.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"capabilities":{},"name":"Fix the build","prompt":"Make the build pass","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_n","task":"Make the build pass"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl b/src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl index 05005c68..f6147569 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/subagent-nested.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"capabilities":{},"name":"Plan the work","prompt":"Plan it","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_outer","task":"Plan it"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl b/src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl index d181cb0e..4b6b9d74 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/subagent-task-legacy.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"capabilities":{},"name":"Explore code","prompt":"Find the parser","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_1","task":"Find the parser"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl b/src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl index 4a3a9c6c..6a997450 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/subagent-transcript-extension.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"capabilities":{},"name":"Summarize","prompt":"Summarize the repo","sessionUpdate":"subagent_spawned","subagentSessionId":"agent_t","task":"Summarize the repo"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl b/src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl index f008c274..3730428c 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/task-create-update.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"entries":[{"content":"Write tests","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl b/src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl index 5ec6bee5..073527bd 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/task-output-and-stop.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"TaskOutput"}},"content":[],"kind":"other","name":"TaskOutput","sessionUpdate":"tool_call","status":"pending","title":"TaskOutput","toolCallId":"toolu_out"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl b/src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl index 1896ae63..c6fc2207 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/text-and-thinking.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"Let me thin","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"content":{"text":"k about it.","type":"text"},"messageId":"msg_text","sessionUpdate":"agent_thought_chunk"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl b/src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl index 11164963..ce7e9731 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/todo-write.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"entries":[{"content":"Writing tests","priority":"medium","status":"in_progress"},{"content":"Ship","priority":"medium","status":"pending"}],"sessionUpdate":"plan"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl b/src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl index 3242bdaf..c2c0380c 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/tool-progress.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Bash"},"terminal_info":{"terminal_id":"toolu_slow"}},"content":[{"terminalId":"toolu_slow","type":"terminal"}],"kind":"execute","name":"Bash","sessionUpdate":"tool_call","status":"pending","title":"Terminal","toolCallId":"toolu_slow"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl b/src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl index ae093a75..239e5795 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/web-fetch-and-search.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"WebFetch"}},"content":[],"kind":"fetch","name":"WebFetch","sessionUpdate":"tool_call","status":"pending","title":"Fetch","toolCallId":"toolu_fetch"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"sessionUpdate":"tool_call_update","title":"Fetch https://example.com","toolCallId":"toolu_fetch"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl b/src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl index 91aee924..c5834a77 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/write-existing.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"locations":[{"path":"/old.ts"}],"sessionUpdate":"tool_call_update","title":"Write old.ts","toolCallId":"toolu_write"}}} diff --git a/src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl b/src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl index 1b41425d..ecb635c0 100644 --- a/src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl +++ b/src/tests/acp-scenarios/__snapshots__/air/write-new.jsonl @@ -1,4 +1,4 @@ -{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[],"sessionUpdate":"available_commands_update"}}} +{"kind":"sessionUpdate","payload":{"sessionId":"","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"_meta":{"claudeCode":{"toolName":"Write"}},"content":[],"kind":"edit","locations":[],"name":"Write","sessionUpdate":"tool_call","status":"pending","title":"Preparing file…","toolCallId":"toolu_write"}}} {"kind":"sessionUpdate","payload":{"sessionId":"","update":{"locations":[{"path":"/new.ts"}],"sessionUpdate":"tool_call_update","title":"Write new.ts","toolCallId":"toolu_write"}}} diff --git a/src/tests/acp-scenarios/compare.ts b/src/tests/acp-scenarios/compare.ts index f1afd2a0..a9130de3 100644 --- a/src/tests/acp-scenarios/compare.ts +++ b/src/tests/acp-scenarios/compare.ts @@ -16,6 +16,9 @@ * `compaction_summary_chunk` text that went out before it. * - A subagent message or thought is not sent again in full when the chunks * of the same message that went out before it hold the same text. + * - An `available_commands_update` also lists the `mcp` command of the + * adapter (see {@link ADAPTER_COMMANDS}), when origin/main did not list + * `mcp`. The adapter replaces the text of `/mcp` for every client. */ import type { Recorded } from "./harness.js"; @@ -32,6 +35,9 @@ export const AIR_ONLY_META_KEYS = new Set([ /** The `_meta.claudeCode` keys that exist only for AIR. */ export const AIR_ONLY_CLAUDE_CODE_KEYS = new Set(["title", "subagent", "skill", "skillPath"]); +/** The names of the commands that the adapter adds to `available_commands_update`. */ +export const ADAPTER_COMMANDS = new Set(["mcp"]); + /** The tool call fields that an update replaces as a whole. */ const REPLACED_FIELDS = [ "status", @@ -137,6 +143,22 @@ function subagentChunk(update: Json | undefined): { key: string; text: string } return { key: `${update.sessionUpdate} ${parent} ${update.messageId}`, text }; } +function commandNames(update: Json): Set { + return new Set(((update.availableCommands ?? []) as Json[]).map((command) => command.name)); +} + +/** A copy of `got` without the adapter commands that `want` does not list. */ +function withoutAdapterCommands(want: Json, got: Json): Json { + const listed = commandNames(want); + const commands = (got.availableCommands ?? []) as Json[]; + return { + ...got, + availableCommands: commands.filter( + (command) => listed.has(command.name) || !ADAPTER_COMMANDS.has(command.name as string), + ), + }; +} + function isAppended(key: string): boolean { return APPENDED_META_KEYS.has(key.slice("_meta.".length)); } @@ -214,6 +236,9 @@ export function compareWithBaseline(baseline: Recorded[], current: Recorded[]): ); return other(want) === other(got); } + if (want.sessionUpdate === "available_commands_update") { + return canonical(want) === canonical(withoutAdapterCommands(want, got)); + } if (want.sessionUpdate === "compaction_update" && want.summary && !got.summary) { const text = (want.summary as { text?: string }[]).map((part) => part.text ?? "").join(""); return ( diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index 4ac7f9fd..aca70097 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -297,6 +297,12 @@ describe("ACP v2 sessions", () => { description: "Review a change", input: { type: "text", hint: "" }, }, + { + name: "mcp", + description: + "Show the MCP servers and their status, or reconnect, enable, or disable a server", + input: { type: "text", hint: "[reconnect|enable|disable [|all]]" }, + }, ]); }); diff --git a/src/tests/create-session-options.test.ts b/src/tests/create-session-options.test.ts index 13cc954c..a9e07fb2 100644 --- a/src/tests/create-session-options.test.ts +++ b/src/tests/create-session-options.test.ts @@ -17,6 +17,9 @@ let contextUsageResult: (() => Promise<{ rawMaxTokens: number; model?: string }> let sessionMessages: Record[]; let sessionMessagesResult: () => Promise[]>; let initModels: Record[] | undefined; +/** Holds the CLI initialization until the test resolves it. */ +let initGate: Promise | undefined; +let initError: Error | undefined; let setModelImpl: ((model: string) => Promise) | undefined; let interruptImpl: (() => Promise) | undefined; let closeSpy: ReturnType void>>; @@ -38,16 +41,11 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { return makeMockQuery({ interrupt: () => (interruptImpl ? interruptImpl() : Promise.resolve(undefined)), close: () => closeSpy(), - initializationResult: async () => ({ - models: initModels ?? [ - { - value: "claude-sonnet-4-6", - displayName: "Claude Sonnet", - description: "Fast", - supportsAutoMode: true, - }, - ], - }), + initializationResult: async () => { + await initGate; + if (initError) throw initError; + return initializationResponse(); + }, setModel: (model: string) => (setModelImpl ? setModelImpl(model) : Promise.resolve()), getContextUsage: () => contextUsageResult ? contextUsageResult() : Promise.resolve(DEFAULT_CONTEXT_USAGE), @@ -59,6 +57,19 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => { }; }); +function initializationResponse() { + return { + models: initModels ?? [ + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet", + description: "Fast", + supportsAutoMode: true, + }, + ], + }; +} + vi.mock("../tools.js", async () => { const actual = await vi.importActual("../tools.js"); return { @@ -87,6 +98,8 @@ describe("createSession options merging", () => { sessionMessagesResult = async () => sessionMessages; vi.mocked(getSessionMessages).mockClear(); initModels = undefined; + initGate = undefined; + initError = undefined; setModelImpl = undefined; interruptImpl = undefined; closeSpy = vi.fn<() => void>(); @@ -1407,4 +1420,145 @@ describe("createSession options merging", () => { expect((agent as any).sessions[sessionId].models.currentModelId).toBe("claude-sonnet-4-6"); }); }); + + describe("loadSession history replay", () => { + const userMessage = (uuid: string, text: string) => ({ + type: "user", + uuid, + session_id: "replay-probe", + parent_tool_use_id: null, + parent_agent_id: null, + message: { role: "user", content: [{ type: "text", text }] }, + }); + + function capturingAgent(updates: SessionNotification[]) { + return new ClaudeAcpAgent({ + ...createMockClient(), + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + }, + } as AcpClient); + } + + it("replays the history while the CLI starts", async () => { + let releaseInit!: () => void; + initGate = new Promise((resolve) => (releaseInit = resolve)); + sessionMessages = [userMessage("u1", "first"), userMessage("u2", "second")]; + const updates: SessionNotification[] = []; + const loading = capturingAgent(updates).loadSession({ + sessionId: "replay-probe", + cwd: process.cwd(), + mcpServers: [], + }); + let loaded = false; + void loading.then(() => (loaded = true)); + + await vi.waitFor(() => expect(updates).toHaveLength(2)); + expect(loaded).toBe(false); + expect(updates.map((update) => update.update.sessionUpdate)).toEqual([ + "user_message_chunk", + "user_message_chunk", + ]); + + releaseInit(); + await expect(loading).resolves.toMatchObject({ sessionId: "replay-probe" }); + }); + + it("keeps the not-found error when the CLI finds no conversation", async () => { + initError = new Error("No conversation found with session ID: replay-probe"); + const updates: SessionNotification[] = []; + const loadAgent = capturingAgent(updates); + + await expect( + loadAgent.loadSession({ sessionId: "replay-probe", cwd: process.cwd(), mcpServers: [] }), + ).rejects.toMatchObject({ code: RequestError.resourceNotFound().code }); + expect(updates).toEqual([]); + expect((loadAgent as any).sessions["replay-probe"]).toBeUndefined(); + }); + + it("stops the replay when the CLI start fails", async () => { + let failInit!: () => void; + initGate = new Promise((resolve) => (failInit = resolve)); + initError = new Error("No conversation found with session ID: replay-probe"); + let releaseRead!: () => void; + const readGate = new Promise((resolve) => (releaseRead = resolve)); + sessionMessagesResult = async () => { + await readGate; + return [userMessage("u1", "first")]; + }; + const updates: SessionNotification[] = []; + const loading = capturingAgent(updates).loadSession({ + sessionId: "replay-probe", + cwd: process.cwd(), + mcpServers: [], + }); + const failed = expect(loading).rejects.toMatchObject({ + code: RequestError.resourceNotFound().code, + }); + + failInit(); + // The failed start closes its query, and the load then stops the replay. + await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); + await new Promise((resolve) => setImmediate(resolve)); + releaseRead(); + await failed; + expect(updates).toEqual([]); + }); + + it("sends no update after a load that fails while the replay runs", async () => { + let failInit!: () => void; + initGate = new Promise((resolve) => (failInit = resolve)); + initError = new Error("Claude Code process exited with code 1"); + sessionMessages = [ + userMessage("u1", "first"), + userMessage("u2", "second"), + userMessage("u3", "third"), + ]; + const updates: SessionNotification[] = []; + let releaseClient!: () => void; + const clientGate = new Promise((resolve) => (releaseClient = resolve)); + const loading = new ClaudeAcpAgent({ + ...createMockClient(), + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + await clientGate; + }, + } as AcpClient).loadSession({ + sessionId: "replay-probe", + cwd: process.cwd(), + mcpServers: [], + }); + let updatesAtError: number | undefined; + const failed = loading.then( + () => expect.unreachable("the load must fail"), + () => (updatesAtError = updates.length), + ); + + // The client still handles the first replayed update when the CLI start fails. + await vi.waitFor(() => expect(updates).toHaveLength(1)); + failInit(); + await vi.waitFor(() => expect(closeSpy).toHaveBeenCalled()); + releaseClient(); + await failed; + for (let i = 0; i < 5; i++) await new Promise((resolve) => setImmediate(resolve)); + + expect(updatesAtError).toBeGreaterThan(0); + expect(updates).toHaveLength(updatesAtError!); + }); + + it("rejects an invalid cwd before it sends an update", async () => { + sessionMessages = [userMessage("u1", "first")]; + const updates: SessionNotification[] = []; + + await expect( + capturingAgent(updates).loadSession({ + sessionId: "replay-probe", + cwd: path.join(os.tmpdir(), "no-such-directory-for-replay-probe"), + mcpServers: [], + }), + ).rejects.toMatchObject({ code: RequestError.invalidParams().code }); + expect(updates).toEqual([]); + expect(getSessionMessages).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/tests/mcp-command.test.ts b/src/tests/mcp-command.test.ts new file mode 100644 index 00000000..19475ff8 --- /dev/null +++ b/src/tests/mcp-command.test.ts @@ -0,0 +1,846 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, it, vi } from "vitest"; +import type { McpServerStatus } from "@anthropic-ai/claude-agent-sdk"; +import type { SessionNotification } from "@agentclientprotocol/sdk"; +import { ClaudeAcpAgent, stripLocalCommandMetadata, type AcpClient } from "../acp-agent.js"; +import { + cleanMcpError, + formatMcpStatus, + MCP_AVAILABLE_COMMAND, + parseMcpCommand, +} from "../mcp-command.js"; +import { Pushable } from "../utils.js"; +import { initializeClient } from "./helpers.js"; +import { + mockSessionState, + successfulResultMessage, + userEcho, + wrapQuery, +} from "./session-doubles.js"; + +const SERVERS: McpServerStatus[] = [ + { + name: "github", + status: "connected", + scope: "user", + tools: [{ name: "search" }, { name: "issue" }], + }, + { name: "linear", status: "needs-auth", scope: "project", source: "plugin" }, + { name: "db", status: "failed", error: "spawn db-mcp ENOENT", scope: "local" }, + { name: "docs", status: "pending", scope: "user" }, + { name: "old", status: "disabled", scope: "user" }, +]; + +/** The SDK message shapes that carry the text of a local command. Claude + * Code 2.1.286 sends `assistant` and then `result`. `system` is the + * dedicated shape of other builds. `idle` ends the turn with an idle state + * and no result, as after an interrupt. */ +type OutputShape = "system" | "assistant" | "result" | "idle"; + +/** The text that Claude Code 2.1.286 sends in SDK mode for an action. */ +const UNAVAILABLE = "Reconnect, enable, and disable aren't available in this session."; + +/** The text of Claude Code in SDK mode for the prompt `text`. Claude Code + * does not run an action. It only refuses it. */ +function cliText(text: string, statuses: McpServerStatus[]): string { + const [command, action, ...rest] = text.split(/\s+/); + if (command !== "/mcp" || !/^(reconnect|enable|disable)?$/.test(action ?? "")) { + return `CLI output of ${text}.`; + } + if (action === undefined) { + return `${statuses.length} MCP server(s): ${statuses.map((status) => status.name).join(", ")}. Use \`/mcp\` in the terminal for details.`; + } + const server = rest.join(" "); + if (server !== "" && server !== "all" && !statuses.some((status) => status.name === server)) { + return `There's no MCP server named "${server}". Run \`/mcp\` in the terminal to see configured servers.`; + } + return UNAVAILABLE; +} + +function syntheticAssistant(text: string) { + return { + type: "assistant", + parent_tool_use_id: null, + uuid: randomUUID(), + session_id: "test-session", + message: { + id: `local-${randomUUID()}`, + type: "message", + role: "assistant", + model: "", + content: [{ type: "text", text }], + stop_reason: "stop_sequence", + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + }, + }; +} + +/** An agent with one session. Its fake Claude Code answers each prompt as + * Claude Code does in SDK mode: it changes nothing and sends its text in + * each of `shapes`. `beforeShape` can hold a shape back. The query reports + * `statuses`, and its control API changes them, unless `query` replaces a + * method. `acpServers` are the MCP servers of the ACP session request. + * `events` records the CLI runs, the status reads, the control calls, and + * the answers in order. */ +function setup( + options: { + query?: Record; + client?: Record; + shapes?: OutputShape[]; + beforeShape?: (shape: OutputShape) => Promise; + acpServers?: string[]; + } = {}, +) { + const shapes = options.shapes ?? ["assistant", "result"]; + const statuses = SERVERS.map((status) => ({ ...status })); + const updates: SessionNotification[] = []; + const events: string[] = []; + const agent = new ClaudeAcpAgent( + { + sessionUpdate: async (notification: SessionNotification) => { + updates.push(notification); + if (notification.update.sessionUpdate === "agent_message_chunk") events.push("answer"); + }, + createElicitation: vi.fn(async () => ({ action: "accept" })), + completeElicitation: vi.fn(async () => {}), + ...options.client, + } as unknown as AcpClient, + { log: () => {}, error: () => {} }, + ); + const input = new Pushable(); + const forwarded: string[] = []; + async function* messages() { + for await (const user of input) { + const text = user.message.content.map((block: any) => block.text).join(" "); + forwarded.push(text); + yield userEcho(user); + events.push(`run ${text}`); + const output = cliText(text, statuses); + for (const shape of shapes) { + await options.beforeShape?.(shape); + if (shape === "system") { + yield { + type: "system", + subtype: "local_command_output", + content: output, + uuid: randomUUID(), + session_id: "test-session", + }; + } else if (shape === "assistant") { + yield syntheticAssistant(output); + } else if (shape === "idle") { + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } else { + yield successfulResultMessage({ result: output }); + } + } + if (!shapes.includes("result") && !shapes.includes("idle")) { + yield successfulResultMessage(); + } + } + } + const find = (name: string) => { + const status = statuses.find((candidate) => candidate.name === name); + if (!status) throw new Error(`Server not found: ${name}`); + return status; + }; + const sdkQuery = Object.assign(wrapQuery(messages()), { + mcpServerStatus: vi.fn(async () => { + events.push("status"); + return statuses.map((status) => ({ ...status })); + }), + reconnectMcpServer: vi.fn(async (name: string) => { + events.push(`reconnect ${name}`); + const status = find(name); + if (status.status !== "needs-auth") status.status = "connected"; + }), + toggleMcpServer: vi.fn(async (name: string, enabled: boolean) => { + events.push(`${enabled ? "enable" : "disable"} ${name}`); + find(name).status = enabled ? "connected" : "disabled"; + }), + ...options.query, + }); + agent.sessions["test-session"] = mockSessionState({ + query: sdkQuery, + input, + creationParams: { + cwd: "/test", + mcpServers: (options.acpServers ?? []).map((name) => ({ + name, + command: "server", + args: [], + env: [], + })), + }, + }); + const text = () => + updates + .filter((update) => update.update.sessionUpdate === "agent_message_chunk") + .map((update) => (update.update as any).content.text) + .join(""); + const prompt = (command: string) => + agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: command }] }); + return { agent, sdkQuery, statuses, forwarded, events, text, prompt }; +} + +/** The server list after a change of `changes` to {@link SERVERS}. */ +function listAfter(changes: Record): string { + return formatMcpStatus( + SERVERS.map((status) => + changes[status.name] ? { ...status, status: changes[status.name]! } : status, + ), + ); +} + +describe("parseMcpCommand", () => { + it("parses the action, the server, and all", () => { + expect(parseMcpCommand("/mcp")).toEqual({ action: "status" }); + expect(parseMcpCommand("/mcp reconnect")).toEqual({ action: "reconnect", all: true }); + expect(parseMcpCommand("/mcp reconnect all")).toEqual({ action: "reconnect", all: true }); + expect(parseMcpCommand("/mcp Reconnect my server")).toEqual({ + action: "reconnect", + all: false, + server: "my server", + }); + expect(parseMcpCommand("/mcp disable all")).toEqual({ action: "disable", all: true }); + expect(parseMcpCommand("/mcp enable")).toEqual({ action: "enable", all: false }); + expect(parseMcpCommand(" /mcp ")).toEqual({ action: "status" }); + }); + + it("rejects every other prompt", () => { + for (const text of [ + "/mcp:github:prompt", + "/mcp help", + "/mcp list", + "/mcpx", + "please run /mcp", + ]) { + expect(parseMcpCommand(text)).toBeNull(); + } + }); +}); + +const LONG_ERROR = + 'MCP startup failed: handshaking with MCP server failed: JSON-RPC error: -32603: No IDE found. Install the "MCP Server" plugin and ensure it is enabled. Probed ports: 64342: JSON-RPC error: -32603: No IDE found. Install the "MCP Server" plugin and ensure it is enabled.'; + +describe("formatMcpStatus", () => { + it("renders the summary, the groups, the disabled line, and the hint", () => { + expect(formatMcpStatus(SERVERS)).toBe( + [ + "**MCP servers:** 5 (1 failed, 1 needs authentication, 1 connecting, 1 connected, 1 disabled)", + "", + "**Failed**", + "- `db`: spawn db-mcp ENOENT", + "", + "**Needs authentication**", + "- `linear`", + "", + "**Connecting**", + "- `docs`", + "", + "**Connected**", + "- `github`: 2 tools", + "", + "**Disabled:** `old`", + "", + "Run `/mcp reconnect ` to reconnect one server, or `/mcp reconnect` to reconnect every server that is not connected and not disabled.", + ].join("\n"), + ); + }); + + it("keeps the SDK order inside a group and omits an empty group", () => { + const markdown = formatMcpStatus([ + { name: "b", status: "failed", error: "boom" }, + { name: "c", status: "connected" }, + { name: "a", status: "failed", error: "bang" }, + { name: "x", status: "disabled" }, + { name: "y", status: "disabled" }, + ]); + expect(markdown).toContain("**Failed**\n- `b`: boom\n- `a`: bang"); + expect(markdown).toContain("**Connected**\n- `c`\n"); + expect(markdown).toContain("**Disabled:** `x`, `y`"); + expect(markdown).not.toContain("**Connecting**"); + expect(markdown).not.toContain("**Needs authentication**"); + expect(markdown).toContain("2 failed"); + }); + + it("uses the singular and the plural for the tool count", () => { + const markdown = formatMcpStatus([ + { name: "one", status: "connected", tools: [{ name: "t" }] }, + { name: "none", status: "connected", tools: [] }, + { name: "many", status: "connected", tools: [{ name: "a" }, { name: "b" }] }, + ]); + expect(markdown).toContain("- `one`: 1 tool\n- `none`: 0 tools\n- `many`: 2 tools"); + expect(markdown).toContain("**MCP servers:** 3 (3 connected)"); + }); + + it("uses the plural for more than one server that needs authentication", () => { + const markdown = formatMcpStatus([ + { name: "a", status: "needs-auth" }, + { name: "b", status: "needs-auth" }, + ]); + expect(markdown).toContain("**MCP servers:** 2 (2 need authentication)"); + }); + + it("shows the hint only when a server can be reconnected", () => { + const hint = "Run `/mcp reconnect `"; + expect( + formatMcpStatus([ + { name: "a", status: "connected" }, + { name: "b", status: "disabled" }, + ]), + ).not.toContain(hint); + expect(formatMcpStatus([{ name: "a", status: "failed" }])).toContain(hint); + expect(formatMcpStatus([{ name: "a", status: "needs-auth" }])).toContain(hint); + expect(formatMcpStatus([{ name: "a", status: "pending" }])).toContain(hint); + }); + + it("drops the scope and the source of a server", () => { + const markdown = formatMcpStatus(SERVERS); + expect(markdown).not.toContain("Scope"); + expect(markdown).not.toContain("plugin"); + }); + + it("shows the scope when two servers have the same name", () => { + const markdown = formatMcpStatus([ + { name: "ctx", status: "connected", scope: "user" }, + { name: "ctx", status: "failed", scope: "project", error: "boom" }, + { name: "solo", status: "connected", scope: "user" }, + ]); + expect(markdown).toContain("- `ctx` (project): boom"); + expect(markdown).toContain("- `ctx` (user)\n- `solo`"); + }); + + it("shortens a long multi-segment error", () => { + const markdown = formatMcpStatus([{ name: "ide", status: "failed", error: LONG_ERROR }]); + expect(markdown).toContain( + '- `ide`: No IDE found. Install the "MCP Server" plugin and ensure it is enabled.\n', + ); + }); + + it("escapes the Markdown syntax in an error and keeps one line", () => { + const markdown = formatMcpStatus([ + { name: "my_server", status: "failed", error: " *bad*\n`x` | y" }, + ]); + expect(markdown).toContain("- `my_server`: \\ \\*bad\\* \\`x\\` \\| y\n"); + }); + + it("counts a status that the adapter does not know", () => { + const markdown = formatMcpStatus([ + { name: "a", status: "connected" }, + { name: "b", status: "sleeping" as McpServerStatus["status"] }, + ]); + expect(markdown).toContain("**MCP servers:** 2 (1 connected, 1 sleeping)"); + expect(markdown).toContain("**Connected**\n- `a`\n\n**Sleeping**\n- `b`"); + }); + + it("orders the summary counts as the groups, with an unknown status before the disabled ones", () => { + const markdown = formatMcpStatus([ + { name: "a", status: "connected" }, + { name: "b", status: "disabled" }, + { name: "c", status: "sleeping" as McpServerStatus["status"] }, + { name: "d", status: "pending" }, + { name: "e", status: "failed" }, + ]); + expect(markdown).toContain( + "**MCP servers:** 5 (1 failed, 1 connecting, 1 connected, 1 sleeping, 1 disabled)", + ); + expect(markdown).toMatch( + /\*\*Failed\*\*[\s\S]*\*\*Connecting\*\*[\s\S]*\*\*Connected\*\*[\s\S]*\*\*Sleeping\*\*[\s\S]*\*\*Disabled:\*\*/, + ); + }); + + it("shows no error detail for an error with only whitespace", () => { + const markdown = formatMcpStatus([ + { name: "x", status: "failed", error: " \n ", tools: [{ name: "a" }, { name: "b" }] }, + { name: "y", status: "failed", error: "\t" }, + ]); + expect(markdown).toContain("- `x`: 2 tools\n- `y`\n"); + }); + + it("shows a server name with a backtick unchanged in a longer fence", () => { + const markdown = formatMcpStatus([ + { name: "a`b", status: "connected" }, + { name: "`edge", status: "disabled" }, + ]); + expect(markdown).toContain("- ``a`b``\n"); + expect(markdown).toContain("**Disabled:** `` `edge ``"); + }); +}); + +describe("cleanMcpError", () => { + it("keeps the real error of a wrapped, repeated error", () => { + expect(cleanMcpError(LONG_ERROR)).toBe( + 'No IDE found. Install the "MCP Server" plugin and ensure it is enabled.', + ); + }); + + it("keeps a short error as it is", () => { + expect(cleanMcpError("spawn db-mcp ENOENT")).toBe("spawn db-mcp ENOENT"); + expect(cleanMcpError("Failed to connect to the server: timeout")).toBe( + "Failed to connect to the server: timeout", + ); + }); + + it("drops a type path and a long generic", () => { + expect( + cleanMcpError("rmcp::service::ServiceError: Transport closed: connection reset by peer"), + ).toBe("Transport closed: connection reset by peer"); + expect( + cleanMcpError( + "Result>: the server exited with code 1", + ), + ).toBe("the server exited with code 1"); + }); + + it("keeps at most two sentences", () => { + expect(cleanMcpError("One. Two! Three? Four.")).toBe("One. Two!"); + }); + + it("cuts a long error with an ellipsis", () => { + const cleaned = cleanMcpError(`${"word ".repeat(60)}end`); + expect(cleaned.length).toBeLessThanOrEqual(160); + expect(cleaned.endsWith("word…")).toBe(true); + }); + + it("never keeps an error code alone", () => { + expect(cleanMcpError("error: 401")).toBe("error: 401"); + expect(cleanMcpError("Error: 12345")).toBe("Error: 12345"); + expect(cleanMcpError("Error 401")).toBe("Error 401"); + expect(cleanMcpError("MCP startup failed: JSON-RPC error: -32603")).toBe( + "JSON-RPC error: -32603", + ); + }); + + it("drops the MCP SDK wrapper", () => { + expect(cleanMcpError("McpError: MCP error -32001: Request timed out")).toBe( + "Request timed out", + ); + }); + + it("cuts a long error at a code point boundary", () => { + const cleaned = cleanMcpError("😀".repeat(200)); + expect(cleanMcpError("😀".repeat(100))).toBe("😀".repeat(100)); + const chars = Array.from(cleaned); + expect(chars.length).toBeLessThanOrEqual(160); + expect(chars.every((char) => char === "😀" || char === "…")).toBe(true); + expect(cleaned.endsWith("😀…")).toBe(true); + }); + + it("gives an empty text for an error with only whitespace", () => { + expect(cleanMcpError(" \n\t ")).toBe(""); + }); +}); + +describe("/mcp", () => { + it("sends /mcp to Claude Code and replaces its text with the list", async () => { + const { sdkQuery, forwarded, text, prompt } = setup(); + + const response = await prompt("/mcp"); + + expect(response.stopReason).toBe("end_turn"); + expect(forwarded).toEqual(["/mcp"]); + expect(text()).toBe(formatMcpStatus(SERVERS)); + expect(text()).not.toContain("terminal"); + expect(sdkQuery.reconnectMcpServer).not.toHaveBeenCalled(); + expect(sdkQuery.toggleMcpServer).not.toHaveBeenCalled(); + }); + + it("says when no MCP server is configured", async () => { + const { text, prompt } = setup({ query: { mcpServerStatus: vi.fn(async () => []) } }); + + await prompt("/mcp"); + + expect(text()).toBe("No MCP servers are configured."); + }); + + it("reconnects a server through the control API after the text of Claude Code", async () => { + const { sdkQuery, forwarded, events, text, prompt } = setup(); + + await prompt("/mcp reconnect db"); + + expect(forwarded).toEqual(["/mcp reconnect db"]); + expect(events).toEqual(["run /mcp reconnect db", "status", "reconnect db", "status", "answer"]); + expect(sdkQuery.reconnectMcpServer).toHaveBeenCalledWith("db"); + expect(text()).toBe(`- Reconnected \`db\`\n\n${listAfter({ db: "connected" })}`); + expect(text()).not.toContain("aren't available"); + }); + + it.each([ + ["disable github", "github", false, "- Disabled `github`", { github: "disabled" }], + ["enable old", "old", true, "- Enabled `old`", { old: "connected" }], + ] as const)( + "runs /mcp %s through the control API", + async (args, server, enabled, line, changes) => { + const { sdkQuery, text, prompt } = setup(); + + await prompt(`/mcp ${args}`); + + expect(sdkQuery.toggleMcpServer).toHaveBeenCalledExactlyOnceWith(server, enabled); + expect(text()).toBe(`${line}\n\n${listAfter(changes)}`); + }, + ); + + it.each(["/mcp reconnect all", "/mcp reconnect"])( + "%s reconnects every server that is not connected and not disabled", + async (command) => { + const { sdkQuery, text, prompt } = setup(); + + await prompt(command); + + expect(sdkQuery.reconnectMcpServer.mock.calls).toEqual([["linear"], ["db"], ["docs"]]); + expect(text()).toBe( + [ + "- Reconnected `linear`\n- Reconnected `db`\n- Reconnected `docs`", + listAfter({ db: "connected", docs: "connected" }), + ].join("\n\n"), + ); + }, + ); + + it("disables every enabled server and enables every disabled server for all", async () => { + const { sdkQuery, prompt } = setup(); + + await prompt("/mcp disable all"); + expect(sdkQuery.toggleMcpServer.mock.calls).toEqual([ + ["github", false], + ["linear", false], + ["db", false], + ["docs", false], + ]); + + sdkQuery.toggleMcpServer.mockClear(); + await prompt("/mcp enable all"); + expect(sdkQuery.toggleMcpServer.mock.calls).toEqual([ + ["github", true], + ["linear", true], + ["db", true], + ["docs", true], + ["old", true], + ]); + }); + + it("says when a reconnect of all has nothing to reconnect", async () => { + const { sdkQuery, text, prompt } = setup({ + query: { + mcpServerStatus: vi.fn(async () => [{ name: "github", status: "connected" }]), + }, + }); + + await prompt("/mcp reconnect all"); + + expect(sdkQuery.reconnectMcpServer).not.toHaveBeenCalled(); + expect(text()).toBe( + "Every MCP server is connected or disabled. There is nothing to reconnect.\n\n" + + formatMcpStatus([{ name: "github", status: "connected" }]), + ); + }); + + it("asks for a server name for an enable without a name", async () => { + const { sdkQuery, text, prompt } = setup(); + + await prompt("/mcp disable"); + + expect(sdkQuery.toggleMcpServer).not.toHaveBeenCalled(); + expect(text()).toBe( + `Name a server or \`all\`, for example \`/mcp disable \`.\n\n${formatMcpStatus(SERVERS)}`, + ); + }); + + it("keeps the unknown-server sentence of Claude Code and drops the terminal sentence", async () => { + const { sdkQuery, text, prompt } = setup(); + + await prompt("/mcp reconnect nosuch"); + + expect(sdkQuery.reconnectMcpServer).not.toHaveBeenCalled(); + expect(text()).toBe(`There's no MCP server named "nosuch".\n\n${formatMcpStatus(SERVERS)}`); + }); + + it("isolates a control API error to its server", async () => { + const { text, prompt } = setup({ + query: { + reconnectMcpServer: vi.fn(async (name: string) => { + if (name === "db") throw new Error("MCP startup failed: spawn db-mcp ENOENT"); + }), + }, + }); + + await prompt("/mcp reconnect all"); + + expect(text()).toBe( + [ + "- Reconnected `linear`\n- Couldn't reconnect `db`: spawn db-mcp ENOENT\n- Reconnected `docs`", + formatMcpStatus(SERVERS), + ].join("\n\n"), + ); + }); + + it.each(["/mcp", "/mcp reconnect db"])( + "keeps the text of Claude Code for %s when the status read fails", + async (command) => { + const { sdkQuery, text, prompt } = setup({ + query: { + mcpServerStatus: vi.fn(async () => { + throw new Error("control channel closed"); + }), + }, + }); + + const response = await prompt(command); + + expect(response.stopReason).toBe("end_turn"); + expect(text()).toBe(cliText(command, SERVERS)); + expect(sdkQuery.reconnectMcpServer).not.toHaveBeenCalled(); + }, + ); + + it.each([ + [["system", "assistant"]], + [["assistant", "system"]], + [["system", "result"]], + [["assistant", "result"]], + ] as OutputShape[][][])( + "runs the action and sends the result once when Claude Code mirrors its text as %j", + async (shapes) => { + const { sdkQuery, text, prompt } = setup({ shapes }); + + await prompt("/mcp reconnect db"); + + expect(text()).toBe(`- Reconnected \`db\`\n\n${listAfter({ db: "connected" })}`); + expect(sdkQuery.reconnectMcpServer).toHaveBeenCalledOnce(); + expect(sdkQuery.mcpServerStatus).toHaveBeenCalledTimes(2); + }, + ); + + it("replaces the result text when no other shape carries it", async () => { + const { text, prompt } = setup({ shapes: ["result"] }); + + await prompt("/mcp"); + + expect(text()).toBe(formatMcpStatus(SERVERS)); + }); + + it("leaves the text of other /mcp prompts unchanged", async () => { + const { agent, sdkQuery, forwarded, text, prompt } = setup(); + + await prompt("/mcp:github:prompt"); + await prompt("/mcp help"); + await agent.prompt({ + sessionId: "test-session", + prompt: [ + { type: "text", text: "/mcp" }, + { type: "text", text: "more" }, + ], + }); + + // The adapter rewrites an MCP prompt command into the Claude Code form. + expect(forwarded).toEqual(["/github:prompt (MCP)", "/mcp help", "/mcp more"]); + expect(text()).toBe( + ["/github:prompt (MCP)", "/mcp help", "/mcp more"] + .map((command) => cliText(command, SERVERS)) + .join(""), + ); + expect(sdkQuery.mcpServerStatus).not.toHaveBeenCalled(); + }); + + // Claude Code ends an interrupted turn with an idle state. + it.each([[["assistant", "result", "idle"]], [["result", "idle"]]] as OutputShape[][][])( + "publishes nothing when a cancel comes during the status read with %j", + async (shapes) => { + let started!: () => void; + const reading = new Promise((resolve) => (started = resolve)); + const { agent, text, prompt } = setup({ + shapes, + query: { + mcpServerStatus: vi.fn( + () => + new Promise(() => { + started(); + }), + ), + }, + }); + + const response = prompt("/mcp"); + await reading; + await agent.cancel({ sessionId: "test-session" }); + + expect((await response).stopReason).toBe("cancelled"); + expect(text()).toBe(""); + }, + ); + + it("stops the actions and publishes nothing when a cancel comes during a reconnect", async () => { + let started!: () => void; + const reconnecting = new Promise((resolve) => (started = resolve)); + const reconnectMcpServer = vi.fn( + () => + new Promise(() => { + started(); + }), + ); + const { agent, text, prompt } = setup({ + shapes: ["assistant", "result", "idle"], + query: { reconnectMcpServer }, + }); + + const response = prompt("/mcp reconnect all"); + await reconnecting; + await agent.cancel({ sessionId: "test-session" }); + + expect((await response).stopReason).toBe("cancelled"); + expect(text()).toBe(""); + expect(reconnectMcpServer).toHaveBeenCalledOnce(); + }); +}); + +describe("/mcp and MCP OAuth", () => { + const authenticated = () => + vi.fn(async (_server: string) => ({ requiresUserAction: false, callbackExpected: false })); + + async function oauthSetup(options: Parameters[0] = {}) { + const mcpAuthenticate = authenticated(); + const harness = setup({ ...options, query: { mcpAuthenticate, ...options.query } }); + await initializeClient(harness.agent, { elicitation: { url: {} } } as any); + return { ...harness, mcpAuthenticate }; + } + + it("starts the MCP OAuth flow after an explicit reconnect of an ACP server", async () => { + const { mcpAuthenticate, text, prompt } = await oauthSetup({ acpServers: ["linear"] }); + + await prompt("/mcp reconnect linear"); + + await vi.waitFor(() => expect(mcpAuthenticate).toHaveBeenCalledOnce()); + expect(mcpAuthenticate).toHaveBeenCalledWith("linear"); + expect(text()).toBe(`- Reconnected \`linear\`\n\n${formatMcpStatus(SERVERS)}`); + }); + + it("starts the MCP OAuth flow only for the ACP servers of a reconnect of all", async () => { + const statuses: McpServerStatus[] = [ + { name: "acp", status: "needs-auth" }, + { name: "user", status: "needs-auth" }, + ]; + const { mcpAuthenticate, prompt } = await oauthSetup({ + acpServers: ["acp"], + query: { mcpServerStatus: vi.fn(async () => statuses), reconnectMcpServer: vi.fn() }, + }); + + await prompt("/mcp reconnect all"); + + await vi.waitFor(() => expect(mcpAuthenticate).toHaveBeenCalledOnce()); + expect(mcpAuthenticate).toHaveBeenCalledWith("acp"); + }); + + it("does not start the MCP OAuth flow for a plain /mcp", async () => { + const { mcpAuthenticate, sdkQuery, prompt } = await oauthSetup({ acpServers: ["linear"] }); + + await prompt("/mcp"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(sdkQuery.mcpServerStatus).toHaveBeenCalledOnce(); + expect(mcpAuthenticate).not.toHaveBeenCalled(); + }); + + it("does not start the MCP OAuth flow for a server that is not from the ACP request", async () => { + const { mcpAuthenticate, prompt } = await oauthSetup(); + + await prompt("/mcp reconnect linear"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mcpAuthenticate).not.toHaveBeenCalled(); + }); + + it("does not start the MCP OAuth flow when the turn was cancelled", async () => { + let releaseResult!: () => void; + const resultHeld = new Promise((resolve) => (releaseResult = resolve)); + const { agent, mcpAuthenticate, text, prompt } = await oauthSetup({ + acpServers: ["linear"], + shapes: ["assistant", "result", "idle"], + beforeShape: (shape) => (shape === "result" ? resultHeld : Promise.resolve()), + }); + + const response = prompt("/mcp reconnect linear"); + await vi.waitFor(() => expect(text()).toContain("Reconnected `linear`")); + await agent.cancel({ sessionId: "test-session" }); + releaseResult(); + + expect((await response).stopReason).toBe("cancelled"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(mcpAuthenticate).not.toHaveBeenCalled(); + }); + + it("does not hold the turn while the MCP OAuth flow runs", async () => { + const mcpAuthenticate = vi.fn(() => new Promise(() => {})); + const { agent, prompt } = setup({ query: { mcpAuthenticate }, acpServers: ["linear"] }); + await initializeClient(agent, { elicitation: { url: {} } } as any); + + const response = await prompt("/mcp reconnect linear"); + + expect(response.stopReason).toBe("end_turn"); + await vi.waitFor(() => expect(mcpAuthenticate).toHaveBeenCalledOnce()); + }); + + it("does not start the MCP OAuth flow without URL elicitation", async () => { + const mcpAuthenticate = authenticated(); + const { prompt } = setup({ query: { mcpAuthenticate }, acpServers: ["linear"] }); + + await prompt("/mcp reconnect linear"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mcpAuthenticate).not.toHaveBeenCalled(); + }); + + it("does not start the MCP OAuth flow when the result fell back to the CLI text", async () => { + const { mcpAuthenticate, prompt } = await oauthSetup({ + acpServers: ["linear"], + query: { + mcpServerStatus: vi.fn(async () => { + throw new Error("control channel closed"); + }), + }, + }); + + await prompt("/mcp reconnect linear"); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(mcpAuthenticate).not.toHaveBeenCalled(); + }); +}); + +describe("/mcp in available_commands_update", () => { + it("advertises one mcp entry when Claude Code also has one", async () => { + const { agent, sdkQuery } = setup(); + const sessionUpdate = vi.fn(async () => {}); + agent.client = { sessionUpdate } as unknown as AcpClient; + Object.assign(sdkQuery, { + supportedCommands: vi.fn(async () => [ + { name: "mcp", description: "Manage MCP servers" }, + { name: "compact", description: "Compact the conversation" }, + ]), + }); + + await (agent as any).sendAvailableCommandsUpdate("test-session"); + + const update = (sessionUpdate.mock.calls[0] as any[])[0].update; + const mcpEntries = update.availableCommands.filter( + (command: { name: string }) => command.name === "mcp", + ); + expect(mcpEntries).toEqual([MCP_AVAILABLE_COMMAND]); + }); +}); + +describe("/mcp replay", () => { + it("hides a persisted /mcp invocation", () => { + expect(stripLocalCommandMetadata("/mcp")).toBeNull(); + expect( + stripLocalCommandMetadata( + "/mcpreconnect db", + ), + ).toBeNull(); + }); +}); diff --git a/src/tests/resumed-session.test.ts b/src/tests/resumed-session.test.ts index 972c085e..930aa284 100644 --- a/src/tests/resumed-session.test.ts +++ b/src/tests/resumed-session.test.ts @@ -3,7 +3,7 @@ import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk"; import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { readResumedModel, resumedModelFromTranscript } from "../resumed-session.js"; +import { readResumedTail, resumedModelFromTranscript } from "../resumed-session.js"; function assistant( model: unknown, @@ -51,7 +51,7 @@ describe("resumedModelFromTranscript", () => { }); }); -describe("readResumedModel", () => { +describe("readResumedTail", () => { let configDir: string; let originalConfigDir: string | undefined; @@ -94,7 +94,7 @@ describe("readResumedModel", () => { record("claude-haiku-4-5", { isSidechain: true }), ]); - expect(await readResumedModel("long")).toBe("claude-opus-5"); + expect((await readResumedTail("long")).model).toBe("claude-opus-5"); }); it("reads a record that is longer than one backward read", async () => { @@ -103,18 +103,103 @@ describe("readResumedModel", () => { userLine("after"), ]); - expect(await readResumedModel("wide")).toBe("claude-opus-5"); + expect((await readResumedTail("wide")).model).toBe("claude-opus-5"); }); it("reads the first line of the file", async () => { await transcript("single", [record("claude-opus-5")]); - expect(await readResumedModel("single")).toBe("claude-opus-5"); + expect((await readResumedTail("single")).model).toBe("claude-opus-5"); }); it("returns undefined when no real model is recorded", async () => { await transcript("none", [userLine("hello"), record("")]); - expect(await readResumedModel("none")).toBeUndefined(); + expect((await readResumedTail("none")).model).toBeUndefined(); + }); + + const prompt = (text: string, extra: object = {}) => ({ ...userLine(text), ...extra }); + + it("reads the permission mode of the last main-thread user record", async () => { + await transcript("mode", [ + prompt("first", { permissionMode: "plan" }), + record("claude-opus-5"), + prompt("second", { permissionMode: "bypassPermissions" }), + record("claude-opus-5"), + prompt("nested", { permissionMode: "default", isSidechain: true }), + { type: "mode", mode: "normal" }, + userLine("tool result without a mode"), + ]); + + expect(await readResumedTail("mode")).toEqual({ + model: "claude-opus-5", + permissionMode: "bypassPermissions", + }); + }); + + it("reads a permission mode that is before the last model", async () => { + await transcript("early-mode", [ + prompt("first", { permissionMode: "acceptEdits" }), + ...Array.from({ length: 200 }, () => userLine("x".repeat(1000))), + record("claude-opus-5"), + ]); + + expect((await readResumedTail("early-mode")).permissionMode).toBe("acceptEdits"); + }); + + it("ignores the permission-mode records of the CLI metadata block", async () => { + await transcript("metadata-mode", [ + prompt("first", { permissionMode: "bypassPermissions", origin: { kind: "human" } }), + record("claude-opus-5"), + { type: "permission-mode", permissionMode: "auto", sessionId: "metadata-mode" }, + ]); + + expect((await readResumedTail("metadata-mode")).permissionMode).toBe("bypassPermissions"); + }); + + it("gives no plan mode when a plan exit follows it", async () => { + const planExit = { type: "attachment", attachment: { type: "plan_mode_exit" } }; + await transcript("plan-exit", [ + prompt("plan it", { permissionMode: "plan", origin: { kind: "human" } }), + record("claude-opus-5"), + planExit, + record("claude-opus-5"), + ]); + await transcript("plan-exit-then-mode", [ + prompt("plan it", { permissionMode: "plan", origin: { kind: "human" } }), + planExit, + prompt("go", { permissionMode: "acceptEdits", origin: { kind: "human" } }), + record("claude-opus-5"), + ]); + + expect(await readResumedTail("plan-exit")).toEqual({ model: "claude-opus-5" }); + expect((await readResumedTail("plan-exit-then-mode")).permissionMode).toBe("acceptEdits"); + }); + + it("stops the mode search at the last human prompt without a mode", async () => { + await transcript("bounded", [ + prompt("old", { permissionMode: "bypassPermissions", origin: { kind: "human" } }), + prompt("new", { origin: { kind: "human" } }), + record("claude-opus-5"), + ]); + + expect(await readResumedTail("bounded")).toEqual({ model: "claude-opus-5" }); + }); + + it("reads the mode of the prompt before a compaction", async () => { + await transcript("compacted", [ + prompt("old", { permissionMode: "plan", origin: { kind: "human" } }), + { type: "system", subtype: "compact_boundary" }, + prompt("summary", { isCompactSummary: true }), + record("claude-opus-5"), + ]); + + expect((await readResumedTail("compacted")).permissionMode).toBe("plan"); + }); + + it("returns no permission mode when the transcript records none", async () => { + await transcript("no-mode", [userLine("hello"), record("claude-opus-5")]); + + expect(await readResumedTail("no-mode")).toEqual({ model: "claude-opus-5" }); }); }); diff --git a/src/tests/session-mode.test.ts b/src/tests/session-mode.test.ts index c3cc74d4..46b96f2a 100644 --- a/src/tests/session-mode.test.ts +++ b/src/tests/session-mode.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import type { SessionNotification } from "@agentclientprotocol/sdk"; -import { AUTO_MODE_FALLBACK, type SessionMode, SessionModeManager } from "../session-mode.js"; +import { + AUTO_MODE_FALLBACK, + resolveInitialPermissionMode, + type SessionMode, + SessionModeManager, +} from "../session-mode.js"; const SESSION_ID = "session-1"; @@ -236,3 +241,49 @@ describe("session mode", () => { expect(session.query.setPermissionMode).toHaveBeenCalledWith("acceptEdits"); }); }); + +describe("resolveInitialPermissionMode", () => { + const logger = () => ({ error: vi.fn() }); + + it("uses defaultMode when nothing else is given", () => { + expect(resolveInitialPermissionMode({ defaultMode: "plan" }, logger(), true)).toBe("plan"); + }); + + it("continues in the mode of the resumed transcript instead of defaultMode", () => { + expect( + resolveInitialPermissionMode( + { resumed: "bypassPermissions", defaultMode: "plan" }, + logger(), + true, + ), + ).toBe("bypassPermissions"); + }); + + it("lets an explicit mode win over the resumed transcript", () => { + expect( + resolveInitialPermissionMode( + { explicit: "acceptEdits", resumed: "bypassPermissions", defaultMode: "plan" }, + logger(), + true, + ), + ).toBe("acceptEdits"); + }); + + it("falls back to defaultMode when bypass is not available now", () => { + const log = logger(); + expect( + resolveInitialPermissionMode( + { resumed: "bypassPermissions", defaultMode: "plan" }, + log, + false, + ), + ).toBe("plan"); + expect(log.error).toHaveBeenCalledWith(expect.stringContaining('"bypassPermissions"')); + }); + + it("falls back to defaultMode for an unknown resumed mode", () => { + expect( + resolveInitialPermissionMode({ resumed: "warp", defaultMode: "acceptEdits" }, logger(), true), + ).toBe("acceptEdits"); + }); +}); diff --git a/src/usage-markdown.ts b/src/usage-markdown.ts index ef36d657..b3a4fdfa 100644 --- a/src/usage-markdown.ts +++ b/src/usage-markdown.ts @@ -80,7 +80,8 @@ function usageBar(percent: number): string { return `${"█".repeat(filled)}${"░".repeat(cells - filled)}`; } -function escapeMarkdown(value: string): string { +/** Escape the Markdown syntax characters of `value` and fold its line breaks. */ +export function escapeMarkdown(value: string): string { return value.replace(/([\\`*_[\]<>|])/g, "\\$1").replace(/[\r\n]+/g, " "); } diff --git a/src/utils.ts b/src/utils.ts index f444f7a5..5ba08dd8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -87,3 +87,34 @@ export function unreachable(value: never, logger: Logger = console) { export function sleep(time: number): Promise { return new Promise((resolve) => setTimeout(resolve, time)); } + +/** The outcome of {@link raceTimeoutAndAbort}. */ +export type RaceOutcome = { type: "done"; value: T } | { type: "timeout" } | { type: "aborted" }; + +/** Wait for `promise` until `timeoutMs` passes or `signal` aborts. A rejection + * of `promise` rejects the result. The timer does not keep the process alive. */ +export async function raceTimeoutAndAbort( + promise: Promise, + timeoutMs: number, + signal: AbortSignal, +): Promise> { + let timeout: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + return await Promise.race([ + promise.then((value): RaceOutcome => ({ type: "done", value })), + new Promise>((resolve) => { + timeout = setTimeout(() => resolve({ type: "timeout" }), timeoutMs); + timeout.unref?.(); + }), + new Promise>((resolve) => { + onAbort = () => resolve({ type: "aborted" }); + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + if (onAbort) signal.removeEventListener("abort", onAbort); + } +}