mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 11:51:14 +00:00
feat: serve ACP v2 with a direct v2 agent and remove the v1 bridge (#1560)
## Summary ACP v2 was served by a bridge (`src/v2/agent.ts`) that converted each v2 request to v1 and ran it through `ClaudeAcpAgent`. Every v2 session therefore ran the v1 core's turn machinery: held prompts, echo matching, trailing-idle debt and the hold for background subagents. v2 asks for none of it. This PR serves v2 with its own agent in `src/v2/`, which follows Claude Code's own events. A prompt is answered when Claude Code takes it in, and `state_update` reports foreground work. Background subagents and tasks are listed in `background` and do not hold the turn. - **New v2 agent (`src/v2/`).** `V2Agent` and `V2Session` cover: - the prompt lifecycle and cancellation - subagents as child sessions, following the subagents RFD, plus background lists - replay, restarts for provider and route changes, auth and providers - elicitation, config options, `/remote-control`, and the `--hide-claude-auth` guard - **Bridge removed.** `ClaudeAcpAgent` serves ACP v1 only: its `v2` option, foreground/background hooks and `this.v2` branches are gone. v1 behavior is unchanged, and no v1 test expectation or golden file changed. The control flow of `acp-agent.ts` is simpler and the file is about 450 lines shorter. - **Shared modules.** Protocol-neutral logic moved out of `acp-agent.ts` into modules both versions use, including: - `session-model.ts`, `session-effort.ts`, `session-config-options.ts` and `session-env.ts` - `providers.ts`, `auth-status-reporter.ts` and `context-window.ts` - `result-end.ts`: one set of rules for reading a result in both cores - `prompt-suggestions.ts` and `mcp-servers.ts` - **JetBrains AIR on v2.** An AIR v2 client gets the AIR extensions that ACP v2 does not cover: - `recommendedValue` - the permission presentation - question custom answers - prompt suggestions - `agentFileChangeReport`, sent at the end of the turn, before its idle ACP v2 itself covers subagent sessions, background subagents, async tasks, plan files, diff patches, compaction, custom instructions and session failures. See "ACP v2" in `docs/air-extensions.md`. - **Ported from main.** Main's v2 changes made on the bridge are ported to the new agent, and main's v2 tests run against it: - workflow cards and phases (#1550, #1552) - task tool calls (#1556) - plan removal on reset (#1554) - subagent checklist plan ids (#1558) - the Fable alias (#1548) ## Behavior changes for v2 clients Each one follows the v2 draft spec, where the bridge did not: - **Background subagents.** They no longer hold the prompt open. They are listed in `background`, and their work is reported in their child session's state. - **`requires_action`.** While the root waits on foreground subagents, it reports `requires_action` only when every one of them waits on the user. - **Resume of an active session.** Its work keeps running, and the new MCP servers replace the old ones (`setMcpServers`). The bridge tore the session down. - **Cancel.** The idle after a cancel waits for Claude Code's aborted result and carries its usage. A stopped child's open tool calls end before its idle. - **`/remote-control`.** It runs as its own turn. Sent while Claude works, it waits for that work to end. - **Accepted URL elicitation.** It keeps the work `requires_action` until Claude Code reports the elicitation complete. - **Exited Claude Code.** A session whose Claude Code process exited stays active and answers -32603 with what happened, until it is resumed. - **Provider change.** `providers/set`/`disable` restarts idle sessions before it answers. A busy session restarts at its next turn boundary, and its prompts are not held meanwhile, which the provider RFD allows. ## Workarounds - **`/remote-control` waits outside the prompt queue.** The adapter's own `/clear` and plan prompts, which continue a turn in a fresh context, use their own push queue (`ownPushes`). They must not wait behind a `/remote-control` that waits for that same turn to end. - **Restart on additional directories.** A resume with other additional directories restarts the query at its next turn boundary. The SDK cannot change the directories of a running query. ## Rollback `CLAUDE_AGENT_ACP_PROTOCOL=v1` negotiates every client down to v1. The bridge and its `CLAUDE_AGENT_ACP_V2_CORE` switch are removed. ## Not served on v2 yet - AIR's tool call keys (`commandTitle`, `subagent`, `skill`, `mcp`), the session index, and API retry warnings. - A `replayFrom` cursor other than `start`, which is rejected with -32602, and steering, which is v1 only. ## Packaging - `lib.js` exports are unchanged. - `acp-agent.js` exports no name fewer than before. It adds `AUTONOMOUS_RESULT_ORIGINS`, `getAvailableSlashCommands` and `isAbortedResult`, which the v2 agent uses, and the export pin test is updated. - Internal `dist/v2/*` modules changed. They are reachable only through the `"./*"` export and are not documented. ## Testing - `npm run format:check`, `lint`, `build` and `test:run` pass: 3935 passed, 33 skipped. - `npm run test:integration` against the real CLI passes: 3960 passed, 8 skipped. This includes `src/tests/v2-integration.test.ts`. - The v2 golden scenarios (`acp-scenarios-v2.test.ts`) match without being regenerated. - The binary builds and `scripts/smoke-binary.mjs` passes. - Two tests unchanged from main can time out under heavy load: the session-index file watcher test and v1's "lagged trailer" test. Both pass on rerun. ## Follow-up - Split `src/` into v1, v2 and shared modules. - Move the SDK-to-ACP mapping that `src/v2/` still imports from `acp-agent.ts` into a shared module. - Add a lint rule that keeps the boundary. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
13
AGENTS.md
13
AGENTS.md
@@ -58,11 +58,14 @@ opening a PR.
|
||||
- If a result is meant to match another request's output, reuse that request's
|
||||
logic.
|
||||
- The adapter serves ACP v1 and v2. A change that is not meant for one protocol
|
||||
version only must work correctly in both. `src/v2/` converts v2 requests to v1
|
||||
and reuses the v1 logic; keep v2-only differences in the conversion.
|
||||
- A session runs one turn at a time. "Claude is working" means `startTurn`'s
|
||||
check (an unsettled turn in `turnQueue`, or `foregroundWork` for the session);
|
||||
don't write a narrower one. Anything that sends `session/update` must go
|
||||
version only must work correctly in both. `ClaudeAcpAgent` serves v1, and
|
||||
`src/v2/` serves v2; they share modules (the SDK mapping, tool call
|
||||
rendering, permissions, config options). Put a change meant for both versions
|
||||
in the shared module, or make it in both cores.
|
||||
- A session runs one turn at a time. On v1, "Claude is working" means
|
||||
`startTurn`'s check (an unsettled turn in `turnQueue`, or `foregroundWork` for
|
||||
the session); on v2, an open turn of the v2 session. Don't write a
|
||||
narrower one. Anything that sends `session/update` must go
|
||||
through the turn queue or be refused while the session is busy, or its output
|
||||
lands inside another turn. Keep the session marked busy across awaited network
|
||||
calls.
|
||||
|
||||
@@ -142,7 +142,11 @@ On v2 the adapter serves the session baseline (`session/new`, `session/list`, `s
|
||||
with replay, `session/close`, `session/prompt`, `session/cancel`), `session/delete`,
|
||||
`session/fork`, additional directories, config options (the mode is one), plans, notices,
|
||||
compaction, display terminals for commands, permission requests, elicitation, providers, auth, and
|
||||
[subagents](#subagent-sessions). Steering and the AIR extensions are v1 only for now.
|
||||
[subagents](#subagent-sessions). Steering is v1 only for now.
|
||||
|
||||
v2 is served by the v2 agent in `src/v2/`, which runs straight on Claude
|
||||
Code instead of translating to v1. A v2 client that declares JetBrains AIR
|
||||
(`_meta.jetbrains.air`) also gets the AIR extensions that ACP v2 does not cover.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
|
||||
@@ -145,6 +145,15 @@ The agent list does not depend on the capabilities that AIR declares, except `se
|
||||
An extension is active only when the client declared its capability. The client does not declare `sessionBatchGet`, `sessionArchive`, `sessionRename`, `sessionListSubscribe` and `sessionWorktrees`.
|
||||
The response to a client that is not AIR has no `_meta.jetbrains` key.
|
||||
|
||||
### ACP v2
|
||||
|
||||
The v2 core (`src/v2/`) serves an AIR client only the extensions that ACP v2 does not cover.
|
||||
Its `initialize` response lists `agentFileChangeReport` and `recommendedValue`.
|
||||
It also serves the [permission presentation](#permission-presentation), the [question custom answers](#question-custom-answers), the [predicted follow-up prompt](#predicted-follow-up-prompt), and [load replay end](#load-replay-end).
|
||||
The [agent file-change report](#agent-file-change-report) goes out at the end of the turn that serves the prompt, before the turn's idle `state_update`, because a v2 `session/prompt` is answered when Claude Code takes the prompt in.
|
||||
ACP v2 covers subagents, background work, file plans, patches, compaction, the system prompt, and turn failures itself, so the v2 core serves no `nativeSubagentSessions`, `backgroundSubagents`, `asyncTasks`, `planFile`, `diffPatch`, `contextCompaction`, `customInstructions`, or `sessionFailure`.
|
||||
It does not serve the [tool call contract](#tool-call-contract) yet.
|
||||
|
||||
### Capabilities
|
||||
|
||||
| Capability | Advertised | What the adapter does when the client declares it | Section |
|
||||
@@ -1062,7 +1071,8 @@ The adapter ignores a malformed request and a repeated request id.
|
||||
|
||||
### Report
|
||||
|
||||
The adapter sends one `session_info_update` before the `PromptResponse`:
|
||||
The adapter sends one `session_info_update` before the `PromptResponse`.
|
||||
On ACP v2 it comes before the idle `state_update` of the turn that serves the prompt (see [ACP v2](#acp-v2)):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1638,7 +1648,8 @@ The hold protects a client that stops listening at the prompt response.
|
||||
### Behavior with the capability
|
||||
|
||||
AIR declares `backgroundSubagents` in `initialize.clientCapabilities._meta.jetbrains.air.capabilities`.
|
||||
The capability applies to ACP v1 only. ACP v2 keeps the hold, and the agent does not advertise the capability to ACP v2.
|
||||
The capability applies to ACP v1 only, and the agent does not advertise it to ACP v2.
|
||||
ACP v2 has no hold: the v2 agent answers `session/prompt` when Claude Code takes the prompt in, lists a background subagent in its parent session's `background` while it works, and reports each subagent's work in the state of its child session (see [ACP v2](#acp-v2)).
|
||||
|
||||
- The turn ends at its result with the stop reason of the result, usually `end_turn`.
|
||||
- The subagent updates, permission requests and elicitations go out after the prompt response.
|
||||
|
||||
3546
src/acp-agent.ts
3546
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
@@ -17,8 +17,7 @@ export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
|
||||
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
|
||||
* as revised): `subagent_update` and `session_message`, which the SDK types.
|
||||
* A client that declares `clientCapabilities.subagents` and is not AIR gets
|
||||
* it. (An ACP v2 client gets it without declaring anything, since subagents
|
||||
* are part of v2's baseline.)
|
||||
* it.
|
||||
* - `air`: the earlier draft of that RFD, which AIR implements:
|
||||
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
|
||||
* whichever capability it declares (`docs/air-extensions.md`).
|
||||
|
||||
88
src/auth-methods.ts
Normal file
88
src/auth-methods.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { AuthMethod } from "@agentclientprotocol/sdk/unstable";
|
||||
import { agentCommand } from "./agent-command.js";
|
||||
import { shouldHideClaudeAuth } from "./hide-claude-auth.js";
|
||||
|
||||
/**
|
||||
* The terminal login methods the agent advertises, in v1 form.
|
||||
*
|
||||
* - `terminal`: the client runs `args` against the agent's own command
|
||||
* (`auth.terminal` in v1, an `auth.terminal` object in v2).
|
||||
* - `metaTerminal`: the client reads `_meta["terminal-auth"]`, which names
|
||||
* the command too.
|
||||
*
|
||||
* Without either, the client cannot run a login, and the list is empty. On a
|
||||
* remote machine (SSH, `NO_BROWSER`, `CLAUDE_CODE_REMOTE`, as the SDK's own
|
||||
* isRemote check) the OAuth redirect to localhost cannot reach a browser, and
|
||||
* `claude auth login` falls back to a manual flow that does not work over ACP,
|
||||
* so the one method runs the TUI's `claude /login` instead. The Claude
|
||||
* subscription method is left out when `shouldHideClaudeAuth()` says so.
|
||||
*/
|
||||
export function terminalAuthMethods({
|
||||
terminal,
|
||||
metaTerminal,
|
||||
}: {
|
||||
terminal: boolean;
|
||||
metaTerminal: boolean;
|
||||
}): AuthMethod[] {
|
||||
if (!terminal && !metaTerminal) return [];
|
||||
const withMeta = (method: AuthMethod & { args: string[] }, label: string): AuthMethod => {
|
||||
if (!metaTerminal) return method;
|
||||
const agent = agentCommand();
|
||||
return {
|
||||
...method,
|
||||
_meta: {
|
||||
"terminal-auth": { command: agent.command, args: [...agent.args, ...method.args], label },
|
||||
},
|
||||
};
|
||||
};
|
||||
const isRemote = !!(
|
||||
process.env.NO_BROWSER ||
|
||||
process.env.SSH_CONNECTION ||
|
||||
process.env.SSH_CLIENT ||
|
||||
process.env.SSH_TTY ||
|
||||
process.env.CLAUDE_CODE_REMOTE
|
||||
);
|
||||
if (isRemote) {
|
||||
if (shouldHideClaudeAuth()) return [];
|
||||
return [
|
||||
withMeta(
|
||||
{
|
||||
description: "Run `claude /login` in the terminal",
|
||||
name: "Log in with Claude",
|
||||
id: "claude-login",
|
||||
type: "terminal",
|
||||
args: ["--cli"],
|
||||
},
|
||||
"Claude Login",
|
||||
),
|
||||
];
|
||||
}
|
||||
const methods: AuthMethod[] = [];
|
||||
if (!shouldHideClaudeAuth()) {
|
||||
methods.push(
|
||||
withMeta(
|
||||
{
|
||||
description: "Use Claude subscription ",
|
||||
name: "Claude Subscription",
|
||||
id: "claude-ai-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--claudeai"],
|
||||
},
|
||||
"Claude Login",
|
||||
),
|
||||
);
|
||||
}
|
||||
methods.push(
|
||||
withMeta(
|
||||
{
|
||||
description: "Use Anthropic Console (API usage billing)",
|
||||
name: "Anthropic Console",
|
||||
id: "console-login",
|
||||
type: "terminal",
|
||||
args: ["--cli", "auth", "login", "--console"],
|
||||
},
|
||||
"Anthropic Console Login",
|
||||
),
|
||||
);
|
||||
return methods;
|
||||
}
|
||||
309
src/auth-status-reporter.ts
Normal file
309
src/auth-status-reporter.ts
Normal file
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* The connection's `authStatus` state, shared by the v1 core and the v2
|
||||
* core: the last identity it reported, the `claude auth status --json` probe,
|
||||
* and the `_auth/status_update` pushes (see `auth-status.ts` for when each
|
||||
* core reads the identity).
|
||||
*/
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
type AccountInfo,
|
||||
filterEscalatingDefaultMode,
|
||||
type Options,
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
type AuthStatus,
|
||||
type AuthStatusKind,
|
||||
fromAccountInfo,
|
||||
fromCliStatus,
|
||||
mergeAuthStatus,
|
||||
sameAuthStatus,
|
||||
} from "./auth-status.js";
|
||||
import { claudeCliPath } from "./claude-cli.js";
|
||||
import type { SettingsManager } from "./settings.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
type ReporterLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
|
||||
/** The settings a probe runs under: a session's cwd, its key helper, and its launch env. */
|
||||
export type AuthStatusSettings = Pick<SettingsManager, "getCwd" | "getApiKeyHelper">;
|
||||
|
||||
export interface AuthStatusReporterOptions {
|
||||
/** Sends `_auth/status_update`. */
|
||||
push: (status: AuthStatus) => Promise<void>;
|
||||
logger: ReporterLogger;
|
||||
/** An ACP gateway login is active: the gateway, not the CLI store, is the identity. */
|
||||
gatewayLogin: () => boolean;
|
||||
/** A `providers/set` override is active: a session account describes its route. */
|
||||
providerOverride: () => boolean;
|
||||
/** A completed probe read `kind`. */
|
||||
onProbe?: (kind: AuthStatusKind) => void;
|
||||
}
|
||||
|
||||
export class AuthStatusReporter {
|
||||
/** Last identity reported to the client. Undefined means "not determined yet". */
|
||||
current?: AuthStatus;
|
||||
/**
|
||||
* Counts auth-affecting events (a login, a logout, a new settings context).
|
||||
* A probe records it at start and publishes only if it has not moved, so a
|
||||
* slow read can never overwrite a newer login or logout.
|
||||
*/
|
||||
epoch = 0;
|
||||
private settings?: {
|
||||
manager: AuthStatusSettings;
|
||||
override?: Settings;
|
||||
env?: Options["env"];
|
||||
};
|
||||
private helper?: string;
|
||||
/** In-flight `claude auth status --json` probe, shared by every caller so
|
||||
* a concurrent `initialize` and start-of-prompt read never spawn two CLI
|
||||
* processes. */
|
||||
private probing: Promise<AuthStatus | undefined> | null = null;
|
||||
/** Keeps the "session account told us nothing" note to one line per process
|
||||
* instead of one per session. */
|
||||
private loggedUninformativeAccount = false;
|
||||
/** Same, for the "client provider override active" note. */
|
||||
private loggedOverriddenAccount = false;
|
||||
/** Same, for the "the CLI probe timed out" warning: a wedged CLI stays wedged
|
||||
* and would otherwise warn once per probe, i.e. once per user prompt. */
|
||||
private loggedProbeTimeout = false;
|
||||
|
||||
constructor(private readonly options: AuthStatusReporterOptions) {}
|
||||
|
||||
/**
|
||||
* Stores `next` and pushes it to the client.
|
||||
*
|
||||
* A push goes out only when the payload changed. The identity is read on many
|
||||
* occasions — each session create, each guarded turn, the start of each user
|
||||
* prompt — and almost all of them see the same login. Clients replace their
|
||||
* whole state on each update and tolerate duplicates, so a repeat is
|
||||
* harmless, but it is also pure noise; {@link sameAuthStatus} drops it.
|
||||
*
|
||||
* What the agent owes is truth — a stale or uninformative source must not
|
||||
* reach here at all (see the epoch check in the probe and the guards at
|
||||
* session create).
|
||||
*/
|
||||
set(next: AuthStatus | undefined): void {
|
||||
if (!next) {
|
||||
return;
|
||||
}
|
||||
if (sameAuthStatus(this.current, next)) {
|
||||
return;
|
||||
}
|
||||
this.current = next;
|
||||
// ACP notifications get no reply, and clients that do not know the method
|
||||
// drop it silently — send unconditionally and never fail a caller on it.
|
||||
void Promise.resolve()
|
||||
.then(() => this.options.push(next))
|
||||
.catch((error) => {
|
||||
this.options.logger.error("Failed to send _auth/status_update:", error);
|
||||
});
|
||||
}
|
||||
|
||||
/** A login or a logout: any probe already running read the world before it. */
|
||||
changed(): void {
|
||||
this.epoch += 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes run under these settings from now on. A new context (cwd, key
|
||||
* helper, or launch env) starts a fresh probe in it.
|
||||
*/
|
||||
useSettings(
|
||||
manager: AuthStatusSettings,
|
||||
settingsOverride?: Settings,
|
||||
launchEnv?: Options["env"],
|
||||
) {
|
||||
const helper = manager.getApiKeyHelper(settingsOverride);
|
||||
const changed =
|
||||
manager.getCwd() !== (this.settings?.manager.getCwd() ?? process.cwd()) ||
|
||||
helper !== this.helper ||
|
||||
JSON.stringify(launchEnv) !== JSON.stringify(this.settings?.env);
|
||||
this.settings = { manager, override: settingsOverride, env: launchEnv };
|
||||
this.helper = helper;
|
||||
if (changed) {
|
||||
// The epoch bump discards any probe that read the old context, and the
|
||||
// session account may carry no identity signal (an apiKeyHelper), so
|
||||
// only a probe in the new context can classify it.
|
||||
this.epoch += 1;
|
||||
void this.probe({ fresh: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Report the identity behind a session's `AccountInfo`.
|
||||
*
|
||||
* `AccountInfo` is richer than the CLI probe (it is what the live query
|
||||
* actually authenticates with). The v1 core calls it before the
|
||||
* `--hide-claude-auth` guard may refuse the session or the turn: that flag
|
||||
* blocks the *use* of a subscription, it does not make the state secret, and
|
||||
* a refusal is when the client most needs to know the account it was
|
||||
* refused for.
|
||||
*
|
||||
* Two cases skip it:
|
||||
*
|
||||
* - ACP gateway *authentication*: the gateway, not this account, is the
|
||||
* agent-owned identity — already reported as `kind: "gateway"` by the login.
|
||||
* - A client-driven provider override (`providers/set`): the session then
|
||||
* routes through the client's endpoint and `AccountInfo` describes that
|
||||
* route (e.g. `apiProvider: "gateway"`), which is NOT agent-owned state.
|
||||
* `authStatus` reports the agent's own login only, so the CLI probe —
|
||||
* which reads the credential store the override never touches — stays
|
||||
* authoritative.
|
||||
*
|
||||
* An account with no identity signal (e.g. `{apiProvider: "firstParty"}`
|
||||
* under an apiKeyHelper) means "nothing to add", not "logged out" — keep
|
||||
* what the CLI probe already established instead of overwriting it.
|
||||
*/
|
||||
publishSessionAccount(account: AccountInfo | undefined, apiKeyHelper?: string): void {
|
||||
if (this.options.providerOverride()) {
|
||||
if (!this.loggedOverriddenAccount) {
|
||||
this.loggedOverriddenAccount = true;
|
||||
this.options.logger.log(
|
||||
"[authStatus] client provider override active; keeping agent-owned probe state",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (this.options.gatewayLogin()) {
|
||||
return;
|
||||
}
|
||||
const fromSession = fromAccountInfo(account, apiKeyHelper);
|
||||
if (fromSession) {
|
||||
this.set(fromSession);
|
||||
} else if (!this.loggedUninformativeAccount) {
|
||||
this.loggedUninformativeAccount = true;
|
||||
this.options.logger.log(
|
||||
"[authStatus] session account carries no identity signal; keeping probe",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Shares one in-flight `claude auth status --json` run between callers. The
|
||||
* promise is released once settled so a later call re-probes instead of
|
||||
* replaying a stale verdict. `fresh` forces a new run even when one is in
|
||||
* flight — `logout` needs a read that started after the credentials were
|
||||
* cleared. */
|
||||
probe(options?: { fresh?: boolean }): Promise<AuthStatus | undefined> {
|
||||
if (!options?.fresh && this.probing) {
|
||||
return this.probing;
|
||||
}
|
||||
const probe = this.runProbe();
|
||||
this.probing = probe;
|
||||
void probe.finally(() => {
|
||||
if (this.probing === probe) {
|
||||
this.probing = null;
|
||||
}
|
||||
});
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** Never rejects: an unavailable CLI means "not reported", not an error. */
|
||||
private async runProbe(): Promise<AuthStatus | undefined> {
|
||||
const { logger } = this.options;
|
||||
// Monotonicity: a read that started before the connection's latest
|
||||
// auth-affecting event describes a world that no longer exists. Remember
|
||||
// which one this read belongs to and drop the answer if it moved on.
|
||||
const epoch = this.epoch;
|
||||
const context = this.settings;
|
||||
const apiKeyHelper = context?.manager.getApiKeyHelper(context.override);
|
||||
// ACP gateway auth bypasses the CLI credential store entirely, so the
|
||||
// probe would report an identity that is not the one being used. A mere
|
||||
// client provider override (`providers/set`) does NOT skip the probe: it
|
||||
// reroutes traffic without touching the credential store, and the store is
|
||||
// exactly the agent-owned login `authStatus` reports.
|
||||
if (this.options.gatewayLogin()) {
|
||||
return this.current;
|
||||
}
|
||||
let stdout: string;
|
||||
try {
|
||||
const cliPath = await claudeCliPath();
|
||||
const args = ["auth", "status", "--json"];
|
||||
// `--settings` is a global CLI option: after the `auth status` subcommand the CLI rejects
|
||||
// it with "unknown option '--settings'", so it has to come first.
|
||||
if (context?.override) args.unshift("--settings", JSON.stringify(context.override));
|
||||
({ stdout } = await execFileAsync(cliPath, args, {
|
||||
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
cwd: context?.manager.getCwd() ?? process.cwd(),
|
||||
env: context?.env ? { ...process.env, ...context.env } : process.env,
|
||||
}));
|
||||
} catch (error) {
|
||||
const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null;
|
||||
// Node killed the child on the timeout. Whatever it printed so far is a
|
||||
// truncated fragment, never valid JSON: report nothing and keep the last
|
||||
// known state, so nothing regresses and no push goes out.
|
||||
if (failed?.killed === true && failed.signal) {
|
||||
if (!this.loggedProbeTimeout) {
|
||||
this.loggedProbeTimeout = true;
|
||||
logger.error(
|
||||
"claude auth status did not answer within 5 s; the identity is not refreshed",
|
||||
);
|
||||
}
|
||||
return this.current;
|
||||
}
|
||||
// The logged-out case exits 1 while still printing valid JSON, so the
|
||||
// stdout of a failed exec is parsed just like a successful one.
|
||||
if (typeof failed?.stdout !== "string" || failed.stdout.trim().length === 0) {
|
||||
logger.error(
|
||||
"claude auth status failed:",
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
stdout = failed.stdout;
|
||||
}
|
||||
let status = fromCliStatus(stdout, apiKeyHelper);
|
||||
if (!context && status?.kind === "api_key" && status.detail === "API key helper") {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: process.cwd() });
|
||||
status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper);
|
||||
} catch {
|
||||
logger.error("Could not resolve auth source settings");
|
||||
}
|
||||
}
|
||||
if (!status) {
|
||||
logger.error("claude auth status returned unparseable output");
|
||||
return undefined;
|
||||
}
|
||||
if (epoch !== this.epoch) {
|
||||
// A login or logout landed while this probe was running; the newer
|
||||
// state wins and this answer is discarded, never published.
|
||||
logger.log("[authStatus] discarding a probe that predates the latest auth change");
|
||||
return this.current;
|
||||
}
|
||||
// The CLI probe can be poorer than the session `AccountInfo` for the very
|
||||
// same login (no organization, say). Keep those extra fields rather than
|
||||
// regressing the payload; a different identity replaces it wholesale.
|
||||
const merged = mergeAuthStatus(this.current, status);
|
||||
this.set(merged);
|
||||
this.options.onProbe?.(status.kind);
|
||||
return merged;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `claude auth logout`. The Claude and Console logins keep their
|
||||
* credentials in the CLI's store (keychain or config dir), which only the
|
||||
* binary can clear. The command is non-interactive and idempotent. Throws an
|
||||
* `internal_error` with the CLI's stderr when it fails.
|
||||
*/
|
||||
export async function cliLogout(): Promise<void> {
|
||||
try {
|
||||
const cliPath = await claudeCliPath();
|
||||
await execFileAsync(cliPath, ["auth", "logout"]);
|
||||
} catch (error) {
|
||||
const stderr =
|
||||
typeof error === "object" && error && "stderr" in error
|
||||
? String((error as { stderr: unknown }).stderr).trim()
|
||||
: undefined;
|
||||
throw RequestError.internalError(
|
||||
{ stderr: stderr || undefined },
|
||||
`claude auth logout failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,173 +1,25 @@
|
||||
/**
|
||||
* The background work of a session: the work that Claude Code keeps running
|
||||
* independently of the session's foreground work, which ACP v2 lists on every
|
||||
* `state_update` of the session as `background` (the v2 prompt lifecycle,
|
||||
* "Background work").
|
||||
*
|
||||
* The lists are built from Claude Code's live background task set
|
||||
* (`background_tasks_changed`). Claude Code marks `ambient` its own
|
||||
* housekeeping and every live-update watcher; those are left out, except a
|
||||
* task that a tool call started, such as a `Monitor` call's. The root session and each
|
||||
* announced subagent session (the subagents RFD form) has its own list. Each
|
||||
* task is listed once, in the list of the session that runs it:
|
||||
*
|
||||
* - A command that went to the background: its display terminal, from the
|
||||
* Bash tool result until its task ends (`Session.backgroundTerminals`).
|
||||
* - A detached subagent: its child session, in its parent's list, while it
|
||||
* works, and while it is idle with background work of its own.
|
||||
* - A task that a tool call started, such as a `Monitor`, a `Workflow` or a
|
||||
* backgrounded MCP call: that tool call, kept `in_progress` (or `paused`)
|
||||
* from its tool result until its task ends (`Session.backgroundToolCalls`).
|
||||
* - Any other task, once its `task_started` came and the tool call that
|
||||
* started it, if any, returned: a tool call that the adapter starts for
|
||||
* it (see `SessionBackgroundWork.adoptTasks`). For example, a task that a
|
||||
* hook started, or one whose tool call ended before the task joined the
|
||||
* level.
|
||||
*
|
||||
* Only a session whose interrupt spares background tasks reports its lists
|
||||
* (`Session.reportsBackground`): `session/cancel` must leave listed work
|
||||
* running.
|
||||
* The commands of a session that went to the background, for a client that
|
||||
* renders terminals from the terminal extension and is not AIR (AIR shows a
|
||||
* command through its async tasks): the display terminal of each command
|
||||
* runs from its Bash tool result until its task ends.
|
||||
*/
|
||||
import { RequestError, type ToolCallUpdate } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
import type { NativeSubagent } from "./native-subagents.js";
|
||||
import { type AcpToolCallRenderer, V2_CANCELLED, V2_PAUSED } from "./tool-calls/renderer.js";
|
||||
import { type BackgroundOutput, BackgroundOutputTail } from "./background-output.js";
|
||||
import type { TaskProgressFields } from "./async-tasks.js";
|
||||
import { type WorkflowSnapshot, workflowSnapshotText } from "./workflow-progress.js";
|
||||
import { textContent } from "./tool-calls/content.js";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
export type BackgroundItem =
|
||||
| { type: "terminal"; terminalId: string }
|
||||
| { type: "tool_call"; toolCallId: string }
|
||||
| { type: "subagent"; sessionId: string };
|
||||
|
||||
/** The display terminal of a command that went to the background (see `Session.backgroundTerminals`). */
|
||||
/** The display terminal of a command that went to the background. */
|
||||
export interface BackgroundTerminal {
|
||||
/** The Bash tool call, whose id names the terminal. */
|
||||
toolCallId: string;
|
||||
/** The subagent tool call that ran the command, if any. */
|
||||
parentToolUseId?: string;
|
||||
/** The file that Claude Code writes the command's output to, from the Bash result. */
|
||||
outputFilePath?: string;
|
||||
/** Reads that file into the terminal while the command runs. */
|
||||
output?: BackgroundOutputTail;
|
||||
}
|
||||
|
||||
/** A tool call whose task runs on in the background (see `Session.backgroundToolCalls`). */
|
||||
export interface BackgroundToolCall {
|
||||
toolCallId: string;
|
||||
/**
|
||||
* The tool use of the call, which Claude Code no longer caches after its
|
||||
* result. Undefined for a call that the adapter started for a task (see
|
||||
* `SessionBackgroundWork.adoptTasks`), which has `title` instead.
|
||||
*/
|
||||
toolUse?: { name: string; input: unknown };
|
||||
/** The title of a call that the adapter started for a task: the task's description. */
|
||||
title?: string;
|
||||
/** The subagent tool call that made the call, if any. */
|
||||
parentToolUseId?: string;
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
/** How a background task ended, as Claude Code reports it. Undefined when it did not say. */
|
||||
export type BackgroundTaskEnd = "completed" | "failed" | "stopped" | undefined;
|
||||
|
||||
/** The live background task set of `background_tasks_changed`, by task id. */
|
||||
export type BackgroundLevel = Map<
|
||||
string,
|
||||
{
|
||||
ambient: boolean;
|
||||
taskType: string;
|
||||
description: string;
|
||||
parentTaskId?: string;
|
||||
progress?: WorkflowCardProgress;
|
||||
}
|
||||
>;
|
||||
|
||||
/** What a workflow card shows: the task's progress fields and its latest phase and agent list. */
|
||||
export type WorkflowCardProgress = TaskProgressFields & { workflow?: WorkflowSnapshot };
|
||||
|
||||
/** The work state of a session, as its `state_update` names it. */
|
||||
export type WorkStateName = "idle" | "running" | "requires_action" | "unknown";
|
||||
|
||||
/** A new list of the session `sessionId`, with what its repeated state needs. */
|
||||
export interface BackgroundReport {
|
||||
sessionId: string;
|
||||
items: BackgroundItem[];
|
||||
/** The session's work state now, or undefined when it has none yet. */
|
||||
state: WorkStateName | undefined;
|
||||
/** The parent of a subagent session, which mirrors the child's state. */
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
/** Where the agent reports the lists (see the agent's `background` option). */
|
||||
export interface BackgroundSink {
|
||||
/** The list of a session changed, or is reported for the first time. */
|
||||
changed(report: BackgroundReport): void;
|
||||
/** The session ended: its list is no longer reported. */
|
||||
forget(sessionId: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The state of a session that other modules own, and that the background
|
||||
* work reads: Claude Code's tasks as the consumer tracks them, the subagent
|
||||
* sessions, and the query that stops a task.
|
||||
*/
|
||||
export interface BackgroundOwner {
|
||||
liveBackgroundTasks: Map<string, { parentToolUseId?: string; isSubagent: boolean }>;
|
||||
nativeSubagentsByTaskId?: Map<string, NativeSubagent>;
|
||||
query: { stopTask(taskId: string): Promise<void> };
|
||||
}
|
||||
|
||||
/** A task of a `background_tasks_changed` payload. */
|
||||
export type LevelTask = {
|
||||
task_id: string;
|
||||
task_type: string;
|
||||
description: string;
|
||||
parent_task_id?: string;
|
||||
ambient?: boolean;
|
||||
};
|
||||
|
||||
/** The most returned tool calls that {@link SessionBackgroundWork} remembers. */
|
||||
const MAX_RETURNED_CALLS = 1024;
|
||||
|
||||
/** The tools whose structured result names the task that the call started. */
|
||||
const TASK_TOOLS = new Set(["Monitor", "Workflow"]);
|
||||
|
||||
/**
|
||||
* The background work of one root session, and of its subagent sessions: the
|
||||
* terminals and tool calls of the tasks that run in the background, and what
|
||||
* the client is told of them.
|
||||
*
|
||||
* Terminals are kept for every client that renders them, as before the
|
||||
* background lists. The rest is kept only when `reported`.
|
||||
*/
|
||||
/** The terminals of the commands that one root session, and its subagents, run in the background. */
|
||||
export class SessionBackgroundWork {
|
||||
/** Claude Code's live background task set, from its last `background_tasks_changed`. */
|
||||
private level: BackgroundLevel = new Map();
|
||||
/** The terminals of the commands that went to the background, by task id. */
|
||||
private readonly terminals = new Map<string, BackgroundTerminal>();
|
||||
/** The tool calls whose tasks went on in the background, by task id. */
|
||||
private readonly toolCalls = new Map<string, BackgroundToolCall>();
|
||||
/**
|
||||
* The tool calls that returned, oldest first. A task that a call started
|
||||
* is part of that call until it returns, and the foreground work waits on
|
||||
* it. The task's `task_started` can come after the call returned. Bounded
|
||||
* by {@link MAX_RETURNED_CALLS}: a task joins the level around the time its
|
||||
* call returns.
|
||||
*/
|
||||
private readonly returned = new Set<string>();
|
||||
|
||||
/**
|
||||
* `reported`: whether the client gets the lists. Only when Claude Code's
|
||||
* interrupt spares background tasks, as `perTaskStopAffordance` makes it.
|
||||
*/
|
||||
constructor(
|
||||
private readonly sessionId: string,
|
||||
readonly reported: boolean,
|
||||
) {}
|
||||
constructor(private readonly sessionId: string) {}
|
||||
|
||||
/**
|
||||
* Keeps the display terminal of the command `taskId` running after its Bash
|
||||
@@ -178,446 +30,19 @@ export class SessionBackgroundWork {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads what a tool result message tells of the background tasks:
|
||||
*
|
||||
* - The returned calls no longer hold the tasks that they started.
|
||||
* - Each call whose task goes on in the background is registered: a
|
||||
* `Monitor` or `Workflow` call, whose structured result names its task,
|
||||
* or a call that a live task names as its tool use, such as a
|
||||
* backgrounded MCP call. The task must be in the level: one that ended
|
||||
* already leaves its call as it is. Claude Code marks every live-update
|
||||
* watcher `ambient`, including the task of a `Monitor` call; a call
|
||||
* started it, so it is work the client can see and stop, and is
|
||||
* registered all the same. Ambient tasks without a call of their own,
|
||||
* such as watchers Claude Code starts by itself, are never registered.
|
||||
*
|
||||
* Returns the ids of the registered calls, whose result then reports
|
||||
* `in_progress` (see `withRunningToolCall`).
|
||||
* The task `taskId` ended: exits its command's terminal, with no exit code.
|
||||
* Claude Code reports no exit code for a command.
|
||||
*/
|
||||
toolResults(
|
||||
owner: BackgroundOwner,
|
||||
content: unknown,
|
||||
toolUseResult: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown }>,
|
||||
parentToolUseId: string | null,
|
||||
): Set<string> {
|
||||
const registered = new Set<string>();
|
||||
if (!this.reported || !Array.isArray(content)) return registered;
|
||||
const results = content.filter(
|
||||
(block): block is { type: "tool_result"; tool_use_id: string; is_error?: boolean } =>
|
||||
typeof block === "object" &&
|
||||
block !== null &&
|
||||
block.type === "tool_result" &&
|
||||
typeof block.tool_use_id === "string",
|
||||
);
|
||||
for (const { tool_use_id: toolCallId } of results) {
|
||||
this.returned.add(toolCallId);
|
||||
if (this.returned.size > MAX_RETURNED_CALLS) {
|
||||
this.returned.delete(this.returned.values().next().value!);
|
||||
}
|
||||
}
|
||||
const succeeded = results.filter((block) => block.is_error !== true);
|
||||
const structuredTaskId =
|
||||
succeeded.length === 1 &&
|
||||
typeof toolUseResult === "object" &&
|
||||
toolUseResult !== null &&
|
||||
typeof (toolUseResult as { taskId?: unknown }).taskId === "string"
|
||||
? (toolUseResult as { taskId: string }).taskId
|
||||
: undefined;
|
||||
for (const { tool_use_id: toolCallId } of succeeded) {
|
||||
const toolUse = toolUses[toolCallId];
|
||||
if (!toolUse) continue;
|
||||
const taskId =
|
||||
(TASK_TOOLS.has(toolUse.name) ? structuredTaskId : undefined) ??
|
||||
[...owner.liveBackgroundTasks].find(
|
||||
([, task]) => task.parentToolUseId === toolCallId && !task.isSubagent,
|
||||
)?.[0];
|
||||
const task = taskId === undefined ? undefined : this.level.get(taskId);
|
||||
if (!task || task.taskType === "local_bash" || task.taskType === "local_agent") {
|
||||
continue;
|
||||
}
|
||||
this.toolCalls.set(taskId!, {
|
||||
toolCallId,
|
||||
toolUse,
|
||||
...(parentToolUseId ? { parentToolUseId } : {}),
|
||||
});
|
||||
registered.add(toolCallId);
|
||||
}
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts reading the output of the background command `taskId` into its
|
||||
* terminal, when the client gets the lists and the Bash result named the
|
||||
* command's output file. `send` sends an update of the terminal's tool
|
||||
* call, routed as the call's other updates. Called once the Bash result's
|
||||
* own updates are sent: the first output replaces the placeholder that they
|
||||
* gave the terminal.
|
||||
*/
|
||||
followOutput(taskId: string, send: Send): void {
|
||||
async taskEnded(taskId: string, send: Send): Promise<void> {
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (!this.reported || !terminal?.outputFilePath || terminal.output) return;
|
||||
terminal.output = new BackgroundOutputTail(terminal.outputFilePath, (output) =>
|
||||
send(terminalOutputUpdate(this.sessionId, terminal, output)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The task `taskId` ended. Reads the rest of its command's output, then
|
||||
* exits its terminal, and gives a tool call whose task it was its final
|
||||
* status: `cancelled` for a task that Claude Code stopped, and `completed`
|
||||
* for one that ended without saying how, such as one that the level
|
||||
* dropped: it no longer runs, and reported no failure. Claude Code reports
|
||||
* no exit code for a command, and `completed` does not mean 0: Claude Code
|
||||
* accepts some non-zero codes as a success, such as grep's 1.
|
||||
*/
|
||||
async taskEnded(taskId: string, end: BackgroundTaskEnd, send: Send): Promise<void> {
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (terminal) {
|
||||
await terminal.output?.finish();
|
||||
this.terminals.delete(taskId);
|
||||
await send(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (toolCall) {
|
||||
this.toolCalls.delete(taskId);
|
||||
await send(
|
||||
toolCallUpdate(this.sessionId, toolCall, {
|
||||
status: end === "stopped" ? V2_CANCELLED : end === "failed" ? "failed" : "completed",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The update that reports the background tool call of the task `taskId` as
|
||||
* `paused`, or as `in_progress` again, when `paused` changes it. Claude Code
|
||||
* pauses a workflow this way.
|
||||
*/
|
||||
taskPaused(taskId: string, paused: boolean): AcpSessionNotification | undefined {
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (!toolCall || (toolCall.paused ?? false) === paused) return undefined;
|
||||
toolCall.paused = paused;
|
||||
return toolCallUpdate(this.sessionId, toolCall, { status: paused ? V2_PAUSED : "in_progress" });
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates an existing workflow card without changing its work state. A
|
||||
* report without `workflow` keeps the last phase and agent list.
|
||||
*/
|
||||
taskProgress(taskId: string, progress: WorkflowCardProgress): AcpSessionNotification | undefined {
|
||||
const task = this.level.get(taskId);
|
||||
if (!task || task.taskType !== "local_workflow") return undefined;
|
||||
const previous = workflowProgress(task.progress);
|
||||
task.progress = { ...task.progress, ...progress };
|
||||
const current = workflowProgress(task.progress);
|
||||
if (!current || JSON.stringify(previous) === JSON.stringify(current)) return undefined;
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
return toolCall ? toolCallUpdate(this.sessionId, toolCall, current) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The latest progress of the workflow calls in `toolCallIds`.
|
||||
*
|
||||
* A task can report progress before its launch tool returns. Those fields
|
||||
* wait in the live task level and are sent after the launch result.
|
||||
*/
|
||||
toolCallProgress(toolCallIds: ReadonlySet<string>): AcpSessionNotification[] {
|
||||
return [...this.toolCalls].flatMap(([taskId, toolCall]) => {
|
||||
if (!toolCallIds.has(toolCall.toolCallId)) return [];
|
||||
const progress = workflowProgress(this.level.get(taskId)?.progress);
|
||||
return progress ? [toolCallUpdate(this.sessionId, toolCall, progress)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes Claude Code's new live background task set. The terminals and tool
|
||||
* calls whose tasks it no longer holds end first, before a list drops them,
|
||||
* as v2 requires. A terminal or tool call is registered at its tool result,
|
||||
* after its task joined the level, so a later level without the task means
|
||||
* that it ended. Claude Code usually sends a task's end before that level,
|
||||
* which ends them first, but the SDK does not promise that order.
|
||||
*/
|
||||
async levelChanged(tasks: LevelTask[], send: Send): Promise<void> {
|
||||
if (!this.reported) return;
|
||||
const level: BackgroundLevel = new Map(
|
||||
tasks.map((task) => [
|
||||
task.task_id,
|
||||
{
|
||||
ambient: task.ambient === true,
|
||||
taskType: task.task_type,
|
||||
description: task.description,
|
||||
...(task.parent_task_id !== undefined ? { parentTaskId: task.parent_task_id } : {}),
|
||||
progress: this.level.get(task.task_id)?.progress,
|
||||
},
|
||||
]),
|
||||
);
|
||||
for (const taskId of [...this.terminals.keys(), ...this.toolCalls.keys()]) {
|
||||
if (!level.has(taskId)) await this.taskEnded(taskId, undefined, send);
|
||||
}
|
||||
this.level = level;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query closed: Claude Code's background tasks end with its process.
|
||||
* Stops reading output, and returns the updates that exit the root
|
||||
* session's terminals and cancel its tool calls. The level empties, so the
|
||||
* next lists are empty. A subagent's command or tool call is left as it
|
||||
* was: it is on the child's stream, which the closed query's routing owned.
|
||||
*/
|
||||
queryClosed(): AcpSessionNotification[] {
|
||||
for (const terminal of this.terminals.values()) terminal.output?.stop();
|
||||
if (!this.reported) return [];
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
for (const [taskId, terminal] of this.terminals) {
|
||||
if (terminal.parentToolUseId) continue;
|
||||
this.terminals.delete(taskId);
|
||||
updates.push(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
for (const [taskId, toolCall] of this.toolCalls) {
|
||||
if (toolCall.parentToolUseId) continue;
|
||||
this.toolCalls.delete(taskId);
|
||||
updates.push(toolCallUpdate(this.sessionId, toolCall, { status: V2_CANCELLED }));
|
||||
}
|
||||
this.level = new Map();
|
||||
this.returned.clear();
|
||||
return updates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a tool call for each live task that no terminal or tool call
|
||||
* stands for yet, and returns the `tool_call`s to send: v2 lists background work only by an entity
|
||||
* the client knows. Such a task is one that no tool call started, such as
|
||||
* one a hook started, or one whose tool call returned, with its final
|
||||
* status, before the task started. The call has the task's description as
|
||||
* its title, is `in_progress`, and ends with the task (see `taskEnded`). A
|
||||
* task that an announced subagent session runs gets its call on that
|
||||
* session's stream. Ambient tasks are housekeeping and get no call.
|
||||
*
|
||||
* The agent sends them before each list it publishes, so the call comes
|
||||
* before the first list that names it, as v2 requires.
|
||||
*/
|
||||
adoptTasks(owner: BackgroundOwner): AcpSessionNotification[] {
|
||||
if (!this.reported) return [];
|
||||
const started: AcpSessionNotification[] = [];
|
||||
const children = new Map(
|
||||
[...(owner.nativeSubagentsByTaskId ?? [])].filter(([, child]) => child.announced),
|
||||
);
|
||||
for (const [taskId, task] of this.level) {
|
||||
if (task.ambient || this.toolCalls.has(taskId) || this.terminals.has(taskId)) continue;
|
||||
// A command without a terminal yet is one that foreground work waits on.
|
||||
// A subagent is listed as its session.
|
||||
if (task.taskType === "local_bash" || task.taskType === "local_agent") continue;
|
||||
// A task joins the level before its `task_started`, and before the tool
|
||||
// call that starts it returns. Until both, the task is unknown or part
|
||||
// of that tool call.
|
||||
const live = owner.liveBackgroundTasks.get(taskId);
|
||||
if (!live || (live.parentToolUseId && !this.returned.has(live.parentToolUseId))) continue;
|
||||
const parent = task.parentTaskId ? children.get(task.parentTaskId) : undefined;
|
||||
const toolCall: BackgroundToolCall = {
|
||||
toolCallId: adoptedToolCallId(taskId),
|
||||
title: task.description,
|
||||
...(parent?.parentToolUseId ? { parentToolUseId: parent.parentToolUseId } : {}),
|
||||
};
|
||||
this.toolCalls.set(taskId, toolCall);
|
||||
started.push(adoptedToolCall(this.sessionId, toolCall));
|
||||
}
|
||||
return started;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `background` list of the root session and of each of its announced
|
||||
* subagent sessions, with what their repeated states need, deeper sessions
|
||||
* first and the root last: a child's own list goes out before its parent's
|
||||
* list drops it. Each list is empty while the level has not been reported:
|
||||
* Claude Code sends none before the first background task starts.
|
||||
*/
|
||||
reports(owner: BackgroundOwner, rootState: WorkStateName): BackgroundReport[] {
|
||||
const children = [...(owner.nativeSubagentsByTaskId ?? [])].filter(
|
||||
([, child]) => child.announced,
|
||||
);
|
||||
const childByToolUse = new Map(
|
||||
children.flatMap(([, child]) =>
|
||||
child.parentToolUseId ? [[child.parentToolUseId, child] as const] : [],
|
||||
),
|
||||
);
|
||||
const lists = new Map<string, BackgroundItem[]>([
|
||||
[this.sessionId, []],
|
||||
...children.map(([, child]): [string, BackgroundItem[]] => [child.sessionId, []]),
|
||||
]);
|
||||
// A subagent's command or tool call runs on the child's stream.
|
||||
const ownerOf = (parentToolUseId: string | undefined) =>
|
||||
parentToolUseId ? childByToolUse.get(parentToolUseId)?.sessionId : this.sessionId;
|
||||
for (const [taskId, task] of this.level) {
|
||||
// A registered call's task is listed even when ambient, as a `Monitor`
|
||||
// call's is (see `toolResults`).
|
||||
const toolCall = this.toolCalls.get(taskId);
|
||||
if (toolCall) {
|
||||
const session = ownerOf(toolCall.parentToolUseId);
|
||||
if (session)
|
||||
lists.get(session)!.push({ type: "tool_call", toolCallId: toolCall.toolCallId });
|
||||
continue;
|
||||
}
|
||||
if (task.ambient) continue;
|
||||
const terminal = this.terminals.get(taskId);
|
||||
if (terminal) {
|
||||
const session = ownerOf(terminal.parentToolUseId);
|
||||
if (session)
|
||||
lists.get(session)!.push({ type: "terminal", terminalId: terminal.toolCallId });
|
||||
}
|
||||
// Any other task is listed by the tool call that `adoptTasks` starts
|
||||
// for it, or is not background work. A subagent is listed below, as
|
||||
// its session.
|
||||
}
|
||||
// A child is listed in its parent's list while it works in the background,
|
||||
// and while it is idle with background work of its own. Deeper children
|
||||
// first, so a child's list is whole before its parent's reads it.
|
||||
const sessions = new Map(children.map(([, child]) => [child.sessionId, child]));
|
||||
const depth = (child: NativeSubagent): number => {
|
||||
const parent = sessions.get(child.parentSessionId);
|
||||
return parent ? depth(parent) + 1 : 0;
|
||||
};
|
||||
const ordered = children.sort(([, a], [, b]) => depth(b) - depth(a));
|
||||
for (const [taskId, child] of ordered) {
|
||||
const task = this.level.get(taskId);
|
||||
const working =
|
||||
child.workState !== undefined &&
|
||||
child.workState !== "idle" &&
|
||||
task !== undefined &&
|
||||
!task.ambient;
|
||||
if (working || lists.get(child.sessionId)!.length > 0) {
|
||||
lists.get(child.parentSessionId)?.push({ type: "subagent", sessionId: child.sessionId });
|
||||
}
|
||||
}
|
||||
return [
|
||||
...ordered.map(([, child]) => ({
|
||||
sessionId: child.sessionId,
|
||||
items: lists.get(child.sessionId)!,
|
||||
state: workStateName(child.workState),
|
||||
parentSessionId: child.parentSessionId,
|
||||
})),
|
||||
{ sessionId: this.sessionId, items: lists.get(this.sessionId)!, state: rootState },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* The current state of each background tool call in `toolCallIds`: the
|
||||
* call as `renderer` shows its tool use, with its status now (see
|
||||
* `ClaudeAcpAgent.publishBackgroundToolCalls`).
|
||||
*/
|
||||
toolCallStates(
|
||||
toolCallIds: string[],
|
||||
renderer: AcpToolCallRenderer,
|
||||
cwd: string,
|
||||
): AcpSessionNotification[] {
|
||||
return [...this.toolCalls]
|
||||
.filter(([, toolCall]) => toolCallIds.includes(toolCall.toolCallId))
|
||||
.map(([taskId, toolCall]) => {
|
||||
const update = toolCall.toolUse
|
||||
? renderer.toolCall({ id: toolCall.toolCallId, ...toolCall.toolUse }, { cwd })
|
||||
: adoptedToolCall(this.sessionId, toolCall).update;
|
||||
const status = toolCall.paused ? V2_PAUSED : "in_progress";
|
||||
return {
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
...update,
|
||||
...workflowProgress(this.level.get(taskId)?.progress),
|
||||
status,
|
||||
} as AcpSessionNotification["update"],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The task of the terminal or tool call `target` that the session
|
||||
* `sessionId` lists, or undefined when it lists no such item.
|
||||
*/
|
||||
listedTask(
|
||||
owner: BackgroundOwner,
|
||||
sessionId: string,
|
||||
target: BackgroundTarget,
|
||||
): string | undefined {
|
||||
if (!this.reported) return undefined;
|
||||
const listed = this.reports(owner, "idle")
|
||||
.find((report) => report.sessionId === sessionId)
|
||||
?.items.some((item) =>
|
||||
"terminalId" in target
|
||||
? item.type === "terminal" && item.terminalId === target.terminalId
|
||||
: item.type === "tool_call" && item.toolCallId === target.toolCallId,
|
||||
);
|
||||
if (!listed) return undefined;
|
||||
const id = "terminalId" in target ? target.terminalId : target.toolCallId;
|
||||
const entries = "terminalId" in target ? this.terminals : this.toolCalls;
|
||||
return [...entries].find(([, entry]) => entry.toolCallId === id)?.[0];
|
||||
if (!terminal) return;
|
||||
this.terminals.delete(taskId);
|
||||
await send(terminalExit(this.sessionId, terminal));
|
||||
}
|
||||
}
|
||||
|
||||
type Send = (notification: AcpSessionNotification) => Promise<void>;
|
||||
|
||||
/** A terminal or tool call that `terminal/cancel` or `tool_call/cancel` names. */
|
||||
export type BackgroundTarget = { terminalId: string } | { toolCallId: string };
|
||||
|
||||
/**
|
||||
* Stops the task of a terminal or tool call that the session `sessionId`
|
||||
* lists (v2 `terminal/cancel` and `tool_call/cancel`). `sessions` are the
|
||||
* root sessions: a subagent session's list is built with its root's. The
|
||||
* terminal exits, or the tool call gets its final status, when Claude Code
|
||||
* reports the task stopped.
|
||||
*/
|
||||
export async function cancelBackgroundWork(
|
||||
sessions: Array<BackgroundOwner & { background?: SessionBackgroundWork }>,
|
||||
sessionId: string,
|
||||
target: BackgroundTarget,
|
||||
): Promise<void> {
|
||||
const notCancellable = () =>
|
||||
"terminalId" in target
|
||||
? RequestError.invalidParams(
|
||||
{ sessionId, terminalId: target.terminalId },
|
||||
`Terminal ${target.terminalId} is not a command running in the background, so it cannot be cancelled.`,
|
||||
)
|
||||
: RequestError.invalidParams(
|
||||
{ sessionId, toolCallId: target.toolCallId },
|
||||
`Tool call ${target.toolCallId} is not running in the background, so it cannot be cancelled.`,
|
||||
);
|
||||
for (const session of sessions) {
|
||||
const taskId = session.background?.listedTask(session, sessionId, target);
|
||||
if (taskId === undefined) continue;
|
||||
try {
|
||||
await session.query.stopTask(taskId);
|
||||
} catch (error) {
|
||||
// The task ended meanwhile: it has ended on the client too, or is about to.
|
||||
if (session.background?.listedTask(session, sessionId, target) === undefined) {
|
||||
throw notCancellable();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw notCancellable();
|
||||
}
|
||||
|
||||
/** The update that gives the terminal of `terminal` the text `output`. */
|
||||
function terminalOutputUpdate(
|
||||
sessionId: string,
|
||||
terminal: BackgroundTerminal,
|
||||
output: BackgroundOutput,
|
||||
): AcpSessionNotification {
|
||||
const data = { terminal_id: terminal.toolCallId, data: output.text };
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: terminal.toolCallId,
|
||||
_meta: {
|
||||
...(terminal.parentToolUseId
|
||||
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
|
||||
: {}),
|
||||
...(output.replaces ? { terminal_output: data } : { terminal_output_delta: data }),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The update that exits the terminal of `terminal`, with no exit code. */
|
||||
function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessionNotification {
|
||||
return {
|
||||
@@ -634,122 +59,3 @@ function terminalExit(sessionId: string, terminal: BackgroundTerminal): AcpSessi
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toolCallUpdate(
|
||||
sessionId: string,
|
||||
toolCall: BackgroundToolCall,
|
||||
fields: Omit<ToolCallUpdate, "toolCallId">,
|
||||
): AcpSessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
...fields,
|
||||
...(toolCall.parentToolUseId
|
||||
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
|
||||
: {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the tool call that the adapter starts for the task `taskId`.
|
||||
* Claude Code's tool use ids start with `toolu_`, so it names no other call.
|
||||
*/
|
||||
function adoptedToolCallId(taskId: string): string {
|
||||
return `task_${taskId}`;
|
||||
}
|
||||
|
||||
/** The `tool_call` that the adapter starts for a task (see `SessionBackgroundWork.adoptTasks`). */
|
||||
function adoptedToolCall(sessionId: string, toolCall: BackgroundToolCall): AcpSessionNotification {
|
||||
return {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: toolCall.toolCallId,
|
||||
title: toolCall.title ?? "Background task",
|
||||
kind: "other",
|
||||
status: toolCall.paused ? V2_PAUSED : "in_progress",
|
||||
...(toolCall.parentToolUseId
|
||||
? { _meta: { claudeCode: { parentToolUseId: toolCall.parentToolUseId } } }
|
||||
: {}),
|
||||
} as AcpSessionNotification["update"],
|
||||
};
|
||||
}
|
||||
|
||||
/** The latest workflow activity, rendered as one replaceable tool-card body. */
|
||||
function workflowProgress(progress: WorkflowCardProgress | undefined) {
|
||||
if (!progress || Object.keys(progress).length === 0) return undefined;
|
||||
const activity = progress.description ?? progress.summary;
|
||||
const usage = progress.usage;
|
||||
const text = [
|
||||
progress.summary ?? progress.description,
|
||||
progress.lastToolName ? `Last activity: ${progress.lastToolName}` : undefined,
|
||||
usage
|
||||
? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s`
|
||||
: undefined,
|
||||
progress.workflow ? workflowSnapshotText(progress.workflow) : undefined,
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join("\n\n");
|
||||
return {
|
||||
title: activity ? `Workflow: ${activity}` : "Workflow",
|
||||
content: [textContent(text)],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes the lists of each session that reports them, each time one
|
||||
* changes. The sink sends them to the client synchronously, so a list follows
|
||||
* the updates sent before it.
|
||||
*/
|
||||
export class BackgroundWorkReporter {
|
||||
/** The list last published for each session, as JSON, and the root session it is built with. */
|
||||
private readonly published = new Map<string, { json: string; root: string }>();
|
||||
|
||||
constructor(private readonly sink?: BackgroundSink) {}
|
||||
|
||||
/** Whether the lists are reported at all. */
|
||||
get enabled(): boolean {
|
||||
return this.sink !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publishes each list of the root session `rootSessionId` that changed. The
|
||||
* first call for a session publishes its list, also when it is empty.
|
||||
* `rootState` is the root session's state now.
|
||||
*/
|
||||
sync(
|
||||
rootSessionId: string,
|
||||
session: BackgroundOwner & { background?: SessionBackgroundWork },
|
||||
rootState: WorkStateName,
|
||||
): void {
|
||||
if (!this.sink || !session.background?.reported) return;
|
||||
for (const report of session.background.reports(session, rootState)) {
|
||||
const json = JSON.stringify(report.items);
|
||||
if (this.published.get(report.sessionId)?.json === json) continue;
|
||||
this.published.set(report.sessionId, { json, root: rootSessionId });
|
||||
this.sink.changed(report);
|
||||
}
|
||||
}
|
||||
|
||||
/** The root session `rootSessionId` ended, and its subagent sessions with it. */
|
||||
forget(rootSessionId: string): void {
|
||||
for (const [sessionId, { root }] of this.published) {
|
||||
if (root !== rootSessionId) continue;
|
||||
this.published.delete(sessionId);
|
||||
this.sink?.forget(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A child's work state, when it is one that v2 names. */
|
||||
function workStateName(state: string | undefined): WorkStateName | undefined {
|
||||
return state === "idle" ||
|
||||
state === "running" ||
|
||||
state === "requires_action" ||
|
||||
state === "unknown"
|
||||
? state
|
||||
: undefined;
|
||||
}
|
||||
|
||||
@@ -72,3 +72,30 @@ export function sdkSystemPrompt(
|
||||
? { type: "preset", preset: "claude_code", append: text }
|
||||
: { type: "preset", preset: "claude_code" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK `systemPrompt` of a session: the spec's `systemPrompt`
|
||||
* (`clientPrompt`), else the `_meta.systemPrompt` extension (`metaPrompt`), a
|
||||
* string that replaces the preset or an object of preset options, else the
|
||||
* Claude Code preset. `customInstructions` are appended to the preset.
|
||||
*/
|
||||
export function sessionSystemPrompt(
|
||||
clientPrompt: ClientSystemPrompt | undefined,
|
||||
metaPrompt: unknown,
|
||||
customInstructions?: string,
|
||||
): NonNullable<Options["systemPrompt"]> {
|
||||
if (clientPrompt) return sdkSystemPrompt(clientPrompt, customInstructions);
|
||||
if (typeof metaPrompt === "string" && metaPrompt) return metaPrompt;
|
||||
if (typeof metaPrompt === "object" && metaPrompt !== null && !Array.isArray(metaPrompt)) {
|
||||
// Forward all preset options (append, excludeDynamicSections, and
|
||||
// anything the SDK adds later) while locking type/preset.
|
||||
return {
|
||||
...(metaPrompt as object),
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
} as NonNullable<Options["systemPrompt"]>;
|
||||
}
|
||||
return customInstructions === undefined
|
||||
? { type: "preset", preset: "claude_code" }
|
||||
: { type: "preset", preset: "claude_code", append: customInstructions };
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
ContextCompactionTrigger,
|
||||
createContextCompactionMeta,
|
||||
} from "./context-compaction-meta.js";
|
||||
import { assistantMessageText } from "./session-failure-extension.js";
|
||||
import { compactionToolCall } from "./tool-calls/reporters/compaction.js";
|
||||
|
||||
type CompactionStatus = "completed" | "failed";
|
||||
@@ -132,6 +133,36 @@ export function isCompactSummaryMessage(message: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the compact summary message `message` (see
|
||||
* {@link isCompactSummaryMessage}) of the root conversation as one completed
|
||||
* `compaction_update` at its transcript position, keyed by the message's
|
||||
* uuid. Returns false, and sends nothing, for any other message.
|
||||
*
|
||||
* The boundary's own system record comes back from `getSessionMessages`
|
||||
* without its metadata, so the summary message is the one durable marker.
|
||||
* Its uuid differs from the live id (the `compacting` status uuid): replay
|
||||
* is terminal-first by the RFD, and no client merges a replay into a live
|
||||
* entity store. A summary whose framing is unrecognized is still this
|
||||
* compaction, without summary text.
|
||||
*/
|
||||
export async function replayCompactSummary(
|
||||
message: { uuid?: unknown; message?: unknown },
|
||||
sendUpdate: SendUpdate,
|
||||
options: { sessionId: string; airClient?: boolean },
|
||||
): Promise<boolean> {
|
||||
if (!isCompactSummaryMessage(message) || typeof message.uuid !== "string" || !message.uuid) {
|
||||
return false;
|
||||
}
|
||||
const replayed = new ContextCompactionLifecycle(sendUpdate, {
|
||||
...options,
|
||||
presentation: "compaction_update",
|
||||
});
|
||||
replayed.recordSummary(assistantMessageText(message.message));
|
||||
await replayed.finish(message.uuid, "completed");
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Translates Claude's compaction signals into one idempotent ACP lifecycle.
|
||||
*
|
||||
|
||||
166
src/context-window.ts
Normal file
166
src/context-window.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* The context window of a session's model, as both the v1 core and the
|
||||
* v2 core report it in `usage_update.size`: seeded without IPC when the
|
||||
* session starts or switches models, refined by a background
|
||||
* `getContextUsage`, and learned from each result's `modelUsage`.
|
||||
*/
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { DEFAULT_CONTEXT_WINDOW, inferContextWindowFromModel } from "./session-usage.js";
|
||||
|
||||
/** Cross-session cache of authoritative context windows, keyed by
|
||||
* `${providerCacheKey}\0${modelId}` (see {@link contextWindowCacheKey}).
|
||||
* The window is a property of (model id, backend): the same resolved model id
|
||||
* (e.g. "claude-sonnet-5[1m]", the spelling of the `result.modelUsage` keys)
|
||||
* can name different context lanes behind different base URLs, routing
|
||||
* headers, or credentials, so the key carries both. Caching it module-level
|
||||
* lets a later session/new or switch that resolves to the same (backend,
|
||||
* model) — in this session or any other, within the adapter's lifetime — seed
|
||||
* the correct window synchronously with no IPC. Keying on the resolved id
|
||||
* (rather than the picker value) means aliases that resolve to the same
|
||||
* concrete model share one entry; the result handler additionally writes the
|
||||
* bare assistant-message spelling so seed-time reads that fall back to a
|
||||
* verbatim live id (rows without `resolvedModel`) can hit too.
|
||||
*
|
||||
* Populated authoritatively by each `result.modelUsage` a turn confirms (see
|
||||
* the consumer's result handler). We deliberately never populate it from
|
||||
* `getContextUsage`: its `model` spelling and `rawMaxTokens` can differ from
|
||||
* the `result.modelUsage` key and window (e.g. 967000 vs 1000000 for sonnet),
|
||||
* so it only refines the live session (`refreshContextWindowInBackground`).
|
||||
* Cleared on `logout`: 1M-context entitlement can differ per account/tier, so
|
||||
* windows learned under one login must not seed sessions under the next. */
|
||||
const contextWindowCache = new Map<string, number>();
|
||||
|
||||
/** The env vars that determine which LLM backend — and which context lane on
|
||||
* it — a query's API traffic reaches: endpoint selection (base URLs and the
|
||||
* Bedrock/Vertex switches with their project/region), routing/beta headers
|
||||
* (an `anthropic-beta: context-1m-…` header flips the same model id at the
|
||||
* same endpoint between context lanes), and credential identity (extended
|
||||
* context is entitlement-gated per account). Used to derive the
|
||||
* provider-cache key from the exact env a query is created with, so
|
||||
* `providers/set` config, per-session `_meta` env overrides, and ambient
|
||||
* process env are all distinguished exactly as the CLI will see them. */
|
||||
const PROVIDER_ROUTING_ENV_VARS = [
|
||||
"ANTHROPIC_BASE_URL",
|
||||
"ANTHROPIC_BEDROCK_BASE_URL",
|
||||
"ANTHROPIC_VERTEX_BASE_URL",
|
||||
"CLAUDE_CODE_USE_BEDROCK",
|
||||
"CLAUDE_CODE_USE_VERTEX",
|
||||
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||
"CLOUD_ML_REGION",
|
||||
"AWS_REGION",
|
||||
"ANTHROPIC_CUSTOM_HEADERS",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
] as const;
|
||||
|
||||
/** Stable identifier for the LLM backend a session's query is created against,
|
||||
* used to scope {@link contextWindowCache} per backend. Positional `\0`-join
|
||||
* of {@link PROVIDER_ROUTING_ENV_VARS} values, so no segment can masquerade
|
||||
* as another and unset vars everywhere yield one stable "default" bucket.
|
||||
* Header/credential values can be secrets; the key only ever lives as an
|
||||
* in-memory Map key and is never logged or surfaced. Over-keying is the safe
|
||||
* side: a var change that didn't really change the backend costs one cache
|
||||
* miss (heuristic seed until the next result), while under-keying would serve
|
||||
* one backend's window for another's. */
|
||||
export function providerCacheKeyFor(env: Record<string, string | undefined>): string {
|
||||
return PROVIDER_ROUTING_ENV_VARS.map((name) => env[name] ?? "").join("\0");
|
||||
}
|
||||
|
||||
/** Compose the `contextWindowCache` key from a session's provider key and a
|
||||
* model id. `\0`-joined so the model segment can't collide with a provider
|
||||
* segment. */
|
||||
export function contextWindowCacheKey(providerCacheKey: string, modelId: string): string {
|
||||
return `${providerCacheKey}\0${modelId}`;
|
||||
}
|
||||
|
||||
function cacheContextWindow(modelKey: string, window: number): void {
|
||||
if (window > 0) {
|
||||
contextWindowCache.set(modelKey, window);
|
||||
}
|
||||
}
|
||||
|
||||
/** Caches the window that a result's `modelUsage` reported, keyed on (the
|
||||
* session's provider, the resolved model id — the `modelUsage` key, e.g.
|
||||
* "claude-sonnet-5[1m]") so a later session/new or switch on the same
|
||||
* provider that resolves to this model seeds the correct window
|
||||
* synchronously, with no getContextUsage IPC.
|
||||
*
|
||||
* Also caches it under the assistant message's own (bare) spelling. Seed-time
|
||||
* reads fall back to a picker value / verbatim live id when a row carries no
|
||||
* resolvedModel (the synthesized out-of-allowlist resume row sets it
|
||||
* undefined on purpose), and those spellings match `.model` from the
|
||||
* assistant message, not the decorated modelUsage key — without this entry
|
||||
* such rows could never hit the cache. A non-positive window is not cached. */
|
||||
export function cacheResultContextWindow(
|
||||
providerCacheKey: string,
|
||||
modelUsageKey: string,
|
||||
assistantModel: string | null | undefined,
|
||||
window: number,
|
||||
): void {
|
||||
cacheContextWindow(contextWindowCacheKey(providerCacheKey, modelUsageKey), window);
|
||||
if (assistantModel && assistantModel !== modelUsageKey) {
|
||||
cacheContextWindow(contextWindowCacheKey(providerCacheKey, assistantModel), window);
|
||||
}
|
||||
}
|
||||
|
||||
/** The context window to report *right now* for a model, with NO IPC on the
|
||||
* critical path: the cached authoritative value if we've learned it (from a
|
||||
* prior turn's `result.modelUsage`, this or any session on the same backend),
|
||||
* else the text heuristic over the model row's identity strings, else the
|
||||
* default. Derives the cache key itself — `modelInfo?.resolvedModel ?? modelId`,
|
||||
* the same rule at every seed site — so read keys can't drift from the write
|
||||
* site's spelling. `authoritative` reports whether the value came from the
|
||||
* cache: an authoritative window can legitimately equal
|
||||
* DEFAULT_CONTEXT_WINDOW, so the value alone can't tell the caller. */
|
||||
export function immediateContextWindow(
|
||||
providerCacheKey: string,
|
||||
modelId: string,
|
||||
modelInfo?: Pick<ModelInfo, "resolvedModel" | "displayName" | "description">,
|
||||
): { size: number; authoritative: boolean } {
|
||||
const cached = contextWindowCache.get(
|
||||
contextWindowCacheKey(providerCacheKey, modelInfo?.resolvedModel ?? modelId),
|
||||
);
|
||||
if (cached !== undefined) return { size: cached, authoritative: true };
|
||||
return {
|
||||
size:
|
||||
inferContextWindowFromModel(
|
||||
modelId,
|
||||
modelInfo?.resolvedModel,
|
||||
modelInfo?.displayName,
|
||||
modelInfo?.description,
|
||||
) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
authoritative: false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Forgets the learned windows, as a logout asks: 1M-context entitlement is
|
||||
* gated per org/tier, and an OAuth re-login is invisible to the env-derived
|
||||
* provider cache key, so windows learned under the old login must not seed
|
||||
* sessions under the next. */
|
||||
export function clearContextWindowCache(): void {
|
||||
contextWindowCache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the window of the query's current model with
|
||||
* `getContextUsage().rawMaxTokens`. Undefined when Claude Code reports none.
|
||||
*
|
||||
* Requests the `summary` detail instead of the default `full` breakdown,
|
||||
* which issues one `messages/count_tokens` call per category (~18 for a bare
|
||||
* session) and hit rate limits on every model switch; `summary` answers
|
||||
* locally with the same `rawMaxTokens`. Callers never await it on the setup
|
||||
* or switch path: SDK control requests are serialized, so awaiting would
|
||||
* delay session/new or a model switch (~15s on older CLIs, issues
|
||||
* #886/#880; ~0.5s on 2.1.283).
|
||||
*
|
||||
* The result does not go into the cross-session cache: its `rawMaxTokens` can
|
||||
* differ from the `result.modelUsage` window (e.g. 967000 vs 1000000 for
|
||||
* sonnet), so it only refines the live session.
|
||||
*/
|
||||
export async function readContextWindow(
|
||||
query: Pick<Query, "getContextUsage">,
|
||||
): Promise<number | undefined> {
|
||||
const usage = await query.getContextUsage({ detail: "summary" });
|
||||
return usage.rawMaxTokens > 0 ? usage.rawMaxTokens : undefined;
|
||||
}
|
||||
@@ -717,7 +717,7 @@ function airPatchContent(filePath: string, { change, hunks }: FilePatch): ToolCa
|
||||
*
|
||||
* The agent builds tool call content in v1 types, and v1 cannot express a v2
|
||||
* diff, so the diff travels as v1 content. Nothing in the agent reads inside a
|
||||
* diff, and only a v2 client gets one: the v2 surface sends it as it is
|
||||
* diff, and only a v2 client gets one: the v2 core sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export function v2DiffContent(
|
||||
|
||||
@@ -6,7 +6,13 @@ import type {
|
||||
ElicitationSchema,
|
||||
EnumOption,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ElicitationRequest, ElicitationResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
ElicitationRequest,
|
||||
ElicitationResult,
|
||||
PermissionResult,
|
||||
UserDialogRequest,
|
||||
UserDialogResult,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type {
|
||||
AskUserQuestionInput,
|
||||
AskUserQuestionOutput,
|
||||
@@ -512,3 +518,156 @@ export function refusalFallbackResultFromResponse(response: CreateElicitationRes
|
||||
const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
|
||||
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
|
||||
}
|
||||
|
||||
/**
|
||||
* What became of an elicitation: the client's response, or `withdrawn` when
|
||||
* its signal aborted first (Claude Code withdrew the request, or a cancel or
|
||||
* close of the session did). A response that comes after the abort counts as
|
||||
* withdrawn too.
|
||||
*/
|
||||
export type ElicitationOutcome = { response: CreateElicitationResponse } | { withdrawn: true };
|
||||
|
||||
/**
|
||||
* Sends an elicitation with `send` and reads what became of it. A rejection
|
||||
* while `signal` is not aborted, such as the client's -32602 for a form it
|
||||
* cannot render, is rethrown.
|
||||
*/
|
||||
export async function elicitUnlessWithdrawn(
|
||||
signal: AbortSignal,
|
||||
send: (signal: AbortSignal) => Promise<CreateElicitationResponse>,
|
||||
): Promise<ElicitationOutcome> {
|
||||
try {
|
||||
const response = await send(signal);
|
||||
return signal.aborted ? { withdrawn: true } : { response };
|
||||
} catch (error) {
|
||||
if (signal.aborted) return { withdrawn: true };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends a built elicitation to the client (see {@link elicitUnlessWithdrawn}). */
|
||||
export type Elicit = (request: CreateElicitationRequest) => Promise<ElicitationOutcome>;
|
||||
|
||||
type ElicitationLogger = { error: (...args: unknown[]) => void };
|
||||
|
||||
/**
|
||||
* Answers an MCP server's elicitation (the SDK's `onElicitation`) through
|
||||
* `elicit`. Only the user's own decline reaches the MCP server as `decline`.
|
||||
* A request the user never saw, because the client did not advertise its
|
||||
* mode, it cannot be represented, the client answered with an error, or it
|
||||
* was withdrawn, is a `cancel`.
|
||||
*/
|
||||
export async function answerMcpElicitation(
|
||||
request: ElicitationRequest,
|
||||
{
|
||||
sessionId,
|
||||
support,
|
||||
elicit,
|
||||
logger,
|
||||
}: { sessionId: string; support: ElicitationSupport; elicit: Elicit; logger: ElicitationLogger },
|
||||
): Promise<ElicitationResult> {
|
||||
const isUrl = request.mode === "url";
|
||||
if ((isUrl && !support.url) || (!isUrl && !support.form)) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
const createRequest = mcpElicitationToCreateRequest(request, sessionId);
|
||||
if (!createRequest) {
|
||||
return { action: "cancel" };
|
||||
}
|
||||
try {
|
||||
const outcome = await elicit(createRequest);
|
||||
if ("withdrawn" in outcome) return { action: "cancel" };
|
||||
return createElicitationResponseToElicitResult(outcome.response);
|
||||
} catch (error) {
|
||||
// The client answered with an error, e.g. -32602 for a form it cannot
|
||||
// render, so the user never saw the request. MCP's `decline` means the
|
||||
// user refused; `cancel` means they made no explicit choice.
|
||||
logger.error(`Failed to forward MCP elicitation: ${error}`);
|
||||
return { action: "cancel" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Presents the AskUserQuestion tool's questions as a form elicitation of
|
||||
* `sessionId` through `elicit`, and returns the answers as the tool's
|
||||
* `updatedInput`. A withdrawn question, or one the user cancelled, aborts the
|
||||
* tool use. `reportUnanswered` is read once the answer is in (see
|
||||
* {@link applyAskElicitationResponse}).
|
||||
*/
|
||||
export async function answerAskUserQuestion(
|
||||
toolInput: Record<string, unknown>,
|
||||
{
|
||||
sessionId,
|
||||
toolUseID,
|
||||
airClient = false,
|
||||
reportUnanswered,
|
||||
elicit,
|
||||
logger,
|
||||
}: {
|
||||
sessionId: string;
|
||||
toolUseID: string;
|
||||
airClient?: boolean;
|
||||
reportUnanswered: () => boolean;
|
||||
elicit: Elicit;
|
||||
logger: ElicitationLogger;
|
||||
},
|
||||
): Promise<PermissionResult> {
|
||||
const questions = extractAskUserQuestions(toolInput);
|
||||
if (!questions) {
|
||||
return { behavior: "deny", message: "AskUserQuestion called with no valid questions." };
|
||||
}
|
||||
const createRequest = askUserQuestionsToCreateRequest(questions, sessionId, toolUseID, airClient);
|
||||
let outcome: ElicitationOutcome;
|
||||
try {
|
||||
outcome = await elicit(createRequest);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to present AskUserQuestion elicitation: ${error}`);
|
||||
return { behavior: "deny", message: "Could not present the question to the user." };
|
||||
}
|
||||
if ("withdrawn" in outcome) {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
const answer = applyAskElicitationResponse(outcome.response, toolInput, questions, {
|
||||
reportUnanswered: reportUnanswered(),
|
||||
});
|
||||
if (answer.action === "cancel") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
return { behavior: "allow", updatedInput: answer.updatedInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* Answers a `request_user_dialog` of Claude Code (the SDK's `onUserDialog`).
|
||||
* Only kinds declared in `supportedDialogKinds` are ever emitted; everything
|
||||
* unexpected is answered `cancelled` (the required answer for unrecognized
|
||||
* kinds), which applies the dialog's default behavior CLI-side. The only
|
||||
* declared kind is the refusal-fallback consent prompt, rendered as a form
|
||||
* elicitation. A failed or withdrawn elicitation is `cancelled` too: the CLI
|
||||
* then keeps the refusal rather than switching models without consent.
|
||||
*/
|
||||
export async function answerUserDialog(
|
||||
request: UserDialogRequest,
|
||||
{ sessionId, elicit, logger }: { sessionId: string; elicit: Elicit; logger: ElicitationLogger },
|
||||
): Promise<UserDialogResult> {
|
||||
if (request.dialogKind !== REFUSAL_FALLBACK_DIALOG_KIND) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
const prompt = extractRefusalFallbackPrompt(request.payload);
|
||||
if (!prompt) {
|
||||
logger.error(
|
||||
`refusal_fallback_prompt payload had an unexpected shape; cancelling the dialog: ${JSON.stringify(request.payload)}`,
|
||||
);
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
let outcome: ElicitationOutcome;
|
||||
try {
|
||||
outcome = await elicit(refusalFallbackToCreateRequest(prompt, sessionId));
|
||||
} catch (error) {
|
||||
logger.error(`Failed to present refusal fallback elicitation: ${error}`);
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
if ("withdrawn" in outcome) {
|
||||
return { behavior: "cancelled" };
|
||||
}
|
||||
return { behavior: "completed", result: refusalFallbackResultFromResponse(outcome.response) };
|
||||
}
|
||||
|
||||
@@ -104,11 +104,12 @@ const FILE_EDIT_TOOLS = new Set(["Edit", "Write", "MultiEdit", "NotebookEdit"]);
|
||||
* Adds to `turn.editedPaths` the `file_path` (`notebook_path` for NotebookEdit)
|
||||
* of each successful file-tool edit among the tool results of a subagent
|
||||
* message. Root-agent messages (`parentToolUseId === null`) are skipped: the
|
||||
* checkpoint already tracks their files. Must run before `toAcpNotifications`
|
||||
* drops the tool use cache entries.
|
||||
* checkpoint already tracks their files. A turn that v1 marked `settled`
|
||||
* records nothing. Must run before `toAcpNotifications` drops the tool use
|
||||
* cache entries.
|
||||
*/
|
||||
export function recordSubagentFileEdits(
|
||||
turn: (NativeFileChangeReportTurn & { settled: boolean }) | null | undefined,
|
||||
turn: (NativeFileChangeReportTurn & { settled?: boolean }) | null | undefined,
|
||||
parentToolUseId: string | null,
|
||||
content: unknown,
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
|
||||
214
src/mcp-oauth.ts
Normal file
214
src/mcp-oauth.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* OAuth for the MCP servers that Claude Code reports as `needs-auth`, as an
|
||||
* ACP URL elicitation. Protocol-neutral: the v1 core and the v2 core
|
||||
* both start it once a session's setup response is sent.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { McpServerStatus, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Claude Code keeps the OAuth callback listener open in the background after
|
||||
* `mcpAuthenticate` returns the authorization URL. The SDK does not expose
|
||||
* that listener's completion promise, so watch the server status while the
|
||||
* ACP client has the URL elicitation open. */
|
||||
const MCP_OAUTH_STATUS_POLL_MS = 1_000;
|
||||
const MCP_OAUTH_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** Runtime MCP OAuth control exposed by the pinned Agent SDK. It is not yet in
|
||||
* the public `Query` declaration, even though the method is present on the
|
||||
* SDK query object and backed by Claude Code's `mcp_authenticate` control. */
|
||||
type McpOAuthQuery = Query & {
|
||||
mcpAuthenticate(
|
||||
serverName: string,
|
||||
redirectUri?: string,
|
||||
): Promise<{
|
||||
authUrl?: string;
|
||||
requiresUserAction: boolean;
|
||||
callbackExpected: boolean;
|
||||
redirectScheme?: "localhost" | "custom";
|
||||
callbackPort?: number;
|
||||
state?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function supportsMcpOAuth(query: Query): query is McpOAuthQuery {
|
||||
return typeof (query as Partial<McpOAuthQuery>).mcpAuthenticate === "function";
|
||||
}
|
||||
|
||||
/** What an OAuth run needs of the client connection. */
|
||||
export type McpOAuthClient = {
|
||||
/** Whether the client advertised URL elicitation. Without it, OAuth does not start. */
|
||||
readonly urlElicitation: boolean;
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
readonly logger: { error: (...args: unknown[]) => void };
|
||||
};
|
||||
|
||||
/** What an OAuth run needs of the session. */
|
||||
export type McpOAuthSession = {
|
||||
/** One OAuth run at a time per key: the session object. */
|
||||
readonly key: object;
|
||||
readonly sessionId: string;
|
||||
/** The session's query now, or `undefined` once the session is gone. */
|
||||
currentQuery(): Query | undefined;
|
||||
/** Aborts when the session closes. */
|
||||
readonly closed: AbortSignal;
|
||||
};
|
||||
|
||||
const mcpAuthentications = new WeakMap<object, Promise<void>>();
|
||||
|
||||
/** Start OAuth in the background for the servers in `requestedServers` that
|
||||
* Claude reports as needing authentication. It does nothing while another
|
||||
* OAuth run of the session is in progress. */
|
||||
export function startMcpServerAuthentication(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
requestedServers: Set<string>,
|
||||
): void {
|
||||
if (!client.urlElicitation || requestedServers.size === 0) return;
|
||||
const query = session.currentQuery();
|
||||
if (!query || mcpAuthentications.has(session.key)) return;
|
||||
|
||||
const authentication = authenticateMcpServers(client, session, query, requestedServers)
|
||||
.catch((error) => {
|
||||
if (!session.closed.aborted) {
|
||||
client.logger.error(`Failed to inspect MCP servers for OAuth: ${error}`);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (mcpAuthentications.get(session.key) === authentication) {
|
||||
mcpAuthentications.delete(session.key);
|
||||
}
|
||||
});
|
||||
mcpAuthentications.set(session.key, authentication);
|
||||
}
|
||||
|
||||
async function authenticateMcpServers(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
query: Query,
|
||||
requestedServers: Set<string>,
|
||||
): Promise<void> {
|
||||
if (!supportsMcpOAuth(query)) {
|
||||
client.logger.error("The Claude Agent SDK does not expose MCP OAuth authentication.");
|
||||
return;
|
||||
}
|
||||
|
||||
const statuses = await query.mcpServerStatus();
|
||||
for (const status of statuses) {
|
||||
if (status.status !== "needs-auth" || !requestedServers.has(status.name)) continue;
|
||||
try {
|
||||
await authenticateMcpServer(client, session, query, status.name);
|
||||
} catch (error) {
|
||||
if (session.currentQuery() && !session.closed.aborted) {
|
||||
client.logger.error(`Failed to authenticate MCP server ${status.name}: ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Bridge Claude Code's startup MCP OAuth control to ACP URL elicitation.
|
||||
* Claude opens and owns the localhost callback listener; the ACP client only
|
||||
* needs to present the returned authorization URL. */
|
||||
async function authenticateMcpServer(
|
||||
client: McpOAuthClient,
|
||||
session: McpOAuthSession,
|
||||
query: McpOAuthQuery,
|
||||
serverName: string,
|
||||
): Promise<void> {
|
||||
if (!session.currentQuery()) return;
|
||||
|
||||
const login = await query.mcpAuthenticate(serverName);
|
||||
if (!login.requiresUserAction) return;
|
||||
if (!login.authUrl) {
|
||||
throw new Error("Claude Code requested user action without returning an authorization URL");
|
||||
}
|
||||
|
||||
const elicitationId = `mcp-oauth-${randomUUID()}`;
|
||||
const flowAbort = new AbortController();
|
||||
const abortFlow = () => flowAbort.abort(session.closed.reason);
|
||||
session.closed.addEventListener("abort", abortFlow, { once: true });
|
||||
if (session.closed.aborted) abortFlow();
|
||||
|
||||
try {
|
||||
const completed = waitForMcpAuthentication(session, query, serverName, flowAbort.signal);
|
||||
const elicitation = client.createElicitation(
|
||||
{
|
||||
mode: "url",
|
||||
sessionId: session.sessionId,
|
||||
message: `Authenticate with MCP server ${serverName}`,
|
||||
url: login.authUrl,
|
||||
elicitationId,
|
||||
},
|
||||
flowAbort.signal,
|
||||
);
|
||||
const first = await Promise.race([
|
||||
completed.then((authenticated) => ({ type: "completed" as const, authenticated })),
|
||||
elicitation.then((response) => ({ type: "elicitation" as const, response })),
|
||||
]);
|
||||
|
||||
// A url-mode accept carries no content to check. The guard
|
||||
// `CreateElicitationResponse.isAccept` would also reject an accept whose
|
||||
// `content` holds a v2-only value, such as an object.
|
||||
if (first.type === "elicitation" && first.response.action !== "accept") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (first.type === "elicitation") {
|
||||
await completed;
|
||||
}
|
||||
try {
|
||||
await client.completeElicitation({ elicitationId });
|
||||
} catch (error) {
|
||||
if (!flowAbort.signal.aborted) {
|
||||
client.logger.error(`Failed to complete MCP OAuth elicitation: ${error}`);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flowAbort.abort();
|
||||
session.closed.removeEventListener("abort", abortFlow);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMcpAuthentication(
|
||||
session: McpOAuthSession,
|
||||
query: Query,
|
||||
serverName: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + MCP_OAUTH_TIMEOUT_MS;
|
||||
while (!signal.aborted && Date.now() < deadline) {
|
||||
if (session.currentQuery() !== query) return false;
|
||||
|
||||
const status: McpServerStatus | undefined = (await query.mcpServerStatus()).find(
|
||||
(server) => server.name === serverName,
|
||||
);
|
||||
if (!status || status.status === "failed" || status.status === "disabled") return false;
|
||||
if (status.status === "connected") return true;
|
||||
if (!(await waitUnlessAborted(MCP_OAUTH_STATUS_POLL_MS, signal))) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Wait for a polling interval, resolving false when the session is aborted. */
|
||||
export function waitUnlessAborted(milliseconds: number, signal: AbortSignal): Promise<boolean> {
|
||||
if (signal.aborted) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener("abort", onAbort);
|
||||
resolve(true);
|
||||
}, milliseconds);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
signal.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
33
src/mcp-servers.ts
Normal file
33
src/mcp-servers.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { McpServer } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { McpServerConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/**
|
||||
* The SDK configs of a session's ACP MCP servers, by name. HTTP and SSE
|
||||
* servers keep their URL and headers; a server without `type` is stdio. A
|
||||
* server of any other transport is left out: the session's `initialize`
|
||||
* advertises none.
|
||||
*/
|
||||
export function sdkMcpServers(
|
||||
servers: readonly McpServer[] | null | undefined,
|
||||
): Record<string, McpServerConfig> {
|
||||
const configs: Record<string, McpServerConfig> = {};
|
||||
for (const server of servers ?? []) {
|
||||
if ("type" in server && (server.type === "http" || server.type === "sse")) {
|
||||
configs[server.name] = {
|
||||
type: server.type,
|
||||
url: server.url,
|
||||
headers: server.headers
|
||||
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
|
||||
: undefined,
|
||||
};
|
||||
} else if (!("type" in server)) {
|
||||
configs[server.name] = {
|
||||
type: "stdio",
|
||||
command: server.command,
|
||||
args: server.args,
|
||||
env: server.env ? Object.fromEntries(server.env.map((e) => [e.name, e.value])) : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return configs;
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import path from "node:path";
|
||||
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
import { MODEL_CONFIG_ID } from "./session-model.js";
|
||||
import { orderedSubagentConfiguration } from "./subagent-configuration.js";
|
||||
import { isFinalToolCallStatus } from "./tool-calls/renderer.js";
|
||||
|
||||
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
|
||||
@@ -130,7 +131,7 @@ type TaskStarted = {
|
||||
prompt?: unknown;
|
||||
};
|
||||
|
||||
type SubagentIdentity = {
|
||||
export type SubagentIdentity = {
|
||||
name?: string;
|
||||
description?: string;
|
||||
prompt?: string;
|
||||
@@ -292,30 +293,6 @@ export class NativeSubagentRuntime {
|
||||
return child?.announced ? child.workState : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the `subagent_update` that tells a client that holds nothing
|
||||
* of the announced child `sessionId` of this live session what it is now:
|
||||
* its labels, its directory, its `cancel` capability, and its work state.
|
||||
* Undefined when no such child runs here.
|
||||
*/
|
||||
currentUpdate(sessionId: string): AcpSessionNotification | undefined {
|
||||
if (this.form !== "rfd") return undefined;
|
||||
const child = this.childOfSession(sessionId);
|
||||
if (!child?.announced || !child.workState) return undefined;
|
||||
return {
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...(child.description ? { description: child.description } : {}),
|
||||
...(child.reportedCwd ? { cwd: child.reportedCwd } : {}),
|
||||
capabilities: { cancel: {} },
|
||||
state: { state: child.workState },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the model of a child response, an exact model id, as the entry of
|
||||
* the root session's `model` option that matches it, else as the id (see
|
||||
@@ -412,14 +389,7 @@ export class NativeSubagentRuntime {
|
||||
// so a delayed publication cannot replace newer settings with older ones.
|
||||
const publishing = config.publishing.then(async () => {
|
||||
if (this.configBySession.get(child.sessionId) !== config || ended()) return;
|
||||
const priority = ["mode", "model", "thought_level", "model_config"];
|
||||
const rank = (option: SessionConfigOption) => {
|
||||
const index = priority.indexOf(option.category ?? "");
|
||||
return index < 0 ? priority.length : index;
|
||||
};
|
||||
const configOptions = [...config.options.values()].sort(
|
||||
(left, right) => rank(left) - rank(right),
|
||||
);
|
||||
const configOptions = orderedSubagentConfiguration(config.options.values());
|
||||
const snapshot = JSON.stringify(configOptions);
|
||||
if (config.published === snapshot) return;
|
||||
await this.publish({
|
||||
@@ -1978,7 +1948,7 @@ function isWorking(child: NativeSubagent): boolean {
|
||||
* A failure is idle with the `error` stop reason and, when the SDK gives its
|
||||
* text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
|
||||
* response, so this is how a client learns that its work failed. The error is
|
||||
* an internal error, as for a failed turn on the v2 surface.
|
||||
* an internal error, as for a failed turn on v2.
|
||||
*
|
||||
* A task that a worker restart orphaned, or that was still running when the
|
||||
* stream ended, is idle with no stop reason: its work ended, but the adapter
|
||||
@@ -2133,7 +2103,7 @@ function isFailedToolCallUpdate(update: AcpSessionNotification["update"]): boole
|
||||
* one), a SendMessage call whose message reached no announced child, or a
|
||||
* failed SubagentHandback call.
|
||||
*/
|
||||
function unexposedControlFallback(
|
||||
export function unexposedControlFallback(
|
||||
initial: AcpSessionNotification | undefined,
|
||||
terminal: AcpSessionNotification,
|
||||
sessionId: string,
|
||||
@@ -2227,7 +2197,8 @@ function subagentDescription(prompt: unknown, description: unknown): string {
|
||||
return "Delegated task";
|
||||
}
|
||||
|
||||
function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
/** The labels and prompt of a child, from the input of the Agent or Task call that launched it. */
|
||||
export function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input)) return undefined;
|
||||
const value = input as Record<string, unknown>;
|
||||
const identity: SubagentIdentity = {
|
||||
@@ -2239,7 +2210,7 @@ function subagentIdentity(input: unknown): SubagentIdentity | undefined {
|
||||
return Object.values(identity).some(Boolean) ? identity : undefined;
|
||||
}
|
||||
|
||||
function mergeSubagentIdentity(
|
||||
export function mergeSubagentIdentity(
|
||||
previous: SubagentIdentity | undefined,
|
||||
next: SubagentIdentity,
|
||||
): SubagentIdentity {
|
||||
|
||||
@@ -120,18 +120,64 @@ export function buildClaudePermissionPresentation(
|
||||
}),
|
||||
// Only AIR gets the permission presentation.
|
||||
...(title && capabilities.air.client
|
||||
? {
|
||||
_meta: withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select
|
||||
// an option keeps the decline focused; the option order already
|
||||
// leads with the reject options when this is set.
|
||||
...(value.defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
}),
|
||||
}
|
||||
? { _meta: airPermissionMeta({ title, description, defaultToNo: value.defaultToNo }) }
|
||||
: {}),
|
||||
...(capabilities.v2 ? { title, ...(description ? { description } : {}) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta.jetbrains.air.permission` of a permission request, from the
|
||||
* heading and explanation that {@link buildClaudePermissionPresentation}
|
||||
* built. The v2 core adds it for an AIR client, whose tool calls it
|
||||
* renders without the AIR tool call contract.
|
||||
*/
|
||||
export function airPermissionMeta({
|
||||
title,
|
||||
description,
|
||||
defaultToNo,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
defaultToNo?: boolean;
|
||||
}): Record<string, unknown> {
|
||||
return withAirMeta(undefined, AIR_PERMISSION_KEY, {
|
||||
version: 1,
|
||||
title,
|
||||
...(description ? { description } : {}),
|
||||
// The CLI's own hint, forwarded so a client that can pre-select an
|
||||
// option keeps the decline focused; the option order already leads with
|
||||
// the reject options when this is set.
|
||||
...(defaultToNo === true ? { defaultToNo: true } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta.claudeCode` of a permission request's tool call: which MCP
|
||||
* server serves an `mcp__*` tool and where its definition came from
|
||||
* (`mcpServer`, SDK 0.3.274+), so a client can key trust on `source`, and,
|
||||
* for clients other than AIR, the tool name and the Agent or Task call of a
|
||||
* subagent's request. Undefined when there is nothing to add.
|
||||
*/
|
||||
export function permissionToolCallMeta({
|
||||
toolName,
|
||||
parentToolUseId,
|
||||
mcpServer,
|
||||
airClient,
|
||||
}: {
|
||||
toolName: string;
|
||||
parentToolUseId: string | undefined;
|
||||
mcpServer: { name: string; source: string } | undefined;
|
||||
airClient: boolean;
|
||||
}): { claudeCode: Record<string, unknown> } | undefined {
|
||||
if (!mcpServer && !(parentToolUseId && !airClient)) return undefined;
|
||||
// The name is the config key as authored: untrusted text, so it rides
|
||||
// `_meta` rather than the title.
|
||||
return {
|
||||
claudeCode: {
|
||||
...(airClient ? {} : { toolName }),
|
||||
...(parentToolUseId && !airClient ? { parentToolUseId } : {}),
|
||||
...(mcpServer ? { mcpServer: { name: mcpServer.name, source: mcpServer.source } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -42,3 +42,51 @@ export function promptSuggestionsMeta(text: string | null): Record<string, unkno
|
||||
const value: PromptSuggestionsMetaValue = { suggestions: text === null ? [] : [{ text }] };
|
||||
return { [PROMPT_SUGGESTIONS_META_KEY]: value };
|
||||
}
|
||||
|
||||
/**
|
||||
* The prediction a session holds on the client, and whether an arriving
|
||||
* `prompt_suggestion` still answers the turn the user waits on. Both cores
|
||||
* keep one per session and send the `_meta` that its methods return as a
|
||||
* `session_info_update`, to an AIR client only. Undefined: nothing to send.
|
||||
*/
|
||||
export class PromptSuggestions {
|
||||
/** The last text sent (`null` after a clear). Undefined until the first send. */
|
||||
private published: string | null | undefined;
|
||||
/**
|
||||
* Set at a turn's result, cleared when the next prompt arrives. A
|
||||
* prediction that the next prompt outran (sent before the prediction was
|
||||
* read off the stream) follows a turn that is answered already, so it is
|
||||
* dropped instead of published mid-turn.
|
||||
*/
|
||||
private expected = false;
|
||||
|
||||
/** A result came: the CLI predicts the next prompt right after it. */
|
||||
resultArrived(): void {
|
||||
this.expected = true;
|
||||
}
|
||||
|
||||
/** The `_meta` of the CLI's `prompt_suggestion`, if it answers the last result and changes what the client holds. */
|
||||
suggested(raw: string | null | undefined): Record<string, unknown> | undefined {
|
||||
if (!this.expected) return undefined;
|
||||
this.expected = false;
|
||||
return this.publish(promptSuggestionText(raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* The `_meta` that clears the prediction: a client prompt spent it, or a
|
||||
* `conversation_reset` dropped the transcript it came from. It also drops
|
||||
* a prediction still in flight for the last result. Undefined when the
|
||||
* client holds none.
|
||||
*/
|
||||
clear(): Record<string, unknown> | undefined {
|
||||
this.expected = false;
|
||||
return this.publish(null);
|
||||
}
|
||||
|
||||
private publish(text: string | null): Record<string, unknown> | undefined {
|
||||
// An unchanged prediction is not sent again.
|
||||
if ((this.published ?? null) === text) return undefined;
|
||||
this.published = text;
|
||||
return promptSuggestionsMeta(text);
|
||||
}
|
||||
}
|
||||
|
||||
421
src/providers.ts
Normal file
421
src/providers.ts
Normal file
@@ -0,0 +1,421 @@
|
||||
/**
|
||||
* Client-managed LLM routing, shared by the v1 core and the v2 core:
|
||||
* the `main` provider of `providers/list`, `providers/set` and
|
||||
* `providers/disable`, and the legacy gateway logins (`authenticate` or
|
||||
* `auth/login` with `gateway` or `gateway-bedrock`). Each core holds its own
|
||||
* {@link ProviderConfig} and gateway login, and builds the query env of its
|
||||
* sessions with {@link createEnvForProvider}.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type AuthenticateRequest,
|
||||
type AuthMethod,
|
||||
type ListProvidersResponse,
|
||||
type LlmProtocol,
|
||||
type ProviderInfo,
|
||||
type SetProviderRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
GatewayListingError,
|
||||
listBedrockModels,
|
||||
listGatewayModels,
|
||||
PROVIDER_AUTH_TOKEN_PLACEHOLDER,
|
||||
selectBedrockRegion,
|
||||
} from "./gateway-models.js";
|
||||
|
||||
type ProviderLogger = { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
|
||||
/**
|
||||
* Extra metadata for 'gateway' authentication requests.
|
||||
*/
|
||||
export type GatewayAuthMeta = {
|
||||
/**
|
||||
* These parameters are mapped to environment variables to:
|
||||
* - Redirect API calls via baseUrl
|
||||
* - Inject custom headers
|
||||
* - Bypass the default Claude login requirement
|
||||
*
|
||||
* Both members are optional in the type because the payload arrives
|
||||
* unvalidated from the client. `authenticate` rejects a request that lacks a
|
||||
* usable `baseUrl`.
|
||||
*/
|
||||
gateway?: {
|
||||
baseUrl?: string;
|
||||
headers?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
export type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta };
|
||||
|
||||
export const SUPPORTED_PROTOCOLS: LlmProtocol[] = ["anthropic", "bedrock", "vertex"];
|
||||
export const PROVIDER_ID = "main";
|
||||
export const DEFAULT_ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||
export const DEFAULT_VERTEX_BASE_URL = "https://aiplatform.googleapis.com";
|
||||
|
||||
/**
|
||||
* Vertex needs project + region that the standard `providers/set` payload
|
||||
* (`apiType`/`baseUrl`/`headers`) does not model, so clients pass them through
|
||||
* `_meta.claudeCode.vertex`. Required only when `apiType === "vertex"`.
|
||||
*/
|
||||
export type SetProviderMeta = {
|
||||
claudeCode?: {
|
||||
vertex?: {
|
||||
projectId: string;
|
||||
region: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved, non-secret + secret routing config for the `main` provider. This is
|
||||
* the shared shape produced by both `providers/set` and the legacy gateway auth
|
||||
* path, and consumed by {@link createEnvForProvider}. `null` means the provider
|
||||
* is unconfigured (no client-managed routing in effect).
|
||||
*/
|
||||
export type ProviderConfig = {
|
||||
apiType: LlmProtocol;
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
/** Present only for `apiType === "vertex"`. */
|
||||
vertex?: {
|
||||
projectId: string;
|
||||
region: string;
|
||||
};
|
||||
/**
|
||||
* Model ids an `anthropic` or `bedrock` gateway listed when it was set via
|
||||
* `providers/set`; the model picker offers only these. Absent when the
|
||||
* listing failed or the config came from elsewhere (gateway `authenticate`),
|
||||
* so nothing is narrowed.
|
||||
*/
|
||||
listedModels?: string[];
|
||||
/**
|
||||
* The cross-region prefix of the model ids a `bedrock` gateway
|
||||
* listed (`eu` for `eu.anthropic.claude-sonnet-5`), passed to the CLI as
|
||||
* `ANTHROPIC_BEDROCK_REGION_PREFIX` so it calls the models under that prefix.
|
||||
*/
|
||||
bedrockRegionPrefix?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Adapt a legacy gateway `authenticate` request into the shared
|
||||
* {@link ProviderConfig} shape. Returns `null` when no gateway request is
|
||||
* present. `methodId` selects the protocol: `gateway-bedrock` → bedrock,
|
||||
* otherwise anthropic.
|
||||
*/
|
||||
export function gatewayRequestToProviderConfig(
|
||||
request?: GatewayAuthRequest,
|
||||
): ProviderConfig | null {
|
||||
// `authenticate` validates the payload before it stores one, so a stored
|
||||
// request always carries a usable gateway. Re-check the shape here anyway:
|
||||
// this function decides whether a provider override is active, and the
|
||||
// `--hide-claude-auth` guard is off while one is.
|
||||
const gateway = request?._meta?.gateway;
|
||||
if (!gateway || !isValidBaseUrl(gateway.baseUrl)) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
apiType: request?.methodId === "gateway-bedrock" ? "bedrock" : "anthropic",
|
||||
baseUrl: gateway.baseUrl,
|
||||
headers: gateway.headers ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a resolved provider config into the Claude Code env vars that redirect API
|
||||
* traffic and inject headers. Returns an empty object when routing is
|
||||
* unconfigured. The token/bypass placeholders (`" "`) are required so the CLI
|
||||
* skips its normal login/credential checks when a gateway is in use.
|
||||
*/
|
||||
export function createEnvForProvider(config: ProviderConfig | null): Record<string, string> {
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const resetRouting = {
|
||||
ANTHROPIC_BASE_URL: "",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "",
|
||||
ANTHROPIC_VERTEX_BASE_URL: "",
|
||||
CLAUDE_CODE_USE_BEDROCK: "0",
|
||||
CLAUDE_CODE_USE_VERTEX: "0",
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "",
|
||||
CLOUD_ML_REGION: "",
|
||||
AWS_REGION: "",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "",
|
||||
};
|
||||
const customHeaders = Object.entries(config.headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
|
||||
if (config.apiType === "bedrock") {
|
||||
return {
|
||||
...resetRouting,
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local AWS credential checks
|
||||
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
...(config.bedrockRegionPrefix
|
||||
? { ANTHROPIC_BEDROCK_REGION_PREFIX: config.bedrockRegionPrefix }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (config.apiType === "vertex") {
|
||||
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
|
||||
// validation; fall back to empty strings defensively.
|
||||
// Leaving ANTHROPIC_VERTEX_BASE_URL empty for the default endpoint lets
|
||||
// Claude Code use its native Vertex endpoint/model resolution. Supplying
|
||||
// even the default host marks the session as a custom Vertex deployment and
|
||||
// can hide otherwise-valid models.
|
||||
return {
|
||||
...resetRouting,
|
||||
CLAUDE_CODE_USE_VERTEX: "1",
|
||||
...(config.baseUrl !== DEFAULT_VERTEX_BASE_URL
|
||||
? { ANTHROPIC_VERTEX_BASE_URL: config.baseUrl }
|
||||
: {}),
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
|
||||
CLOUD_ML_REGION: config.vertex?.region ?? "",
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...resetRouting,
|
||||
ANTHROPIC_BASE_URL: config.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
ANTHROPIC_AUTH_TOKEN: PROVIDER_AUTH_TOKEN_PLACEHOLDER, // Bypass local Claude login checks
|
||||
};
|
||||
}
|
||||
|
||||
function sameHeaders(a: Record<string, string>, b: Record<string, string>): boolean {
|
||||
const aKeys = Object.keys(a);
|
||||
return aKeys.length === Object.keys(b).length && aKeys.every((key) => b[key] === a[key]);
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider base URL as logs may show it: its origin only. The URL can come
|
||||
* from the env or the client and can carry credentials (`https://user:key@host`)
|
||||
* or a key in its path or query, so a log line must not print it whole.
|
||||
*/
|
||||
export function loggableBaseUrl(baseUrl: string | undefined): string {
|
||||
if (baseUrl === undefined) return "native";
|
||||
try {
|
||||
return new URL(baseUrl).origin;
|
||||
} catch {
|
||||
return "invalid";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a provider base URL: must be a non-empty absolute http(s) URL.
|
||||
*/
|
||||
export function isValidBaseUrl(baseUrl: string | undefined): baseUrl is string {
|
||||
if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
|
||||
return false;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return parsed.protocol === "http:" || parsed.protocol === "https:";
|
||||
}
|
||||
|
||||
/** The gateway login methods, advertised to a client with `auth._meta.gateway`. */
|
||||
export function gatewayAuthMethods(): AuthMethod[] {
|
||||
return (["anthropic", "bedrock"] as const).map((protocol) => ({
|
||||
id: protocol === "anthropic" ? "gateway" : "gateway-bedrock",
|
||||
name: "Custom model gateway",
|
||||
description: "Use a custom gateway to authenticate and access models",
|
||||
_meta: { gateway: { protocol } },
|
||||
}));
|
||||
}
|
||||
|
||||
export function isGatewayAuthMethod(methodId: string): boolean {
|
||||
return methodId === "gateway" || methodId === "gateway-bedrock";
|
||||
}
|
||||
|
||||
/**
|
||||
* The gateway login `params` asks for, once its payload is usable: the same
|
||||
* base URL rule as `providers/set`. An unchecked payload either throws a
|
||||
* `TypeError` deep in session creation, or installs an empty base URL that
|
||||
* silently turns the `--hide-claude-auth` subscription guard off.
|
||||
*
|
||||
* A request that carries no gateway payload at all keeps its historical
|
||||
* meaning: it installs no override and succeeds. A client that probes the
|
||||
* method this way must keep working. Only a payload that IS present has to be
|
||||
* usable.
|
||||
*/
|
||||
export function validatedGatewayAuthRequest(params: AuthenticateRequest): GatewayAuthRequest {
|
||||
const gateway = (params as GatewayAuthRequest)._meta?.gateway;
|
||||
if (gateway !== undefined && gateway !== null) {
|
||||
if (typeof gateway !== "object" || !isValidBaseUrl(gateway.baseUrl)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ baseUrl: (gateway as { baseUrl?: unknown }).baseUrl },
|
||||
"`_meta.gateway.baseUrl` must be a non-empty absolute http(s) URL.",
|
||||
);
|
||||
}
|
||||
if (gateway.headers !== undefined && typeof gateway.headers !== "object") {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"`_meta.gateway.headers` must be an object of header names to values.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return params as GatewayAuthRequest;
|
||||
}
|
||||
|
||||
/** The native routing of the agent: a gateway login, else the process env. */
|
||||
export function defaultProviderConfig(gatewayAuthRequest?: GatewayAuthRequest): ProviderConfig {
|
||||
const gatewayConfig = gatewayRequestToProviderConfig(gatewayAuthRequest);
|
||||
if (gatewayConfig) {
|
||||
return gatewayConfig;
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_BEDROCK) {
|
||||
return {
|
||||
apiType: "bedrock",
|
||||
baseUrl: process.env.ANTHROPIC_BEDROCK_BASE_URL ?? "https://bedrock-runtime.amazonaws.com",
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
if (process.env.CLAUDE_CODE_USE_VERTEX) {
|
||||
return {
|
||||
apiType: "vertex",
|
||||
baseUrl: process.env.ANTHROPIC_VERTEX_BASE_URL ?? DEFAULT_VERTEX_BASE_URL,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
return {
|
||||
apiType: "anthropic",
|
||||
baseUrl: process.env.ANTHROPIC_BASE_URL ?? DEFAULT_ANTHROPIC_BASE_URL,
|
||||
headers: {},
|
||||
};
|
||||
}
|
||||
|
||||
/** The `providers/list` response for the `main` provider routed by `config`. */
|
||||
export function listProvidersResponse(config: ProviderConfig): ListProvidersResponse {
|
||||
const provider: ProviderInfo = {
|
||||
providerId: PROVIDER_ID,
|
||||
supported: SUPPORTED_PROTOCOLS,
|
||||
required: false,
|
||||
current: { apiType: config.apiType, baseUrl: config.baseUrl },
|
||||
};
|
||||
return { providers: [provider] };
|
||||
}
|
||||
|
||||
/**
|
||||
* The configuration a `providers/set` request asks for. Rejects unknown IDs,
|
||||
* unsupported protocols, empty or invalid base URLs, and a `vertex` request
|
||||
* without its project and region, with `invalid_params`.
|
||||
*/
|
||||
export function providerConfigFromSetRequest(params: SetProviderRequest): ProviderConfig {
|
||||
if (params.providerId !== PROVIDER_ID) {
|
||||
throw RequestError.invalidParams(
|
||||
{ providerId: params.providerId },
|
||||
`Unknown provider ID "${params.providerId}"; expected "${PROVIDER_ID}".`,
|
||||
);
|
||||
}
|
||||
if (!SUPPORTED_PROTOCOLS.includes(params.apiType)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ apiType: params.apiType, supported: SUPPORTED_PROTOCOLS },
|
||||
`Unsupported apiType "${params.apiType}" for provider "${PROVIDER_ID}".`,
|
||||
);
|
||||
}
|
||||
if (!isValidBaseUrl(params.baseUrl)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ baseUrl: params.baseUrl },
|
||||
"baseUrl must be a non-empty absolute http(s) URL.",
|
||||
);
|
||||
}
|
||||
|
||||
const config: ProviderConfig = {
|
||||
apiType: params.apiType,
|
||||
baseUrl: params.baseUrl,
|
||||
headers: params.headers ?? {},
|
||||
};
|
||||
|
||||
// Vertex requires project + region, which the standard payload cannot
|
||||
// carry, so they arrive via `_meta.claudeCode.vertex`.
|
||||
if (params.apiType === "vertex") {
|
||||
const vertex = (params._meta as SetProviderMeta | undefined)?.claudeCode?.vertex;
|
||||
if (
|
||||
!vertex ||
|
||||
typeof vertex.projectId !== "string" ||
|
||||
vertex.projectId.trim() === "" ||
|
||||
typeof vertex.region !== "string" ||
|
||||
vertex.region.trim() === ""
|
||||
) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
"vertex apiType requires non-empty `_meta.claudeCode.vertex.projectId` and `_meta.claudeCode.vertex.region`.",
|
||||
);
|
||||
}
|
||||
config.vertex = { projectId: vertex.projectId, region: vertex.region };
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds to `config` the model ids its gateway lists, and for `bedrock` the
|
||||
* region prefix picked from them, so the sessions it routes offer only those.
|
||||
* Setting the same gateway as `current` again reuses its listing. `vertex`
|
||||
* gateways are not listed. A failed listing throws an `internal_error`.
|
||||
*/
|
||||
export async function withListedModels(
|
||||
config: ProviderConfig,
|
||||
current: ProviderConfig | undefined,
|
||||
logger: ProviderLogger,
|
||||
): Promise<ProviderConfig> {
|
||||
try {
|
||||
Object.assign(config, await listProviderModels(config, current, logger));
|
||||
} catch (error) {
|
||||
if (error instanceof GatewayListingError) {
|
||||
throw RequestError.internalError(
|
||||
{ providerId: PROVIDER_ID, apiType: config.apiType, baseUrl: config.baseUrl },
|
||||
`Model discovery for provider "${PROVIDER_ID}" failed: ${error.message}`,
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
logger.log(
|
||||
`[providers/set] listedModels=${config.listedModels?.length ?? "none"} bedrockRegionPrefix=${config.bedrockRegionPrefix ?? "none"}`,
|
||||
);
|
||||
return config;
|
||||
}
|
||||
|
||||
async function listProviderModels(
|
||||
config: ProviderConfig,
|
||||
current: ProviderConfig | undefined,
|
||||
logger: ProviderLogger,
|
||||
): Promise<Pick<ProviderConfig, "listedModels" | "bedrockRegionPrefix">> {
|
||||
// Reuse needs the very same headers, so a rotated token lists again, and a
|
||||
// failed listing then fails that `providers/set`. Deliberate: AIR keeps its
|
||||
// proxy key for the IDE's lifetime and does not rotate it.
|
||||
if (
|
||||
current?.listedModels &&
|
||||
current.apiType === config.apiType &&
|
||||
current.baseUrl === config.baseUrl &&
|
||||
sameHeaders(current.headers, config.headers)
|
||||
) {
|
||||
return {
|
||||
listedModels: current.listedModels,
|
||||
bedrockRegionPrefix: current.bedrockRegionPrefix,
|
||||
};
|
||||
}
|
||||
if (config.apiType === "anthropic") {
|
||||
return { listedModels: await listGatewayModels(config.baseUrl, config.headers, logger) };
|
||||
}
|
||||
if (config.apiType === "bedrock") {
|
||||
const modelIds = await listBedrockModels(config.baseUrl, config.headers, logger);
|
||||
if (!modelIds) return {};
|
||||
const region = selectBedrockRegion(modelIds);
|
||||
if (region.modelIds.length < modelIds.length) {
|
||||
logger.log(
|
||||
`[providers/set] Bedrock models span several region prefixes; using ${region.regionPrefix} and dropping ${modelIds.length - region.modelIds.length} id(s) of other prefixes`,
|
||||
);
|
||||
}
|
||||
return { listedModels: region.modelIds, bedrockRegionPrefix: region.regionPrefix };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
129
src/remote-control.ts
Normal file
129
src/remote-control.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* `/remote-control`, which the adapter serves itself, and the messages that a
|
||||
* person types in another client of the session (claude.ai or the Claude
|
||||
* mobile app, over Remote Control). Shared by the v1 core and the v2
|
||||
* core, which run the command each in their own turn model.
|
||||
*/
|
||||
/** The content blocks of a prompt, in v1 or v2 form: the command is one text block. */
|
||||
type PromptContent = ReadonlyArray<{ type: string; text?: unknown }>;
|
||||
|
||||
import type {
|
||||
Query,
|
||||
SDKUserMessage,
|
||||
SDKUserMessageReplay,
|
||||
SlashCommand,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Runtime Remote Control control exposed by the pinned Agent SDK. Like
|
||||
* `mcpAuthenticate`, it is not in the public `Query` declaration yet. The CLI's
|
||||
* own `/remote-control` is terminal-bound and not offered to SDK hosts, so the
|
||||
* adapter handles the command itself and answers with the session URL. */
|
||||
export type RemoteControlQuery = Query & {
|
||||
enableRemoteControl(
|
||||
enabled: boolean,
|
||||
name?: string,
|
||||
): Promise<{ session_url?: string; connect_url?: string }>;
|
||||
};
|
||||
|
||||
export function supportsRemoteControl(query: Query): query is RemoteControlQuery {
|
||||
return typeof (query as Partial<RemoteControlQuery>).enableRemoteControl === "function";
|
||||
}
|
||||
|
||||
export const REMOTE_CONTROL_COMMAND_NAME = "remote-control";
|
||||
|
||||
export type RemoteControlCommand = { action: "connect"; name?: string } | { action: "disconnect" };
|
||||
|
||||
/** Parses `/remote-control [name]` and `/remote-control off` from a single-text
|
||||
* prompt. Connecting is idempotent, so the command never needs to know whether
|
||||
* Remote Control is already on. */
|
||||
export function parseRemoteControlCommand(prompt: PromptContent): RemoteControlCommand | null {
|
||||
const [first] = prompt;
|
||||
if (prompt.length !== 1 || first?.type !== "text" || typeof first.text !== "string") return null;
|
||||
const match = /^\/remote-control(?:\s+([\s\S]*))?$/.exec(first.text.trim());
|
||||
if (!match) return null;
|
||||
const argument = match[1]?.trim();
|
||||
if (argument?.toLowerCase() === "off") return { action: "disconnect" };
|
||||
return argument ? { action: "connect", name: argument } : { action: "connect" };
|
||||
}
|
||||
|
||||
/** Whether Claude Code offers a slash command of this name to an ACP client.
|
||||
* This is the rule `getAvailableSlashCommands` applies when it lists the
|
||||
* commands, so the adapter stands in for the command exactly when the client
|
||||
* sees the adapter's entry: Claude Code's terminal-bound commands are not
|
||||
* offered, and a user's own command or skill of the name is never shadowed. */
|
||||
export function claudeCodeOffersCommand(
|
||||
commands: readonly SlashCommand[],
|
||||
terminalCommands: readonly string[] | undefined,
|
||||
name: string,
|
||||
): boolean {
|
||||
return commands.some(
|
||||
(command) => command.name === name && !terminalCommands?.includes(command.name),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/remote-control` command of `prompt` that the adapter runs: the query
|
||||
* serves Remote Control and Claude Code does not offer a command of that name.
|
||||
* `null` for any other prompt, which goes to Claude Code.
|
||||
*/
|
||||
export async function adapterRemoteControlCommand(
|
||||
prompt: PromptContent,
|
||||
query: Query,
|
||||
terminalCommands: readonly string[] | undefined,
|
||||
): Promise<{ query: RemoteControlQuery; command: RemoteControlCommand } | null> {
|
||||
const command = parseRemoteControlCommand(prompt);
|
||||
if (!command || !supportsRemoteControl(query)) return null;
|
||||
if (
|
||||
claudeCodeOffersCommand(
|
||||
await query.supportedCommands(),
|
||||
terminalCommands,
|
||||
REMOTE_CONTROL_COMMAND_NAME,
|
||||
)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return { query, command };
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects Remote Control (again, if it is already on) or disconnects it, and
|
||||
* returns the text that tells the user how it went. A failure is in the text:
|
||||
* this never throws.
|
||||
*/
|
||||
export async function runRemoteControlCommand(
|
||||
query: RemoteControlQuery,
|
||||
command: RemoteControlCommand,
|
||||
logError: (message: string) => void,
|
||||
): Promise<string> {
|
||||
if (command.action === "disconnect") {
|
||||
try {
|
||||
await query.enableRemoteControl(false);
|
||||
return "Remote Control is off. Run /remote-control to reconnect.";
|
||||
} catch (error) {
|
||||
logError(`Failed to disable Remote Control: ${error}`);
|
||||
return `Could not stop Remote Control: ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const result = await query.enableRemoteControl(true, command.name);
|
||||
return result.session_url
|
||||
? `Remote Control is active. Continue this session from claude.ai/code or the Claude mobile app:\n\n${result.session_url}\n\nRun /remote-control off to disconnect.`
|
||||
: "Remote Control is active, but Claude Code did not return a session URL.";
|
||||
} catch (error) {
|
||||
logError(`Failed to enable Remote Control: ${error}`);
|
||||
return `Could not start Remote Control: ${error instanceof Error ? error.message : error}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** A top-level message a person typed in another client of the session, such
|
||||
* as claude.ai or the Claude mobile app over Remote Control. Tool results and
|
||||
* the CLI's own synthetic messages are user-role too, but carry no human
|
||||
* origin. */
|
||||
export function isRemoteHumanMessage(message: SDKUserMessage | SDKUserMessageReplay): boolean {
|
||||
return (
|
||||
message.type === "user" &&
|
||||
message.origin?.kind === "human" &&
|
||||
message.parent_tool_use_id === null &&
|
||||
!message.isSynthetic
|
||||
);
|
||||
}
|
||||
62
src/result-end.ts
Normal file
62
src/result-end.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* How Claude Code's `result` ends the work that it reports: the rules that
|
||||
* both cores read a turn's result with (the v1 prompt loop in `acp-agent.ts`
|
||||
* and `v2/outcome.ts`). Each core adds its own side effects, such as v1's
|
||||
* session failure category.
|
||||
*/
|
||||
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export type ResultEnd =
|
||||
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
|
||||
/**
|
||||
* The result failed the work, with Claude Code's error text. `loginRequired`:
|
||||
* the text of a `success` result tells the user to run `/login`.
|
||||
*/
|
||||
| { error: string; loginRequired: boolean };
|
||||
|
||||
/**
|
||||
* True when CLI error text tells the user to sign in with `/login`. The CLI
|
||||
* words this several ways — "Not logged in · Please run /login", "Not logged
|
||||
* in · Run /login", "Login expired · Run /login to sign in again, or
|
||||
* re-authenticate your Anthropic profile" — so match the "run /login"
|
||||
* instruction rather than one sentence.
|
||||
*/
|
||||
export function isLoginRequiredText(text: string): boolean {
|
||||
return /\brun \/login\b/i.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* - Any subtype with `stop_reason: "refusal"`: `refusal`, also on an error.
|
||||
* - `success`: an error with the `/login` text fails the work, then
|
||||
* `stop_reason: "max_tokens"` is `max_tokens`, then any other error fails
|
||||
* the work. Otherwise `end_turn`.
|
||||
* - `error_during_execution`: `max_tokens` as for `success`, then an error
|
||||
* fails the work. Otherwise `end_turn`.
|
||||
* - `error_max_turns`, `error_max_budget_usd` and
|
||||
* `error_max_structured_output_retries`: an error fails the work, whatever
|
||||
* the stop reason. Otherwise `max_turn_requests`.
|
||||
*/
|
||||
export function resultEnd(result: SDKResultMessage): ResultEnd {
|
||||
if (result.stop_reason === "refusal") return { stopReason: "refusal" };
|
||||
switch (result.subtype) {
|
||||
case "success":
|
||||
if (result.is_error && isLoginRequiredText(result.result)) {
|
||||
return { error: result.result, loginRequired: true };
|
||||
}
|
||||
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
|
||||
if (result.is_error) return { error: result.result, loginRequired: false };
|
||||
return { stopReason: "end_turn" };
|
||||
case "error_during_execution":
|
||||
if (result.stop_reason === "max_tokens") return { stopReason: "max_tokens" };
|
||||
if (result.is_error) return failed(result);
|
||||
return { stopReason: "end_turn" };
|
||||
default:
|
||||
// `error_max_turns`, `error_max_budget_usd`, `error_max_structured_output_retries`.
|
||||
if (result.is_error) return failed(result);
|
||||
return { stopReason: "max_turn_requests" };
|
||||
}
|
||||
}
|
||||
|
||||
function failed(result: Exclude<SDKResultMessage, { subtype: "success" }>): ResultEnd {
|
||||
return { error: result.errors.join(", ") || result.subtype, loginRequired: false };
|
||||
}
|
||||
21
src/serve.ts
21
src/serve.ts
@@ -1,7 +1,7 @@
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "./utils.js";
|
||||
import { v2AgentApp } from "./v2/agent.js";
|
||||
import { v2AgentApp, type AgentHandle } from "./v2/agent.js";
|
||||
|
||||
/**
|
||||
* The ACP versions that the adapter serves: `v1` or `v2` alone, or both with
|
||||
@@ -32,13 +32,13 @@ export function serveAcp(
|
||||
const input = nodeToWebReadable(process.stdin);
|
||||
// The router opens the connection of the negotiated version only when it
|
||||
// reads `initialize`, so a client that leaves before then leaves no agent.
|
||||
let agent: ClaudeAcpAgent | undefined;
|
||||
const onAgent = (created: ClaudeAcpAgent) => {
|
||||
let agent: AgentHandle | undefined;
|
||||
const onAgent = (created: AgentHandle) => {
|
||||
agent = created;
|
||||
};
|
||||
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols).connect(
|
||||
v2.ndJsonStream(output, input),
|
||||
);
|
||||
const { closed } = acpProtocolRouter(logger, onAgent, options.protocols, {
|
||||
onV2Agent: onAgent,
|
||||
}).connect(v2.ndJsonStream(output, input));
|
||||
return {
|
||||
closed,
|
||||
dispose: async () => {
|
||||
@@ -53,15 +53,20 @@ export function serveAcp(
|
||||
* the draft ACP v2, and every other client gets ACP v1. With `v1`, a v2
|
||||
* client is negotiated down to v1. With `v2`, the router rejects the
|
||||
* `initialize` of a v1 client as an unsupported protocol version. `onAgent`
|
||||
* receives the agent of the connection once the router has opened it.
|
||||
* receives the agent of a v1 connection (`ClaudeAcpAgent`) once the router
|
||||
* has opened it, and `onV2Agent` the agent of a v2 connection, which the
|
||||
* v2 core (`src/v2/`) serves.
|
||||
*/
|
||||
export function acpProtocolRouter(
|
||||
logger: Logger | undefined,
|
||||
onAgent: (agent: ClaudeAcpAgent) => void,
|
||||
protocols: AcpProtocols = "auto",
|
||||
{ onV2Agent = () => {} }: { onV2Agent?: (agent: AgentHandle) => void } = {},
|
||||
): v2.AgentProtocolRouter {
|
||||
const router = v2.agentProtocolRouter();
|
||||
if (protocols !== "v2") router.withV1(v1AgentApp(logger, onAgent));
|
||||
if (protocols !== "v1") router.withV2(v2AgentApp(logger, onAgent));
|
||||
if (protocols !== "v1") {
|
||||
router.withV2(v2AgentApp(logger, onV2Agent));
|
||||
}
|
||||
return router;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/** Shared sentinels and ids for session preferences managed by the adapter. */
|
||||
export const DEFAULT_MODEL_ID = "default";
|
||||
export const DEFAULT_AGENT_ID = "default";
|
||||
export const AGENT_CONFIG_ID = "agent";
|
||||
export const EFFORT_CONFIG_ID = "effort";
|
||||
export const ULTRACODE_CONFIG_ID = "ultracode";
|
||||
|
||||
244
src/session-config-options.ts
Normal file
244
src/session-config-options.ts
Normal file
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* The config options of a session, as both the v1 core and the v2 core
|
||||
* build them: mode, model, effort, Fast mode, ultracode and the main-thread
|
||||
* agent.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type ClientCapabilities,
|
||||
type SessionConfigOption,
|
||||
type SessionModeState,
|
||||
type SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AgentInfo, ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
} from "./air-extension.js";
|
||||
import { AGENT_CONFIG_ID, DEFAULT_AGENT_ID } from "./session-config-ids.js";
|
||||
import { buildEffortConfigOption } from "./session-effort.js";
|
||||
import {
|
||||
createFastModeConfigOption,
|
||||
FAST_MODE_CONFIG_ID,
|
||||
resolveFastModeEnabled,
|
||||
type FastModeOptionState,
|
||||
} from "./session-fast-mode.js";
|
||||
import { MODE_CONFIG_ID, SessionModeManager } from "./session-mode.js";
|
||||
import {
|
||||
buildModelConfigOption,
|
||||
MODEL_CONFIG_ID,
|
||||
resolveModelPreference,
|
||||
type SessionModelState,
|
||||
} from "./session-model.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
resolveUltracodeEnabled,
|
||||
ULTRACODE_CONFIG_ID,
|
||||
type UltracodeOptionState,
|
||||
} from "./session-ultracode.js";
|
||||
|
||||
// `supportedAgents()` always returns Claude Code's built-in subagents — the
|
||||
// ones used for Task-tool delegation (Explore, Plan, etc.) — even when the user
|
||||
// has configured none of their own. Those aren't meaningful *main-thread*
|
||||
// personas, so we filter them out and only surface the Agent picker when the
|
||||
// user (or a plugin/project) has configured custom agents. Update this set if
|
||||
// the SDK's built-in roster changes.
|
||||
export const BUILTIN_AGENT_NAMES = new Set([
|
||||
"claude",
|
||||
"general-purpose",
|
||||
"Explore",
|
||||
"Plan",
|
||||
"statusline-setup",
|
||||
]);
|
||||
|
||||
// Value of the synthetic "Default" entry in the agent picker, which maps to the
|
||||
// standard Claude Code agent (`applyFlagSettings({ agent: null })`). It is a
|
||||
// reserved sentinel: a custom agent named exactly this would collide with it
|
||||
// (two options sharing the value, selection silently routing to `null`), so we
|
||||
// exclude that name from discovery.
|
||||
/** Discover user/plugin/project-configured main-thread agents, excluding the
|
||||
* built-in subagents and the reserved "default" sentinel. Returns an empty
|
||||
* list if discovery fails so a flaky control request never blocks session
|
||||
* creation. */
|
||||
export async function discoverCustomAgents(q: Query): Promise<AgentInfo[]> {
|
||||
try {
|
||||
return customAgents(await q.supportedAgents());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** The configured agents of `agents`: neither a built-in subagent nor the "default" sentinel. */
|
||||
export function customAgents(agents: readonly AgentInfo[]): AgentInfo[] {
|
||||
return agents.filter((a) => !BUILTIN_AGENT_NAMES.has(a.name) && a.name !== DEFAULT_AGENT_ID);
|
||||
}
|
||||
|
||||
/** Stable ids for the session config options surfaced via `configOptions`.
|
||||
* Centralized so the option declarations in `buildConfigOptions` and the
|
||||
* handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the
|
||||
* same identifiers and can't drift apart. */
|
||||
export { MODE_CONFIG_ID };
|
||||
export { AGENT_CONFIG_ID };
|
||||
|
||||
/** Whether the Client advertised support for boolean session config options
|
||||
* (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"`
|
||||
* config options to Clients that opt in; otherwise we fall back to a `select`.
|
||||
* See https://agentclientprotocol.com/rfds/boolean-config-option. */
|
||||
export function clientSupportsBooleanConfigOptions(
|
||||
clientCapabilities?: ClientCapabilities | null,
|
||||
): boolean {
|
||||
return clientCapabilities?.session?.configOptions?.boolean != null;
|
||||
}
|
||||
|
||||
/** Whether the Client advertised the AIR `recommendedValue` capability for
|
||||
* select-style model and effort options. Missing or malformed AIR metadata
|
||||
* retains the legacy `default` entries. */
|
||||
export function clientSupportsRecommendedConfigValue(
|
||||
clientCapabilities?: ClientCapabilities | null,
|
||||
): boolean {
|
||||
return clientSupportsAirCapability(clientCapabilities, AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY);
|
||||
}
|
||||
|
||||
export type ConfigOptionPresentation = {
|
||||
/** Replace ambiguous `default` rows with concrete values and advertise the
|
||||
* SDK/adapter recommendation as `_meta.jetbrains.air.recommendedValue`. */
|
||||
useRecommendedValue: boolean;
|
||||
};
|
||||
|
||||
export function buildConfigOptions(
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
currentEffortLevel?: string,
|
||||
agents: AgentInfo[] = [],
|
||||
currentAgent: string = DEFAULT_AGENT_ID,
|
||||
fastMode?: FastModeOptionState,
|
||||
presentation?: ConfigOptionPresentation,
|
||||
ultracode?: UltracodeOptionState,
|
||||
): SessionConfigOption[] {
|
||||
const options: SessionConfigOption[] = [SessionModeManager.configOption(modes)];
|
||||
// A gateway that lists none of the SDK's models leaves no model to offer, so
|
||||
// there is no model option at all.
|
||||
if (models.availableModels.length > 0) {
|
||||
options.push(
|
||||
buildModelConfigOption(models, modelInfos, presentation?.useRecommendedValue === true),
|
||||
);
|
||||
}
|
||||
const effort = buildEffortConfigOption(
|
||||
modelInfos,
|
||||
models.currentModelId,
|
||||
currentEffortLevel,
|
||||
presentation?.useRecommendedValue === true,
|
||||
);
|
||||
if (effort) options.push(effort);
|
||||
|
||||
// Surface the Fast mode toggle only when the current model supports it. The
|
||||
// option renders as a native boolean toggle for Clients that opted in, and a
|
||||
// two-value select otherwise.
|
||||
if (fastMode?.supported) {
|
||||
options.push(
|
||||
createFastModeConfigOption(
|
||||
fastMode.enabled,
|
||||
fastMode.useBooleanOption,
|
||||
fastMode.disabledReason,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Surface the ultracode toggle only while the CLI can run it: dynamic
|
||||
// workflows are enabled and the current model supports it.
|
||||
if (ultracode?.supported) {
|
||||
options.push(createUltracodeConfigOption(ultracode.enabled, ultracode.useBooleanOption));
|
||||
}
|
||||
|
||||
// Only surface the Agent picker when there's a real choice — i.e. the user
|
||||
// has configured at least one custom agent (built-ins are filtered out in
|
||||
// discoverCustomAgents). With none configured, "Default" would be the only
|
||||
// entry, so we omit the option entirely.
|
||||
if (agents.length > 0) {
|
||||
options.push({
|
||||
id: AGENT_CONFIG_ID,
|
||||
name: "Agent",
|
||||
description: "Main-thread agent persona",
|
||||
type: "select",
|
||||
currentValue: currentAgent,
|
||||
options: [
|
||||
{ value: DEFAULT_AGENT_ID, name: "Default", description: "Standard Claude Code agent" },
|
||||
...agents.map((a) => ({
|
||||
value: a.name,
|
||||
name: a.name,
|
||||
description: a.description || undefined,
|
||||
})),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value that a `session/set_config_option` request sets on `option`, the
|
||||
* option as the session advertised it. The value must match the option's
|
||||
* type: a `type: "boolean"` option takes `true`/`false`, not "on"/"off", and a
|
||||
* `type: "select"` option takes one of its values.
|
||||
*
|
||||
* Fast mode and ultracode give `enabled`: a boolean, or the "on"/"off" select
|
||||
* fallback for clients without boolean config options. A model takes an alias
|
||||
* too, such as "opus" or "sonnet" for a full model id. It is resolved against
|
||||
* `modelInfos` first: those entries carry `resolvedModel`, so a full model id
|
||||
* (in either hint spelling) lands on the right row via the exact tier instead
|
||||
* of a fuzzier one picking a same-family sibling from a different context
|
||||
* lane. The option's own entries (which never carry `resolvedModel`) remain as
|
||||
* a fallback for resolutions that don't map back onto a selectable option
|
||||
* (e.g. a fuzzy hit on an out-of-picker verbatim entry). `value` is then the
|
||||
* canonical option value, the model id rather than the alias.
|
||||
*/
|
||||
export function configOptionValue(
|
||||
option: SessionConfigOption,
|
||||
params: SetSessionConfigOptionRequest,
|
||||
modelInfos: ModelInfo[],
|
||||
): { enabled: boolean } | { value: string } {
|
||||
const expectedType = option.type === "boolean" ? "boolean" : "string";
|
||||
if (typeof params.value !== expectedType) {
|
||||
throw RequestError.invalidParams(
|
||||
{ configId: params.configId, value: params.value },
|
||||
`Config option ${params.configId} takes a ${expectedType} value, but received: ${JSON.stringify(params.value)}`,
|
||||
);
|
||||
}
|
||||
if (params.configId === FAST_MODE_CONFIG_ID) return { enabled: resolveFastModeEnabled(params) };
|
||||
if (params.configId === ULTRACODE_CONFIG_ID) {
|
||||
return { enabled: resolveUltracodeEnabled(params) };
|
||||
}
|
||||
if (typeof params.value !== "string") {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid value for config option ${params.configId}: ${params.value}`,
|
||||
);
|
||||
}
|
||||
const requested = params.value;
|
||||
const allValues =
|
||||
"options" in option && Array.isArray(option.options)
|
||||
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
|
||||
: [];
|
||||
let validValue = allValues.find((o) => o.value === requested);
|
||||
if (!validValue && params.configId === MODEL_CONFIG_ID) {
|
||||
const toOptionValue = (resolved: ModelInfo | null) =>
|
||||
resolved ? allValues.find((o) => o.value === resolved.value) : undefined;
|
||||
validValue = toOptionValue(resolveModelPreference(modelInfos, requested));
|
||||
if (!validValue) {
|
||||
const optionInfos: ModelInfo[] = allValues.map((o) => ({
|
||||
value: o.value,
|
||||
displayName: o.name,
|
||||
description: o.description ?? "",
|
||||
}));
|
||||
validValue = toOptionValue(resolveModelPreference(optionInfos, requested));
|
||||
}
|
||||
}
|
||||
if (!validValue) {
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid value for config option ${params.configId}: ${requested}`,
|
||||
);
|
||||
}
|
||||
return { value: validValue.value };
|
||||
}
|
||||
@@ -25,6 +25,22 @@ export function effortFlagSettings(
|
||||
return ultracode ? { effortLevel, ultracode: true } : { effortLevel };
|
||||
}
|
||||
|
||||
/**
|
||||
* `params` without `_meta.claudeCode.options.effort`, for a restart of the
|
||||
* session's query: the restart restores the current effort pin instead of
|
||||
* the one the session was created with.
|
||||
*/
|
||||
export function withoutEffort<T extends { _meta?: unknown }>(params: T): T {
|
||||
const meta = params._meta as
|
||||
{ claudeCode?: { options?: { effort?: unknown } } } | null | undefined;
|
||||
if (meta?.claudeCode?.options?.effort === undefined) {
|
||||
return params;
|
||||
}
|
||||
const options = { ...meta.claudeCode.options };
|
||||
delete options.effort;
|
||||
return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } };
|
||||
}
|
||||
|
||||
function canonicalizeModelSettingsKey(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
@@ -133,3 +149,130 @@ export function buildEffortConfigOption(
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** A session's effort bookkeeping: the level the user pinned through the
|
||||
* picker, and the level last applied to the SDK's flag layer. */
|
||||
export type EffortState = {
|
||||
pinned: string | undefined;
|
||||
applied: string | undefined;
|
||||
};
|
||||
|
||||
/** The effort after a switch to `newModelInfo`, before the option is rebuilt
|
||||
* (see {@link settleEffortAfterModelSwitch}). */
|
||||
export type EffortSwitch = {
|
||||
/** The effort to build the new model's option with. */
|
||||
seed: string | undefined;
|
||||
/** Whether the user's pin is one of the new model's levels. */
|
||||
pinnedForNewModel: boolean;
|
||||
before: EffortState;
|
||||
/** The level applied before the switch, restored if the sync fails. */
|
||||
appliedBeforeSwitch: string | undefined;
|
||||
};
|
||||
|
||||
/** The effort seed of a model switch. It depends on who owns the choice: a
|
||||
* user pin made through the ACP picker this session lives at the SDK's flag
|
||||
* layer and follows the session across switches, so it carries forward.
|
||||
* Otherwise the CLI resolves effort itself from the persisted settings — the
|
||||
* NEW model's `modelSettings` entry first (the CLI persists /effort per
|
||||
* model), then the legacy top-level value — so the option shows what it
|
||||
* will actually run instead of dragging the old model's value along. */
|
||||
export function effortForModelSwitch(input: {
|
||||
state: EffortState;
|
||||
/** The current value of the effort option, if it is shown. */
|
||||
displayed: string | undefined;
|
||||
newModelInfo: ModelInfo | undefined;
|
||||
modelId: string;
|
||||
/** The merged effort settings (see {@link mergeEffortSettings}). */
|
||||
settings: Settings;
|
||||
useRecommendedValue: boolean;
|
||||
}): EffortSwitch {
|
||||
const { state, displayed, newModelInfo, useRecommendedValue } = input;
|
||||
const pinned = state.pinned;
|
||||
const pinnedForNewModel =
|
||||
pinned !== undefined &&
|
||||
newModelInfo?.supportsEffort === true &&
|
||||
newModelInfo.supportedEffortLevels?.some((level) => level === pinned) === true;
|
||||
return {
|
||||
seed: pinnedForNewModel
|
||||
? pinned
|
||||
: settingsEffortForModel(input.settings, newModelInfo, input.modelId),
|
||||
pinnedForNewModel,
|
||||
before: state,
|
||||
appliedBeforeSwitch:
|
||||
state.applied ??
|
||||
(useRecommendedValue && typeof displayed === "string" && displayed !== "default"
|
||||
? displayed
|
||||
: pinned),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the effort of a model switch to the SDK's flag layer, once the new
|
||||
* model's effort `option` is built from `change.seed`. Returns the new state
|
||||
* and the option to show (undefined to hide it).
|
||||
*
|
||||
* Opted-in clients (`useRecommendedValue`) apply the concrete displayed effort
|
||||
* on every switch, including settings-derived values, so a previous automatic
|
||||
* flag cannot shadow the new model's settings. This does not create a user
|
||||
* pin. For other clients, the flag is synced only when a user pin is lost
|
||||
* across the switch — the new model clamped it away, where the flag must be
|
||||
* cleared too or the SDK would keep running the old pin invisibly.
|
||||
* Settings-derived seeds are display-only: the CLI resolves persisted effort
|
||||
* itself, and pinning it at the flag layer would shadow the per-model values
|
||||
* on every later switch.
|
||||
*
|
||||
* The model switch already succeeded, so a failed sync is returned as
|
||||
* `error`, not thrown. The old bookkeeping stays, and the unapplied value is
|
||||
* never shown: the option shows the last applied value when the new model can
|
||||
* select it, else it is hidden until a later switch rebuilds it.
|
||||
*/
|
||||
export async function settleEffortAfterModelSwitch(
|
||||
change: EffortSwitch,
|
||||
option: SessionConfigOption | undefined,
|
||||
ultracode: boolean,
|
||||
useRecommendedValue: boolean,
|
||||
applyFlagSettings: (settings: ReturnType<typeof effortFlagSettings>) => Promise<void>,
|
||||
): Promise<{ state: EffortState; option: SessionConfigOption | undefined; error?: unknown }> {
|
||||
const { pinnedForNewModel, before } = change;
|
||||
const sync = useRecommendedValue || (before.pinned !== undefined && !pinnedForNewModel);
|
||||
if (!sync) {
|
||||
return {
|
||||
state: pinnedForNewModel ? { pinned: before.pinned, applied: before.pinned } : before,
|
||||
option,
|
||||
};
|
||||
}
|
||||
const newEffort = typeof option?.currentValue === "string" ? option.currentValue : undefined;
|
||||
try {
|
||||
// A legacy client's unpinned effort is display-only: the CLI resolves the
|
||||
// persisted value for the new model. When an old user pin is no longer
|
||||
// supported, clear the flag layer instead of replacing it with that
|
||||
// displayed value. Opted-in clients deliberately apply their concrete
|
||||
// displayed effort.
|
||||
await applyFlagSettings(
|
||||
effortFlagSettings(useRecommendedValue ? newEffort : undefined, ultracode),
|
||||
);
|
||||
return {
|
||||
state: {
|
||||
pinned: pinnedForNewModel ? before.pinned : undefined,
|
||||
applied: useRecommendedValue && newEffort !== "default" ? newEffort : undefined,
|
||||
},
|
||||
option,
|
||||
};
|
||||
} catch (error) {
|
||||
const applied = change.appliedBeforeSwitch;
|
||||
const selectable =
|
||||
applied !== undefined &&
|
||||
option?.type === "select" &&
|
||||
option.options.some((entry) =>
|
||||
"value" in entry
|
||||
? entry.value === applied
|
||||
: entry.options.some((nested) => nested.value === applied),
|
||||
);
|
||||
return {
|
||||
state: { pinned: before.pinned, applied },
|
||||
option:
|
||||
selectable && option?.type === "select" ? { ...option, currentValue: applied } : undefined,
|
||||
error,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
81
src/session-env.ts
Normal file
81
src/session-env.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Session options that come from the agent's environment, shared by the v1
|
||||
* core and the v2 core.
|
||||
*/
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import type { Options, Settings, ThinkingConfig } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Translate the legacy `MAX_THINKING_TOKENS` env var, plus the optional
|
||||
* `CLAUDE_CODE_THINKING_DISPLAY` env var, into the SDK's `thinking` option.
|
||||
* The `maxThinkingTokens` option it used to feed is deprecated and reduced
|
||||
* to on/off on current models, so map the value to explicit thinking config
|
||||
* instead: unset → `undefined` (SDK default, adaptive on models that support
|
||||
* it) unless a display mode is requested, in which case it becomes explicit
|
||||
* `adaptive`; `0` → disabled; a positive integer → a fixed token budget.
|
||||
* Anything else is ignored with a warning. `CLAUDE_CODE_THINKING_DISPLAY`
|
||||
* must be `summarized` or `omitted`; it has no effect when thinking is
|
||||
* disabled. */
|
||||
export function resolveThinkingConfig(
|
||||
rawTokens: string | undefined,
|
||||
rawDisplay: string | undefined,
|
||||
logger: { error: (...args: unknown[]) => void },
|
||||
): ThinkingConfig | undefined {
|
||||
let display: "summarized" | "omitted" | undefined;
|
||||
if (rawDisplay !== undefined) {
|
||||
if (rawDisplay === "summarized" || rawDisplay === "omitted") {
|
||||
display = rawDisplay;
|
||||
} else {
|
||||
logger.error(
|
||||
`Ignoring CLAUDE_CODE_THINKING_DISPLAY: expected 'summarized' or 'omitted', got '${rawDisplay}'.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (rawTokens === undefined) {
|
||||
return display ? { type: "adaptive", display } : undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(rawTokens, 10);
|
||||
if (Number.isNaN(parsed) || parsed < 0) {
|
||||
logger.error(
|
||||
`Ignoring MAX_THINKING_TOKENS: expected a non-negative integer, got '${rawTokens}'.`,
|
||||
);
|
||||
return display ? { type: "adaptive", display } : undefined;
|
||||
}
|
||||
return parsed === 0
|
||||
? { type: "disabled" }
|
||||
: { type: "enabled", budgetTokens: parsed, ...(display && { display }) };
|
||||
}
|
||||
|
||||
/** The `CLAUDE_MODEL_CONFIG` JSON: Bedrock model overrides and an allowlist, say. */
|
||||
function parseModelConfig(
|
||||
raw: string | undefined,
|
||||
): { modelOverrides?: Record<string, string>; availableModels?: string[] } | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
|
||||
}
|
||||
const result: { modelOverrides?: Record<string, string>; availableModels?: string[] } = {};
|
||||
if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides;
|
||||
if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels;
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The programmatic settings of a session, before the provider routing: the
|
||||
* models of `CLAUDE_MODEL_CONFIG`, then the client's `settings` option, an
|
||||
* object or a file path relative to `cwd`. Throws for a `CLAUDE_MODEL_CONFIG`
|
||||
* that is no JSON object, before it reads the file.
|
||||
*/
|
||||
export async function configuredSettings(
|
||||
provided: Options["settings"],
|
||||
cwd: string,
|
||||
): Promise<Settings | undefined> {
|
||||
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
||||
const object =
|
||||
typeof provided === "string"
|
||||
? (JSON.parse(await fs.readFile(path.resolve(cwd, provided), "utf8")) as Settings)
|
||||
: (provided as Settings | undefined);
|
||||
return modelConfig ? { ...modelConfig, ...object } : object;
|
||||
}
|
||||
190
src/session-fast-mode.ts
Normal file
190
src/session-fast-mode.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* The Fast mode toggle of a session: its config option, the value a
|
||||
* `session/set_config_option` request sets, and the state Claude Code reports
|
||||
* (`fast_mode_state` and `fast_mode_disabled_reason`, on `system`/init and on
|
||||
* results). Shared by the v1 core and the v2 core.
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
SessionConfigOption,
|
||||
SetSessionConfigOptionRequest,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { FastModeDisabledReason, FastModeState } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { sentenceCase } from "./session-notices.js";
|
||||
|
||||
export const FAST_MODE_CONFIG_ID = "fast";
|
||||
|
||||
/** Select-fallback values used when the client has not opted into boolean
|
||||
* config options (see {@link createFastModeConfigOption}). */
|
||||
export const FAST_MODE_ON = "on";
|
||||
export const FAST_MODE_OFF = "off";
|
||||
const FAST_MODE_DESCRIPTION = "Faster responses on supported models";
|
||||
|
||||
/** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle.
|
||||
* `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK
|
||||
* docs) keeps the toggle on so it reflects the user's intent — only an
|
||||
* explicit `off` clears it. */
|
||||
export function fastModeStateEnabled(state: FastModeState): boolean {
|
||||
return state !== "off";
|
||||
}
|
||||
|
||||
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
|
||||
* that a user can act on (or at least wants to know about). Deliberately
|
||||
* partial — the omitted reasons are not worth surfacing:
|
||||
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
|
||||
* opt-in), so it describes the default, not a problem.
|
||||
* - `preference`: the user turned Fast mode off themselves.
|
||||
* - `pending`: eligibility is still resolving; the next report supersedes it.
|
||||
* - `unknown`: nothing meaningful to say.
|
||||
* Unknown future reasons fall through the same way (open set — the SDK's docs
|
||||
* say to ignore values you don't handle). */
|
||||
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
|
||||
free: "not available on the free plan",
|
||||
extra_usage_disabled: "requires extra usage to be enabled for this account",
|
||||
model_not_allowed: "not available for the selected model",
|
||||
not_first_party: "not available on this API provider",
|
||||
disabled_by_env: "disabled by environment configuration",
|
||||
network_error: "eligibility could not be verified (network error)",
|
||||
};
|
||||
|
||||
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
|
||||
* a reason we have an explanation for, else `undefined`. Keeping only
|
||||
* explainable reasons means state comparisons (see `fastModeStateChange`) track
|
||||
* exactly what the user can see, so routine reports like
|
||||
* `sdk_opt_in_required` never churn the config option. */
|
||||
export function normalizeFastModeDisabledReason(
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
): FastModeDisabledReason | undefined {
|
||||
return reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
|
||||
}
|
||||
|
||||
/** Build the Fast mode config option. When the Client supports boolean config
|
||||
* options we expose a native `type: "boolean"` toggle; otherwise we degrade to
|
||||
* a two-value `select` ("on"/"off") so older Clients still get a usable
|
||||
* control.
|
||||
*
|
||||
* `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
|
||||
* description while the toggle reads off, so a user whose account or provider
|
||||
* can't serve Fast mode sees why instead of a switch that silently refuses to
|
||||
* stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
|
||||
* state isn't blocking anything right now. */
|
||||
export function createFastModeConfigOption(
|
||||
enabled: boolean,
|
||||
useBooleanOption: boolean,
|
||||
disabledReason?: FastModeDisabledReason,
|
||||
): SessionConfigOption {
|
||||
const explanation = enabled
|
||||
? undefined
|
||||
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
|
||||
const base = {
|
||||
id: FAST_MODE_CONFIG_ID,
|
||||
name: "Fast mode",
|
||||
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : FAST_MODE_DESCRIPTION,
|
||||
category: "model_config",
|
||||
} as const;
|
||||
|
||||
if (useBooleanOption) {
|
||||
return { ...base, type: "boolean", currentValue: enabled };
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
type: "select",
|
||||
currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF,
|
||||
options: [
|
||||
{ value: FAST_MODE_ON, name: "On" },
|
||||
{ value: FAST_MODE_OFF, name: "Off" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the requested Fast mode value from a `session/set_config_option`
|
||||
* request. Accepts a native boolean (boolean-capable Clients) or the
|
||||
* "on"/"off" select-fallback strings. */
|
||||
export function resolveFastModeEnabled(params: SetSessionConfigOptionRequest): boolean {
|
||||
const value = params.value;
|
||||
if (typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
if (value === FAST_MODE_ON) {
|
||||
return true;
|
||||
}
|
||||
if (value === FAST_MODE_OFF) {
|
||||
return false;
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
undefined,
|
||||
`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Per-model Fast mode state threaded into `buildConfigOptions`. The
|
||||
* option is only surfaced when the current model `supported`s fast mode. */
|
||||
export type FastModeOptionState = {
|
||||
supported: boolean;
|
||||
enabled: boolean;
|
||||
/** Whether the Client opted into boolean config options. */
|
||||
useBooleanOption: boolean;
|
||||
/** Latest explainable `fast_mode_disabled_reason`, folded into the option's
|
||||
* description while the toggle reads off. */
|
||||
disabledReason?: FastModeDisabledReason;
|
||||
};
|
||||
|
||||
/** What a reported `fast_mode_state` changes in a Fast mode toggle that the
|
||||
* current model shows. The SDK can flip fast mode independently of the
|
||||
* user — e.g. back to `on` once a rate-limit `cooldown` clears — so
|
||||
* definitive on/off changes are mirrored into the option.
|
||||
*
|
||||
* Undefined when nothing changes: an absent state, a `cooldown` (a transient
|
||||
* suspension of an already-enabled fast mode: the toggle stays as-is rather
|
||||
* than flapping, and a stray cooldown never enables a toggle the user has
|
||||
* off), or the same state with the same explainable reason.
|
||||
*
|
||||
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
|
||||
* state. Only explainable reasons are retained (see
|
||||
* {@link normalizeFastModeDisabledReason}), so a routine
|
||||
* `sdk_opt_in_required` report on every turn's result can't churn the
|
||||
* option, while a real blocker updates the description even when the
|
||||
* toggle's own value is unchanged.
|
||||
*
|
||||
* `notice` is set when the user had Fast mode on and the SDK turned it off
|
||||
* for an explainable reason: the description carries the same explanation,
|
||||
* but a toggle silently snapping back is worth saying out loud once. */
|
||||
export function fastModeStateChange(
|
||||
current: { enabled: boolean; disabledReason?: FastModeDisabledReason },
|
||||
state: FastModeState | undefined,
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
):
|
||||
| { enabled: boolean; disabledReason?: FastModeDisabledReason; notice?: FastModeOffNotice }
|
||||
| undefined {
|
||||
if (state === undefined || state === "cooldown") return undefined;
|
||||
const enabled = state === "on";
|
||||
// A reason only describes an off state; drop any that rides an `on` report
|
||||
// so it can't decorate the option the next time fast mode goes off.
|
||||
const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
|
||||
if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined;
|
||||
const explanation =
|
||||
disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined;
|
||||
if (!current.enabled || enabled || explanation === undefined) {
|
||||
return { enabled, disabledReason };
|
||||
}
|
||||
return {
|
||||
enabled,
|
||||
disabledReason,
|
||||
notice: {
|
||||
notice: {
|
||||
severity: "warning",
|
||||
title: "Fast mode turned off",
|
||||
description: `${sentenceCase(explanation)}.`,
|
||||
},
|
||||
transcript: `**Fast mode turned off:** ${explanation}.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The notice of Fast mode that the SDK turned off, and its transcript line
|
||||
* for a client without `notice` updates. */
|
||||
export type FastModeOffNotice = {
|
||||
notice: { severity: "warning"; title: string; description: string };
|
||||
transcript: string;
|
||||
};
|
||||
@@ -121,13 +121,11 @@ export class SessionIndexConnection {
|
||||
|
||||
constructor(private readonly host: SessionIndexHost) {}
|
||||
|
||||
/** Sets the index up for the client of `initialize`. ACP v2 does not route
|
||||
* the session index methods yet. */
|
||||
negotiate(request: InitializeRequest, options: { v2: boolean }): void {
|
||||
/** Sets the index up for the client of `initialize`. */
|
||||
negotiate(request: InitializeRequest): void {
|
||||
this.service?.dispose();
|
||||
this.service = undefined;
|
||||
if (
|
||||
!options.v2 &&
|
||||
this.host.isAirClient() &&
|
||||
clientSupportsAirCapability(request.clientCapabilities, AIR_SESSION_INDEX_CAPABILITY)
|
||||
) {
|
||||
|
||||
@@ -405,7 +405,7 @@ export class SessionModeManager<S extends SessionMode> {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAvailableModes(
|
||||
export function buildAvailableModes(
|
||||
{ allowBypass, allowAuto }: ModeAvailability,
|
||||
air = false,
|
||||
): SessionModeState["availableModes"] {
|
||||
|
||||
@@ -84,39 +84,73 @@ function versionedModelDisplayName(model: ModelInfo): string {
|
||||
return `${withoutContext} ${version}${contextSuffix[0]}`;
|
||||
}
|
||||
|
||||
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
|
||||
const lower = model.trim().toLowerCase();
|
||||
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
|
||||
const tokens = lower
|
||||
.replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ")
|
||||
function modelWords(value: string): string[] {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
if (token === "opusplan") return "opus";
|
||||
if (token === "best" || token === "default") return "";
|
||||
return token;
|
||||
})
|
||||
.filter((token) => token && token !== "claude")
|
||||
.filter((token) => /[a-z]/.test(token) || token.endsWith("m"));
|
||||
return { tokens, contextHint };
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number {
|
||||
const haystack = `${model.value} ${model.displayName}`.toLowerCase();
|
||||
let score = 0;
|
||||
let nonHintMatched = false;
|
||||
for (const token of tokens) {
|
||||
if (!haystack.includes(token)) continue;
|
||||
if (token !== contextHint) nonHintMatched = true;
|
||||
score += token === contextHint ? 3 : 1;
|
||||
/** Whether `needle`'s words appear in `haystack`'s words, adjacent and in
|
||||
* order: "opus" in "claude-opus-4-5", but not "son" in "sonnet" or "a" in
|
||||
* "Default (recommended)". */
|
||||
function containsWords(haystack: string, needle: string): boolean {
|
||||
const words = modelWords(haystack);
|
||||
const wanted = modelWords(needle);
|
||||
if (wanted.length === 0) return false;
|
||||
for (let start = 0; start + wanted.length <= words.length; start++) {
|
||||
if (wanted.every((word, offset) => words[start + offset] === word)) return true;
|
||||
}
|
||||
return contextHint && !nonHintMatched ? 0 : score;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** `id` without the gateway spellings that `normalizeModelId` keeps for
|
||||
* `deniedModels` and gateway listings: path segments before `claude-`
|
||||
* (`vertex_ai/`, `openrouter/anthropic/`, `publishers/anthropic/models/`)
|
||||
* and the Anthropic API's `-latest` suffix (`claude-3-5-haiku-latest`). */
|
||||
function withoutGatewaySpelling(id: string): string {
|
||||
return id.replace(/^(?:[^/]+\/)+(?=claude-)/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
/** The words of `preference` that name a model: without what
|
||||
* `normalizeModelId` drops (provider prefixes, dates, `-v1:0`, `-fast`,
|
||||
* context hints), version numbers, "claude", and "best"/"default".
|
||||
* "us.anthropic.claude-sonnet-4-5-v1:0" gives ["sonnet"], "opusplan" gives
|
||||
* ["opus"]. */
|
||||
function modelPreferenceTokens(preference: string): string[] {
|
||||
return modelWords(withoutGatewaySpelling(normalizeModelId(preference)))
|
||||
.map((token) => (token === "opusplan" ? "opus" : token))
|
||||
.filter((token) => /[a-z]/.test(token) && !["claude", "best", "default"].includes(token));
|
||||
}
|
||||
|
||||
/** Whether every one of `tokens` is a word of `model`'s value or display name. */
|
||||
function modelCoversTokens(model: ModelInfo, tokens: string[]): boolean {
|
||||
const words = new Set(modelWords(`${model.value} ${model.displayName}`));
|
||||
return tokens.length > 0 && tokens.every((token) => words.has(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* The entry of `models` that `preference` names, or null when it names none.
|
||||
* `best` names the entry `opus` resolves to, unless an entry's value is `best`.
|
||||
* In order: an entry whose value, display name or `resolvedModel` equals it
|
||||
* (in either context-hint spelling); an entry whose value or display name
|
||||
* contains its words ("opus" for "claude-opus-4-5"), or whose value it
|
||||
* contains while every model word of the preference is the entry's
|
||||
* ("sonnet" for "us.anthropic.claude-sonnet-5-v1:0"); then any entry with all
|
||||
* of its model words, preferring one with the same context hint ("sonnet[1m]"
|
||||
* for "claude-sonnet-5-1m"). The fuzzy tiers need a compatible version, and
|
||||
* words match whole: "not-a-model", "son" and "foo-sonnet" name no entry.
|
||||
*/
|
||||
export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null {
|
||||
const trimmed = preference.trim();
|
||||
if (!trimmed) return null;
|
||||
const lower = trimmed.toLowerCase();
|
||||
// The CLI resolves `best` to its flagship: Opus, unless a server-side flag
|
||||
// names another family that this adapter cannot read. The CLI ignores a
|
||||
// `[1m]` hint on `best`. A listed entry named `best` still matches directly.
|
||||
if (stripContextHints(lower) === "best" && !models.some((model) => model.value === trimmed)) {
|
||||
return resolveModelPreference(models, "opus");
|
||||
}
|
||||
const canonicalPreference = canonicalizeModelId(trimmed);
|
||||
const directMatch = models.find(
|
||||
(model) =>
|
||||
@@ -134,28 +168,26 @@ export function resolveModelPreference(models: ModelInfo[], preference: string):
|
||||
if (resolvedMatch) return resolvedMatch;
|
||||
|
||||
const preferenceHint = contextHintOf(trimmed);
|
||||
const tokens = modelPreferenceTokens(trimmed);
|
||||
const includesMatch = models.find((model) => {
|
||||
if (!modelVersionsCompatible(trimmed, model)) return false;
|
||||
if (contextHintOf(model.value) !== preferenceHint) return false;
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
return (
|
||||
containsWords(model.value, trimmed) ||
|
||||
containsWords(model.displayName, trimmed) ||
|
||||
(containsWords(trimmed, model.value) && modelCoversTokens(model, tokens))
|
||||
);
|
||||
});
|
||||
if (includesMatch) return includesMatch;
|
||||
|
||||
const { tokens, contextHint } = tokenizeModelPreference(trimmed);
|
||||
if (tokens.length === 0) return null;
|
||||
let bestMatch: ModelInfo | null = null;
|
||||
let bestScore = 0;
|
||||
for (const model of models) {
|
||||
if (!modelVersionsCompatible(trimmed, model)) continue;
|
||||
const score = scoreModelMatch(model, tokens, contextHint);
|
||||
if (score > 0 && (!bestMatch || score > bestScore)) {
|
||||
bestMatch = model;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return bestMatch;
|
||||
const candidates = models.filter(
|
||||
(model) => modelVersionsCompatible(trimmed, model) && modelCoversTokens(model, tokens),
|
||||
);
|
||||
return (
|
||||
candidates.find((model) => contextHintOf(model.value) === preferenceHint) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo {
|
||||
@@ -517,6 +549,61 @@ export function narrowToGatewayModels(
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The capabilities of the session's current model, and the model infos that
|
||||
* every later lookup reads.
|
||||
*
|
||||
* A resumed session can be running a model outside the `availableModels`
|
||||
* allowlist (currentModelId is then the verbatim live id, see
|
||||
* `matchResumedModel`); its capabilities are still known to the SDK's
|
||||
* unfiltered list, so they are resolved from that before the model is treated
|
||||
* as unknown — otherwise auto mode would be spuriously clamped and the
|
||||
* Fast-mode/Effort options hidden for a model that supports them.
|
||||
*
|
||||
* The fallback-resolved capabilities are registered under the verbatim live
|
||||
* id, so every modelInfos consumer (the effort option, later rebuilds) agrees
|
||||
* with that gating. `buildModelConfigOption` lists this model as its own
|
||||
* entry while it is current and drops it once the user switches to a listed
|
||||
* model; it is never added to `availableModels`. The spread keeps every
|
||||
* capability flag (current and future); the identity fields are overridden
|
||||
* because the fuzzy-matched sibling's resolvedModel/displayName/description
|
||||
* can describe a different context lane and would poison later resolvedModel
|
||||
* matching (a model switch that the adapter did not make) and context-window
|
||||
* inference if they traveled under this id. `allowlistedModelInfo` is the
|
||||
* allowlisted entry alone, the only one whose identity strings are a
|
||||
* trustworthy window signal.
|
||||
*/
|
||||
export function withCurrentModelInfo(
|
||||
allowedModels: ModelInfo[],
|
||||
sdkModels: ModelInfo[],
|
||||
currentModelId: string,
|
||||
): {
|
||||
allowlistedModelInfo: ModelInfo | undefined;
|
||||
currentModelInfo: ModelInfo | undefined;
|
||||
modelInfos: ModelInfo[];
|
||||
} {
|
||||
const allowlistedModelInfo = allowedModels.find((m) => m.value === currentModelId);
|
||||
const fallbackModelInfo = allowlistedModelInfo
|
||||
? undefined
|
||||
: (resolveModelPreference(sdkModels, currentModelId) ?? undefined);
|
||||
return {
|
||||
allowlistedModelInfo,
|
||||
currentModelInfo: allowlistedModelInfo ?? fallbackModelInfo,
|
||||
modelInfos: fallbackModelInfo
|
||||
? [
|
||||
...allowedModels,
|
||||
{
|
||||
...fallbackModelInfo,
|
||||
value: currentModelId,
|
||||
displayName: currentModelId,
|
||||
description: "",
|
||||
resolvedModel: undefined,
|
||||
},
|
||||
]
|
||||
: allowedModels,
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether a rejected `setModel` was vetoed by a user-configured
|
||||
* PreModelSwitch hook. The CLI's rejection message is the stable,
|
||||
* distinguishable marker ("Model switch blocked by a PreModelSwitch hook:
|
||||
|
||||
@@ -118,3 +118,98 @@ export function splitNoticeText(
|
||||
}
|
||||
return rest ? { title: firstLine, description: rest } : { title: firstLine };
|
||||
}
|
||||
|
||||
/** The notice of Claude Code's `informational` message (e.g. why a Stop hook
|
||||
* blocked continuation): a warning at level `warning`, else info. */
|
||||
export function informationalNotice(message: { level: string; content: string }): SessionNotice {
|
||||
const severity = message.level === "warning" ? "warning" : "info";
|
||||
return {
|
||||
severity,
|
||||
...splitNoticeText(
|
||||
message.content,
|
||||
severity === "warning" ? "Claude reported a warning" : "Claude reported a notice",
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The notice of a client MCP server that the `system/init` lists as not
|
||||
* started: `failed`, or `needs-auth` when the client cannot run the OAuth
|
||||
* flow (`oauthAvailable`). Undefined for any other status. */
|
||||
export function mcpServerStartupNotice(
|
||||
server: { name: string; status: string },
|
||||
oauthAvailable: boolean,
|
||||
): SessionNotice | undefined {
|
||||
if (server.status === "failed") {
|
||||
return {
|
||||
severity: "warning",
|
||||
title: `MCP server "${server.name}" failed to start`,
|
||||
description: "Its tools are unavailable in this session. Run /mcp for the error.",
|
||||
};
|
||||
}
|
||||
if (server.status === "needs-auth" && !oauthAvailable) {
|
||||
return {
|
||||
severity: "info",
|
||||
title: `MCP server "${server.name}" needs authentication`,
|
||||
description: "Its tools are unavailable until the server is authenticated.",
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What Claude Code's `model_refusal_fallback` tells the user: the SDK retried
|
||||
* a refused turn on the fallback model.
|
||||
*
|
||||
* Current CLIs only emit direction "retry", a swap that lasts for the
|
||||
* session. "revert"/"sticky" are retained in the SDK enum for older CLIs,
|
||||
* where "revert" marked a turn-only fallback: the session stays on the
|
||||
* original model. `scope` (CLI 2.1.232+) "local" means a subagent,
|
||||
* side-question or background fork response fell back and the session model
|
||||
* is unchanged. Absent scope means an older CLI, where every retry was a
|
||||
* session-level swap. `persistent` is whether the session now runs
|
||||
* `fallback_model`, so its model option must follow.
|
||||
*/
|
||||
export function modelRefusalFallback(message: {
|
||||
original_model: string;
|
||||
fallback_model: string;
|
||||
direction?: string;
|
||||
scope?: string;
|
||||
api_refusal_category?: string | null;
|
||||
api_refusal_explanation?: string | null;
|
||||
}): {
|
||||
persistent: boolean;
|
||||
/** The one-line summary. */
|
||||
summary: string;
|
||||
explanation: string | undefined;
|
||||
/** The notice: the summary as its title when it is short enough, else a
|
||||
* generic title with everything in the description. */
|
||||
notice: SessionNotice;
|
||||
/** The transcript line for a client without `notice` updates. */
|
||||
transcript: string;
|
||||
} {
|
||||
const local = message.scope === "local";
|
||||
const persistent = message.direction !== "revert" && !local;
|
||||
const category = message.api_refusal_category ? ` (${message.api_refusal_category})` : "";
|
||||
const outcome = persistent
|
||||
? `The session will continue on ${message.fallback_model}.`
|
||||
: local
|
||||
? `Only that response came from ${message.fallback_model}; the session stays on ${message.original_model}.`
|
||||
: `The session stays on ${message.original_model}.`;
|
||||
const summary =
|
||||
`${message.original_model} declined this request${category}; ` +
|
||||
`retried with ${message.fallback_model}. ${outcome}`;
|
||||
const explanation = message.api_refusal_explanation || undefined;
|
||||
const full = explanation ? `${summary}\n\n${explanation}` : summary;
|
||||
return {
|
||||
persistent,
|
||||
summary,
|
||||
explanation,
|
||||
notice: {
|
||||
severity: "warning",
|
||||
...(summary.length > MAX_NOTICE_TITLE_LENGTH
|
||||
? { title: "Model fallback", description: full }
|
||||
: { title: summary, ...(explanation ? { description: explanation } : {}) }),
|
||||
},
|
||||
transcript: `**Model fallback:** ${full}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,19 +11,42 @@
|
||||
* `CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC` or
|
||||
* `CLAUDE_CODE_DISABLE_TERMINAL_TITLE` set), the same request generates it.
|
||||
*
|
||||
* {@link SessionTitles} holds the per-session state and is owned by `Session`.
|
||||
* `acp-agent.ts` drives it from five places: {@link SessionTitles.onPrompt} and
|
||||
* {@link SessionTitles.onAssistantText} to collect text,
|
||||
* {@link SessionTitles.onPromptQueued} once a prompt is sent to the CLI,
|
||||
* {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`, and
|
||||
* {@link SessionTitles.reset} on `conversation_reset`.
|
||||
* {@link SessionTitles} holds the per-session state and is owned by the
|
||||
* session, in the v1 core (`acp-agent.ts`) and the v2 core
|
||||
* (`v2/session.ts`). Each drives it from the same places:
|
||||
* {@link SessionTitles.onPrompt} and {@link SessionTitles.onAssistantText} to
|
||||
* collect text, {@link SessionTitles.onPromptQueued} once a prompt is sent to
|
||||
* the CLI, {@link SessionTitles.onTurnEnd} at `session_state_changed: idle`,
|
||||
* and, in v1, {@link SessionTitles.reset} on `conversation_reset`.
|
||||
*/
|
||||
|
||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
ContentBlock,
|
||||
PromptRequest,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { getSessionInfo, type Query, type SDKSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent, Session } from "./acp-agent.js";
|
||||
import { ExplicitTitle } from "./session-index/explicit-title.js";
|
||||
|
||||
/** What titles read of their session. */
|
||||
export interface TitledSession {
|
||||
readonly query: Query;
|
||||
readonly queryClosed?: boolean;
|
||||
/** Whether a cancel stopped the session's work: no title is generated then. */
|
||||
readonly cancelled?: boolean;
|
||||
readonly cwd: string;
|
||||
}
|
||||
|
||||
/** What titles need of the core that serves the session (`ClaudeAcpAgent` in v1). */
|
||||
export interface TitleOwner {
|
||||
readonly client: { sessionUpdate(notification: SessionNotification): Promise<void> };
|
||||
readonly logger: { error: (...args: any[]) => void };
|
||||
/** The live sessions by id: a session replaced or closed meanwhile adopts no title. */
|
||||
readonly sessions: Readonly<Record<string, TitledSession | undefined>>;
|
||||
/** The title as the client shows it, such as with an archive prefix removed. */
|
||||
clientTitle?(title: string): string;
|
||||
}
|
||||
|
||||
const MAX_TITLE_LENGTH = 256;
|
||||
|
||||
/** How much session text the title is generated from. The CLI caps its own
|
||||
@@ -137,7 +160,7 @@ export class SessionTitles {
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly agent: ClaudeAcpAgent,
|
||||
private readonly agent: TitleOwner,
|
||||
private readonly sessionId: string,
|
||||
) {}
|
||||
|
||||
@@ -158,7 +181,7 @@ export class SessionTitles {
|
||||
* prompt alone is the description, because the CLI titles from the prompt
|
||||
* alone. When this yields no title, the stored summary is published and
|
||||
* the next prompt tries again. */
|
||||
onPromptQueued(session: Session, prompt: PromptRequest["prompt"]): void {
|
||||
onPromptQueued(session: TitledSession, prompt: PromptRequest["prompt"]): void {
|
||||
this.askedAtPrompt = false;
|
||||
// `session.cancelled` is not checked: after a cancelled turn it stays set
|
||||
// until the next turn starts, which is after this prompt is queued.
|
||||
@@ -209,7 +232,7 @@ export class SessionTitles {
|
||||
* turn-end must not wait on it. That covers a prompt too short to title
|
||||
* from, such as "hi". Only when that is not possible do we fall back to
|
||||
* `summary`, which for an SDK-driven session is just the raw first prompt. */
|
||||
async onTurnEnd(session: Session): Promise<void> {
|
||||
async onTurnEnd(session: TitledSession): Promise<void> {
|
||||
if (this.explicit.settledByClient) {
|
||||
// No title is generated over the one the client set, but a custom title
|
||||
// stored since by someone else (a `/rename`) is adopted, as before.
|
||||
@@ -301,7 +324,7 @@ export class SessionTitles {
|
||||
* have it. Both come from the session file, as `session/list` reports
|
||||
* them; a session with no file yet sends the last title published, if any,
|
||||
* without `updatedAt`. */
|
||||
async publishCurrent(session: Session): Promise<void> {
|
||||
async publishCurrent(session: TitledSession): Promise<void> {
|
||||
const info = await this.readSessionInfo(session);
|
||||
// A title published but not yet stored (a generated title still being
|
||||
// written) wins over `summary`, which is then only the raw first prompt.
|
||||
@@ -329,7 +352,7 @@ export class SessionTitles {
|
||||
|
||||
/** Read the SDK's stored info for this session. A missing session file or read
|
||||
* error is non-fatal: the title is best-effort and another turn will retry. */
|
||||
private async readSessionInfo(session: Session): Promise<SDKSessionInfo | undefined> {
|
||||
private async readSessionInfo(session: TitledSession): Promise<SDKSessionInfo | undefined> {
|
||||
try {
|
||||
return await getSessionInfo(this.sessionId, { dir: session.cwd });
|
||||
} catch (error) {
|
||||
@@ -357,7 +380,7 @@ export class SessionTitles {
|
||||
|
||||
/** Whether to ask the SDK for a generated title now: once per session, on a
|
||||
* live query, with enough collected text for the generator to work with. */
|
||||
private canRequest(session: Session): boolean {
|
||||
private canRequest(session: TitledSession): boolean {
|
||||
if (this.settled || session.queryClosed || session.cancelled) {
|
||||
return false;
|
||||
}
|
||||
@@ -383,7 +406,7 @@ export class SessionTitles {
|
||||
* session's title, or a `/rename`), else one generated from `description`.
|
||||
* The file is read first because the CLI checks only the titles it holds in
|
||||
* memory before generating one. */
|
||||
private async titleFromPrompt(session: Session, description: string): Promise<void> {
|
||||
private async titleFromPrompt(session: TitledSession, description: string): Promise<void> {
|
||||
const info = await this.readSessionInfo(session);
|
||||
if (this.agent.sessions[this.sessionId] !== session || this.explicit.active) {
|
||||
return;
|
||||
@@ -405,7 +428,7 @@ export class SessionTitles {
|
||||
* prompt or turn end retries, and publishes `fallback` (the stored summary)
|
||||
* so a backend that can't generate titles is no worse off than before. */
|
||||
private async requestGenerateTitle(
|
||||
session: Session,
|
||||
session: TitledSession,
|
||||
description: string,
|
||||
fallback: TitleFallback | undefined,
|
||||
): Promise<void> {
|
||||
|
||||
259
src/session-usage.ts
Normal file
259
src/session-usage.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
/**
|
||||
* Token usage as Claude Code reports it: the context window figures of
|
||||
* `usage_update` and the `_meta.quota` of a turn. Shared by the v1 core and
|
||||
* the v2 core.
|
||||
*/
|
||||
import type { ModelUsage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export type AccumulatedUsage = {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedReadTokens: number;
|
||||
cachedWriteTokens: number;
|
||||
};
|
||||
|
||||
/** Per-model token tallies keyed by the model id the SDK reported them under
|
||||
* (its resolved spelling, e.g. "claude-opus-5[1m]"). */
|
||||
export type ModelTokenTally = Record<string, AccumulatedUsage>;
|
||||
|
||||
export type UsageSnapshot = {
|
||||
input_tokens: number;
|
||||
output_tokens: number;
|
||||
cache_read_input_tokens: number;
|
||||
cache_creation_input_tokens: number;
|
||||
};
|
||||
|
||||
export const ZERO_USAGE = Object.freeze({
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
});
|
||||
|
||||
export const DEFAULT_CONTEXT_WINDOW = 200000;
|
||||
|
||||
/** `_meta.quota` for work that spent `usage` in its main agent loop and
|
||||
* `modelUsage` by model (see `turnQuotaMeta` in `acp-agent.ts`). */
|
||||
export function quotaMeta(usage: AccumulatedUsage, modelUsage: ModelTokenTally | undefined) {
|
||||
return {
|
||||
quota: {
|
||||
token_count: quotaTokenCount(usage),
|
||||
model_usage: Object.entries(modelUsage ?? {}).map(([model, usage]) => ({
|
||||
model,
|
||||
token_count: quotaTokenCount(usage),
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** One `token_count` object. `cachedInputTokens` is cache reads, matching
|
||||
* codex's field; Claude also reports cache writes, which codex has no slot
|
||||
* for, so those ride along in an extra sibling (the same name the ACP `usage`
|
||||
* field already uses) and are counted in `totalTokens`. `reasoningOutputTokens`
|
||||
* is always 0: Claude bills thinking inside its output tokens and never breaks
|
||||
* it out — the key is kept so the shape stays uniform across agents. */
|
||||
function quotaTokenCount(usage: AccumulatedUsage) {
|
||||
return {
|
||||
totalTokens: tallyTotal(usage),
|
||||
inputTokens: usage.inputTokens,
|
||||
cachedInputTokens: usage.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens,
|
||||
outputTokens: usage.outputTokens,
|
||||
reasoningOutputTokens: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/** Sum a tally's four counters. Per the Anthropic API `inputTokens` excludes
|
||||
* the cache counters, so this is not double-counting (see `totalTokens`). */
|
||||
export function tallyTotal(usage: AccumulatedUsage): number {
|
||||
return usage.inputTokens + usage.outputTokens + usage.cachedReadTokens + usage.cachedWriteTokens;
|
||||
}
|
||||
|
||||
/** Project `result.modelUsage` into our tally shape. Counters are coerced the
|
||||
* way `snapshotFromUsage` coerces the stream's: third-party backends have been
|
||||
* observed omitting fields, and a missing or NaN counter must not reach the
|
||||
* wire as NaN (which `JSON.stringify` writes as `null`). */
|
||||
export function normalizeModelUsage(
|
||||
modelUsage: Record<string, ModelUsage> | undefined,
|
||||
): ModelTokenTally {
|
||||
const tally: ModelTokenTally = {};
|
||||
for (const [model, usage] of Object.entries(modelUsage ?? {})) {
|
||||
tally[model] = {
|
||||
inputTokens: finiteCount(usage?.inputTokens),
|
||||
outputTokens: finiteCount(usage?.outputTokens),
|
||||
cachedReadTokens: finiteCount(usage?.cacheReadInputTokens),
|
||||
cachedWriteTokens: finiteCount(usage?.cacheCreationInputTokens),
|
||||
};
|
||||
}
|
||||
return tally;
|
||||
}
|
||||
|
||||
function finiteCount(value: number | null | undefined): number {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
||||
}
|
||||
|
||||
/** `current - previous` per model, dropping models with nothing to report so a
|
||||
* turn only lists the models it actually ran on. A reading that fell BELOW the
|
||||
* previous one means the running total restarted under us (a mid-session
|
||||
* /clear, a zeroed crash result): there is no usable reference left to
|
||||
* subtract, so the reading itself is the increment. */
|
||||
export function modelUsageIncrement(
|
||||
current: ModelTokenTally,
|
||||
previous: ModelTokenTally,
|
||||
): ModelTokenTally {
|
||||
const increment: ModelTokenTally = {};
|
||||
for (const [model, usage] of Object.entries(current)) {
|
||||
const base = previous[model];
|
||||
const subtracted: AccumulatedUsage = base
|
||||
? {
|
||||
inputTokens: usage.inputTokens - base.inputTokens,
|
||||
outputTokens: usage.outputTokens - base.outputTokens,
|
||||
cachedReadTokens: usage.cachedReadTokens - base.cachedReadTokens,
|
||||
cachedWriteTokens: usage.cachedWriteTokens - base.cachedWriteTokens,
|
||||
}
|
||||
: usage;
|
||||
const rewound = Object.values(subtracted).some((count) => count < 0);
|
||||
const resolved = rewound ? usage : subtracted;
|
||||
if (tallyTotal(resolved) > 0) {
|
||||
increment[model] = resolved;
|
||||
}
|
||||
}
|
||||
return increment;
|
||||
}
|
||||
|
||||
/** The per-model rows for the first result of a resumed session. Its
|
||||
* `modelUsage` reading continues from the totals the transcript saved (SDK
|
||||
* 0.3.277+; older transcripts may hold none), so there is no earlier reading
|
||||
* to subtract from, and taking the reading itself would charge the whole
|
||||
* pre-resume history to this one turn. The result's own `usage` is per-turn
|
||||
* (main agent loop only, like the response's `usage`), so it stands in under
|
||||
* the turn's top-level model — `key` is that model's `modelUsage` spelling
|
||||
* when the reading has one, else the assistant message's own. Any subagent
|
||||
* spend on this one turn goes unlisted rather than over-listed. */
|
||||
export function resumedFirstResultModelUsage(
|
||||
usage: {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
},
|
||||
key: string | null,
|
||||
): ModelTokenTally {
|
||||
if (key === null) {
|
||||
return {};
|
||||
}
|
||||
const row: AccumulatedUsage = {
|
||||
inputTokens: finiteCount(usage.input_tokens),
|
||||
outputTokens: finiteCount(usage.output_tokens),
|
||||
cachedReadTokens: finiteCount(usage.cache_read_input_tokens),
|
||||
cachedWriteTokens: finiteCount(usage.cache_creation_input_tokens),
|
||||
};
|
||||
return tallyTotal(row) > 0 ? { [key]: row } : {};
|
||||
}
|
||||
|
||||
/** Fold `increment` into `base` per model — the per-model counterpart of the
|
||||
* `+=` the turn's flat accumulator uses. */
|
||||
export function addModelUsage(base: ModelTokenTally, increment: ModelTokenTally): ModelTokenTally {
|
||||
const merged: ModelTokenTally = { ...base };
|
||||
for (const [model, usage] of Object.entries(increment)) {
|
||||
const existing = merged[model];
|
||||
merged[model] = existing
|
||||
? {
|
||||
inputTokens: existing.inputTokens + usage.inputTokens,
|
||||
outputTokens: existing.outputTokens + usage.outputTokens,
|
||||
cachedReadTokens: existing.cachedReadTokens + usage.cachedReadTokens,
|
||||
cachedWriteTokens: existing.cachedWriteTokens + usage.cachedWriteTokens,
|
||||
}
|
||||
: usage;
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/** Sum all four fields as a proxy for post-turn context occupancy: the current
|
||||
* turn's output becomes next turn's input. Per the Anthropic API, input_tokens
|
||||
* excludes cache tokens — cache_read and cache_creation are reported
|
||||
* separately — so summing all four is not double-counting. */
|
||||
export function totalTokens(usage: UsageSnapshot): number {
|
||||
return (
|
||||
usage.input_tokens +
|
||||
usage.output_tokens +
|
||||
usage.cache_read_input_tokens +
|
||||
usage.cache_creation_input_tokens
|
||||
);
|
||||
}
|
||||
|
||||
/** Project a nullable API usage object into our non-null snapshot shape.
|
||||
* Both SDK message_start and assistant message `usage` have `number | null`
|
||||
* cache fields; we coerce absent values to 0 so `totalTokens` never hits
|
||||
* NaN. `input_tokens`/`output_tokens` are typed `number` by the SDK but
|
||||
* synthetic or third-party-backend stream events have been observed emitting
|
||||
* them as null/undefined — coerce those too so a malformed upstream event
|
||||
* can't leak NaN into the wire `used` field. Delta events have different
|
||||
* semantics (cumulative + prev fallback) and are handled inline. */
|
||||
export function snapshotFromUsage(usage: {
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
cache_read_input_tokens?: number | null;
|
||||
cache_creation_input_tokens?: number | null;
|
||||
}): UsageSnapshot {
|
||||
return {
|
||||
input_tokens: usage.input_tokens ?? 0,
|
||||
output_tokens: usage.output_tokens ?? 0,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? 0,
|
||||
cache_creation_input_tokens: usage.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function commonPrefixLength(a: string, b: string) {
|
||||
let i = 0;
|
||||
while (i < a.length && i < b.length && a[i] === b[i]) {
|
||||
i++;
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
/** Best-effort first guess of a model's context window, used to seed the
|
||||
* window synchronously (via `immediateContextWindow`) until a `result` message
|
||||
* arrives with the authoritative `modelUsage` value.
|
||||
*
|
||||
* Anthropic 1M-context variants encode "1m" as a distinct token in the SDK
|
||||
* model ID (e.g., "claude-opus-4-6-1m"), which `\b1m\b` catches without also
|
||||
* matching things like "10m" or embedded substrings. Semantic aliases like
|
||||
* `default` carry no such token in the ID, but their `resolvedModel` and the
|
||||
* SDK's human-facing `displayName`/`description` can (e.g.
|
||||
* "claude-opus-4-8[1m]", "Opus 4.7 (1M context)"), so callers pass those too.
|
||||
* This text scan can't catch every model — some resolve to extended-context
|
||||
* models with no "1m" anywhere (e.g. `sonnet` → claude-sonnet-5, natively
|
||||
* ~1M). Such a miss falls back to the default window, is refined by the
|
||||
* background `getContextUsage` (`refreshContextWindowInBackground`), and is
|
||||
* corrected by `result.modelUsage` (and cached) within one turn. */
|
||||
export function inferContextWindowFromModel(...texts: Array<string | undefined>): number | null {
|
||||
if (texts.some((text) => text != null && /\b1m\b/i.test(text))) return 1_000_000;
|
||||
return null;
|
||||
}
|
||||
|
||||
export function getMatchingModelUsage(
|
||||
modelUsage: Record<string, ModelUsage>,
|
||||
currentModel: string,
|
||||
) {
|
||||
let bestKey: string | null = null;
|
||||
let bestLen = 0;
|
||||
|
||||
for (const key of Object.keys(modelUsage)) {
|
||||
const len = commonPrefixLength(key, currentModel);
|
||||
if (len > bestLen) {
|
||||
bestLen = len;
|
||||
bestKey = key;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestKey) {
|
||||
// `bestKey` is the SDK's resolved model id (e.g. "claude-sonnet-5[1m]"),
|
||||
// the same spelling as ModelInfo.resolvedModel — the primary key the
|
||||
// window is cached under. `currentModel` (the assistant message's
|
||||
// `.model`) can be the bare form (e.g. "claude-sonnet-5"); the result
|
||||
// handler caches under that spelling too, for seed-time reads that fall
|
||||
// back to a bare id (rows without `resolvedModel`).
|
||||
return { key: bestKey, usage: modelUsage[bestKey] };
|
||||
}
|
||||
}
|
||||
86
src/streamed-text.ts
Normal file
86
src/streamed-text.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* The text and thinking of an assistant message that streamed live as
|
||||
* deltas, and what its consolidated message adds to them. Shared by the v1
|
||||
* core and the v2 core.
|
||||
*/
|
||||
import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** Text or thinking that streamed live as deltas, accumulated per block. */
|
||||
export type StreamedBlock = { index: number; type: "text" | "thinking"; text: string };
|
||||
|
||||
/**
|
||||
* Records the text or thinking that `event` streamed in `streamedBlocks`.
|
||||
* Contiguous deltas of the same block (same index and type) extend the
|
||||
* current entry; anything else opens a new one. Other events change nothing.
|
||||
*/
|
||||
export function recordStreamedDelta(
|
||||
streamedBlocks: StreamedBlock[],
|
||||
event: SDKPartialAssistantMessage["event"],
|
||||
): void {
|
||||
if (event.type !== "content_block_delta") return;
|
||||
const delta = event.delta;
|
||||
const chunk =
|
||||
delta.type === "text_delta"
|
||||
? { type: "text" as const, text: delta.text }
|
||||
: delta.type === "thinking_delta"
|
||||
? { type: "thinking" as const, text: delta.thinking }
|
||||
: undefined;
|
||||
// Skip empty deltas (some gateways emit empty thinking chunks — #793):
|
||||
// appending "" is a no-op, but pushing a "" entry would create a block the
|
||||
// `text.length > 0` guard of `unstreamedRemainder` can never consume,
|
||||
// stalling the diff cursor and re-emitting the next block as a duplicate.
|
||||
if (!chunk?.text) return;
|
||||
const last = streamedBlocks[streamedBlocks.length - 1];
|
||||
if (last && last.index === event.index && last.type === chunk.type) {
|
||||
last.text += chunk.text;
|
||||
} else {
|
||||
streamedBlocks.push({ index: event.index, type: chunk.type, text: chunk.text });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The blocks of a consolidated assistant message without the text that
|
||||
* already streamed as deltas.
|
||||
*
|
||||
* Each assembled text/thinking block is diffed against the streamed blocks in
|
||||
* document order: nothing is left if it streamed in full (the common case),
|
||||
* the whole block if it never streamed (a non-streaming gateway), and just the
|
||||
* tail if the stream was cut short mid-block. Matching on content rather than
|
||||
* the message id keeps the dedupe robust for gateways without a stable id.
|
||||
* Tool-use and other blocks pass through untouched.
|
||||
*/
|
||||
export function unstreamedRemainder<Block extends { type: string }>(
|
||||
blocks: Block[],
|
||||
streamedBlocks: StreamedBlock[],
|
||||
): Block[] {
|
||||
const kept: Block[] = [];
|
||||
let streamPos = 0;
|
||||
for (const item of blocks) {
|
||||
if (item.type !== "text" && item.type !== "thinking") {
|
||||
kept.push(item);
|
||||
continue;
|
||||
}
|
||||
const block = item as Block & { text?: string; thinking?: string };
|
||||
const full = (item.type === "text" ? block.text : block.thinking) ?? "";
|
||||
// Empty assembled blocks carry nothing: drop them.
|
||||
if (full.length === 0) continue;
|
||||
// A streamed block of the same type whose text is a prefix of this one
|
||||
// was already delivered, at least partly. A non-empty streamed text is
|
||||
// required so an empty or aborted streamed block does not swallow it.
|
||||
const streamed = streamedBlocks[streamPos];
|
||||
if (
|
||||
streamed &&
|
||||
streamed.type === item.type &&
|
||||
streamed.text.length > 0 &&
|
||||
full.startsWith(streamed.text)
|
||||
) {
|
||||
streamPos++;
|
||||
const remainder = full.slice(streamed.text.length);
|
||||
if (remainder.length === 0) continue;
|
||||
kept.push({ ...item, [item.type === "text" ? "text" : "thinking"]: remainder });
|
||||
continue;
|
||||
}
|
||||
kept.push(item);
|
||||
}
|
||||
return kept;
|
||||
}
|
||||
96
src/subagent-configuration.ts
Normal file
96
src/subagent-configuration.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* The configuration of a subagent's session, as its hooks report it. Both
|
||||
* the v1 core (`ClaudeAcpAgent.subagentConfigurationHook`, for both forms of
|
||||
* subagent sessions) and the v2 core (`v2/child-config.ts`)
|
||||
* build a child's config options with these.
|
||||
*/
|
||||
import type { SessionConfigOption, SessionModeState } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AgentInfo, HookInput } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { childModelOption, responseModelEntry, type SubagentModels } from "./native-subagents.js";
|
||||
import { AGENT_CONFIG_ID, EFFORT_CONFIG_ID } from "./session-config-ids.js";
|
||||
import { effortLevelName } from "./session-effort.js";
|
||||
import { SessionModeManager } from "./session-mode.js";
|
||||
|
||||
/** What the root session knows that a child's options name. */
|
||||
export type SubagentConfigurationContext = {
|
||||
/** The root session's modes, for the name of the child's mode. */
|
||||
availableModes: SessionModeState["availableModes"];
|
||||
/** The root session's `model` entries, for the child's model. */
|
||||
models: SubagentModels | undefined;
|
||||
/** The custom agents of the session, for the description of the child's agent type. */
|
||||
agents: readonly AgentInfo[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The config options that a hook of a subagent (`agent_id`) reports of it:
|
||||
* its permission mode, its model after a `PostModelSwitch`, its effort, and
|
||||
* its agent type. Each option lists only the child's current value. Empty
|
||||
* for a hook of the root session, and for one that reports none of these.
|
||||
*/
|
||||
export function subagentHookOptions(
|
||||
input: HookInput,
|
||||
context: SubagentConfigurationContext,
|
||||
): SessionConfigOption[] {
|
||||
if (!input.agent_id) return [];
|
||||
const options: SessionConfigOption[] = [];
|
||||
if (input.permission_mode) {
|
||||
const mode = context.availableModes.find((mode) => mode.id === input.permission_mode);
|
||||
options.push(
|
||||
SessionModeManager.configOption({
|
||||
currentModeId: input.permission_mode,
|
||||
availableModes: [mode ?? { id: input.permission_mode, name: input.permission_mode }],
|
||||
}),
|
||||
);
|
||||
}
|
||||
const switchedModel =
|
||||
input.hook_event_name === "PostModelSwitch"
|
||||
? responseModelEntry(input.to_model, context.models)
|
||||
: undefined;
|
||||
// The entry of the root session's `model` option, as for a child response.
|
||||
if (switchedModel) options.push(childModelOption(switchedModel));
|
||||
if (input.effort?.level) {
|
||||
options.push({
|
||||
id: EFFORT_CONFIG_ID,
|
||||
name: "Effort",
|
||||
description: "Current reasoning effort",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: input.effort.level,
|
||||
options: [{ value: input.effort.level, name: effortLevelName(input.effort.level) }],
|
||||
});
|
||||
}
|
||||
if (input.agent_type) {
|
||||
const agent = context.agents.find((agent) => agent.name === input.agent_type);
|
||||
options.push({
|
||||
id: AGENT_CONFIG_ID,
|
||||
name: "Agent",
|
||||
description: "Subagent type",
|
||||
type: "select",
|
||||
currentValue: input.agent_type,
|
||||
options: [
|
||||
{
|
||||
value: input.agent_type,
|
||||
name: input.agent_type,
|
||||
...(agent?.description ? { description: agent.description } : {}),
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
/**
|
||||
* A child's configuration, as it goes to the client: every option it holds,
|
||||
* the mode first, then the model, the effort and the model config, then the
|
||||
* rest in the order they came.
|
||||
*/
|
||||
export function orderedSubagentConfiguration(
|
||||
options: Iterable<SessionConfigOption>,
|
||||
): SessionConfigOption[] {
|
||||
const priority = ["mode", "model", "thought_level", "model_config"];
|
||||
const rank = (option: SessionConfigOption) => {
|
||||
const index = priority.indexOf(option.category ?? "");
|
||||
return index < 0 ? priority.length : index;
|
||||
};
|
||||
return [...options].sort((left, right) => rank(left) - rank(right));
|
||||
}
|
||||
@@ -376,17 +376,7 @@ export class RfdSubagentReplay {
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
if (missing > 0 && supportsNotices) {
|
||||
await this.publish({
|
||||
sessionId: rootSessionId,
|
||||
update: noticeUpdate({
|
||||
severity: "info",
|
||||
title: "Some subagent history is not available",
|
||||
description:
|
||||
missing === 1
|
||||
? "The history of 1 subagent of this session could not be restored."
|
||||
: `The history of ${missing} subagents of this session could not be restored.`,
|
||||
}),
|
||||
});
|
||||
await this.publish({ sessionId: rootSessionId, update: missingHistoryNotice(missing) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,12 +488,10 @@ export class RfdSubagentReplay {
|
||||
message: SessionMessage,
|
||||
text: string,
|
||||
): Promise<void> {
|
||||
const names = [child.sessionId, child.record.name, child.input.name];
|
||||
const call = this.sendMessages.find(
|
||||
(candidate) =>
|
||||
candidate.recipientSessionId === undefined &&
|
||||
names.includes(candidate.to) &&
|
||||
text.includes(candidate.message.trim()),
|
||||
const call = deliveredCall(
|
||||
this.sendMessages,
|
||||
[child.sessionId, child.record.name, child.input.name],
|
||||
text,
|
||||
);
|
||||
if (call) {
|
||||
call.recipientSessionId = child.sessionId;
|
||||
@@ -619,6 +607,38 @@ function isDirectory(dir: string): boolean {
|
||||
}
|
||||
|
||||
/** A message that a SendMessage call delivered to the child. */
|
||||
function isDelivery(message: SessionMessage): boolean {
|
||||
export function isDelivery(message: SessionMessage): boolean {
|
||||
return (message as { origin?: { kind?: unknown } | null }).origin?.kind === "coordinator";
|
||||
}
|
||||
|
||||
/**
|
||||
* The SendMessage call of `calls` that a delivered message `text` came from,
|
||||
* for the child that `names` names (its agent id, its name). Claude Code
|
||||
* frames the message for the child's model ("The coordinator sent a message
|
||||
* while you were working:", then the message, then an instruction), so it is
|
||||
* the first undelivered call to the child whose message the text contains.
|
||||
*/
|
||||
export function deliveredCall<
|
||||
T extends { to: string; message: string; recipientSessionId?: string },
|
||||
>(calls: readonly T[], names: readonly unknown[], text: string): T | undefined {
|
||||
return calls.find(
|
||||
(call) =>
|
||||
call.recipientSessionId === undefined &&
|
||||
names.includes(call.to) &&
|
||||
text.includes(call.message.trim()),
|
||||
);
|
||||
}
|
||||
|
||||
/** The notice that the history of `missing` subagents of a replayed session is not available. */
|
||||
export function missingHistoryNotice(
|
||||
missing: number,
|
||||
): Extract<AcpSessionNotification["update"], { sessionUpdate: "notice" }> {
|
||||
return noticeUpdate({
|
||||
severity: "info",
|
||||
title: "Some subagent history is not available",
|
||||
description:
|
||||
missing === 1
|
||||
? "The history of 1 subagent of this session could not be restored."
|
||||
: `The history of ${missing} subagents of this session could not be restored.`,
|
||||
}) as Extract<AcpSessionNotification["update"], { sessionUpdate: "notice" }>;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,6 @@ import {
|
||||
getSessionInfo,
|
||||
getSessionMessages,
|
||||
getSubagentMessages,
|
||||
type HookCallback,
|
||||
importSessionToStore,
|
||||
listSessions,
|
||||
type ModelInfo,
|
||||
@@ -5297,7 +5296,7 @@ describe("permission request cancellation", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -5367,14 +5366,14 @@ describe("permission request cancellation", () => {
|
||||
});
|
||||
|
||||
describe("a cancelled outcome", () => {
|
||||
function setup(options: { v2?: boolean } = {}) {
|
||||
function setup() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (): Promise<RequestPermissionResponse> => ({
|
||||
outcome: { outcome: "cancelled" },
|
||||
}),
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }, options);
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
const session = injectSession(agent, "session-1");
|
||||
session.emittedToolCalls.add("tool-1");
|
||||
const ask = (agentID?: string) =>
|
||||
@@ -5396,29 +5395,6 @@ describe("permission request cancellation", () => {
|
||||
await expect(ask("agent-fg")).rejects.toThrow("Tool use aborted");
|
||||
});
|
||||
|
||||
it("denies the tool use of foreground work on v2 without a session/cancel", async () => {
|
||||
// On v2 the client also answers `cancelled` when the user dismissed the
|
||||
// prompt or its policy declined it. The turn goes on without the tool.
|
||||
const { session, ask } = setup({ v2: true });
|
||||
session.liveBackgroundTasks.set("agent-fg", { isSubagent: true });
|
||||
|
||||
for (const agentID of [undefined, "agent-fg"]) {
|
||||
await expect(ask(agentID)).resolves.toEqual({
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
toolUseID: "tool-1",
|
||||
decisionClassification: "user_reject",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("aborts the tool use of foreground work on v2 during a session/cancel", async () => {
|
||||
const { session, ask } = setup({ v2: true });
|
||||
session.cancelled = true;
|
||||
|
||||
await expect(ask()).rejects.toThrow("Tool use aborted");
|
||||
});
|
||||
|
||||
it("denies the tool use of a background subagent", async () => {
|
||||
const { session, ask } = setup();
|
||||
session.liveBackgroundTasks.set("agent-bg", { isSubagent: true, backgrounded: true });
|
||||
@@ -7610,76 +7586,6 @@ describe("subagent permission attribution (issue #851)", () => {
|
||||
};
|
||||
}
|
||||
|
||||
it("releases a tool call that ends cancelled on v2, as one that ends failed", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: AcpSessionNotification) => {
|
||||
updates.push(update);
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{ v2: true },
|
||||
);
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
injectGeneratorSession(
|
||||
agent,
|
||||
makeGenerator([
|
||||
{
|
||||
type: "assistant",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
parent_tool_use_id: null,
|
||||
message: {
|
||||
id: "msg-bash",
|
||||
model: "claude-sonnet-4-5",
|
||||
role: "assistant",
|
||||
type: "message",
|
||||
stop_reason: "tool_use",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "make" } },
|
||||
],
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
parent_tool_use_id: null,
|
||||
// The user interrupted the turn while the command ran.
|
||||
tool_result_meta: [{ id: "toolu_bash", non_execution_kind: "interrupted" }],
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_bash",
|
||||
content: "[Request interrupted by user for tool use]",
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
successResult(),
|
||||
]),
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "go" }] });
|
||||
|
||||
expect(
|
||||
updates
|
||||
.map(({ update }) => update)
|
||||
.filter(
|
||||
(update) =>
|
||||
update.sessionUpdate === "tool_call_update" && "status" in update && update.status,
|
||||
)
|
||||
.at(-1),
|
||||
).toMatchObject({ toolCallId: "toolu_bash", status: "cancelled" });
|
||||
// The agent stops tracking the call once it ends, whichever way.
|
||||
expect(agent.sessions["test-session"]?.dispatchedToolCalls?.has("toolu_bash")).toBe(false);
|
||||
});
|
||||
|
||||
it("records task_started's task_id → tool_use_id mapping while consuming the stream", async () => {
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{ sessionUpdate: vi.fn(async () => {}) } as unknown as AcpClient,
|
||||
@@ -15096,7 +15002,7 @@ describe("session/close", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -15220,7 +15126,7 @@ describe("session/delete", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -15385,7 +15291,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -19923,7 +19829,7 @@ describe("post-error recovery", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork("test-session", false),
|
||||
background: new SessionBackgroundWork("test-session"),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -21919,23 +21825,6 @@ describe("output after a cancel", () => {
|
||||
toolUseID: id,
|
||||
} as any);
|
||||
/** The v2 PreToolUse preview hook for the Write call `id`, as Claude Code calls it. */
|
||||
const previewWrite = (agent: ClaudeAcpAgent, id: string) =>
|
||||
(
|
||||
agent as unknown as { fileChangePreviewHook(sessionId: string): HookCallback }
|
||||
).fileChangePreviewHook("test-session")(
|
||||
{
|
||||
hook_event_name: "PreToolUse",
|
||||
tool_name: "Write",
|
||||
tool_input: writeInput,
|
||||
tool_use_id: id,
|
||||
session_id: "test-session",
|
||||
transcript_path: "",
|
||||
cwd: "/workspace/proj",
|
||||
} as any,
|
||||
id,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
/** Sends the prompt `text` and waits until its turn is active. Returns the
|
||||
* prompt and the session's consumer, which ends with the scripted stream. */
|
||||
async function promptActive(agent: ClaudeAcpAgent, text: string) {
|
||||
@@ -22084,7 +21973,6 @@ describe("output after a cancel", () => {
|
||||
async (_when, order, streamed) => {
|
||||
const cancelled = Promise.withResolvers<void>();
|
||||
let asked: Promise<unknown> | undefined;
|
||||
let previewed: Promise<unknown> | undefined;
|
||||
const { agent, texts, updates, permissionRequests } = setup((input) =>
|
||||
(async function* () {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
@@ -22097,14 +21985,12 @@ describe("output after a cancel", () => {
|
||||
yield abortedResult(first.uuid);
|
||||
yield lifecycleFrame(first.uuid, "cancelled");
|
||||
// Claude Code had already taken the second prompt, so it still runs
|
||||
// it, and its answer writes a file. It calls the preview hook and
|
||||
// asks for the permission on its control channel, which can run
|
||||
// ahead of the consumer.
|
||||
// it, and its answer writes a file. It asks for the permission on
|
||||
// its control channel, which can run ahead of the consumer.
|
||||
yield lifecycleFrame(second.uuid, "started");
|
||||
yield requesting(second.uuid);
|
||||
yield userEcho(second);
|
||||
const ask = () => {
|
||||
previewed = previewWrite(agent, "toolu_late");
|
||||
asked = askToWrite(agent, "toolu_late");
|
||||
};
|
||||
if (order === "before") ask();
|
||||
@@ -22113,13 +21999,13 @@ describe("output after a cancel", () => {
|
||||
// Claude Code can run a tool whose input is complete before it
|
||||
// sends the whole message.
|
||||
ask();
|
||||
await Promise.all([previewed, asked]);
|
||||
await asked;
|
||||
}
|
||||
yield* streamed
|
||||
? toolUseMessage("toolu_late", "Write", writeInput)
|
||||
: [toolUseMessage("toolu_late", "Write", writeInput)[0]];
|
||||
if (order === "after") ask();
|
||||
await Promise.all([previewed, asked]);
|
||||
await asked;
|
||||
yield toolResult("toolu_late", "The user cancelled this prompt.", true);
|
||||
yield stampedResult(second.uuid);
|
||||
yield lifecycleFrame(second.uuid, "completed");
|
||||
@@ -23184,67 +23070,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
});
|
||||
|
||||
it("resumes a held turn's foreground work for a steer, and ends it again at the steered result", async () => {
|
||||
const foreground: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{ sessionUpdate: async () => {} } as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
foreground: () => ({
|
||||
running: () => foreground.push("running"),
|
||||
awaitingUser: () => foreground.push("awaitingUser"),
|
||||
ended: (outcome) =>
|
||||
foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`),
|
||||
failed: () => foreground.push("failed"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
yield userEcho((await iter.next()).value);
|
||||
yield running();
|
||||
yield subagentStarted("agent-1");
|
||||
yield resultMessage(); // held for agent-1
|
||||
yield idle();
|
||||
// The steer runs as a cycle of the held turn.
|
||||
yield userEcho((await iter.next()).value);
|
||||
yield assistantText("Steered answer");
|
||||
yield resultMessage();
|
||||
// The steered turn settles at an idle, held again for agent-1.
|
||||
yield idle();
|
||||
yield taskNotification("agent-1");
|
||||
yield assistantText("promised summary");
|
||||
yield resultMessage({ origin: { kind: "task-notification" } });
|
||||
yield idle();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const turn = agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "explore" }],
|
||||
});
|
||||
await vi.waitFor(() =>
|
||||
expect(agent.sessions["test-session"]?.activeTurn?.deferredSettle).toBeDefined(),
|
||||
);
|
||||
await agent.steer({ sessionId: "test-session", prompt: [{ type: "text", text: "also X" }] });
|
||||
|
||||
expect((await turn).stopReason).toBe("end_turn");
|
||||
await agent.sessions["test-session"]?.consumer;
|
||||
expect(foreground).toEqual([
|
||||
"running",
|
||||
"ended end_turn 15",
|
||||
// The steer resumes the turn's work, which ends at the steered result
|
||||
// with the turn's usage, both of its cycles.
|
||||
"running",
|
||||
"ended end_turn 30",
|
||||
// The follow-up after the subagent finished is a cycle of its own.
|
||||
"running",
|
||||
"ended end_turn 15",
|
||||
]);
|
||||
});
|
||||
|
||||
describe("ExitPlanMode answered inside a held turn's followup (issue #1167)", () => {
|
||||
// Plan mode + a background Explore agent: the user turn's result holds,
|
||||
// the subagent finishes, and the model writes the plan and calls
|
||||
@@ -23445,98 +23270,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the turn as the session's foreground work across the clear-context restart", async () => {
|
||||
const foreground: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async (request: any) => ({
|
||||
outcome: {
|
||||
outcome: "selected",
|
||||
optionId: request.options.find((option: any) => option.kind === "allow_once")
|
||||
.optionId,
|
||||
},
|
||||
}),
|
||||
} as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
foreground: () => ({
|
||||
running: () => foreground.push("running"),
|
||||
awaitingUser: () => foreground.push("awaitingUser"),
|
||||
ended: (outcome) =>
|
||||
foreground.push(`ended ${outcome.stopReason} ${outcome.usage?.totalTokens}`),
|
||||
failed: () => foreground.push("failed"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
vi.spyOn(agent as any, "createSession").mockImplementation(
|
||||
async (_params: any, options: any) => {
|
||||
const input = new Pushable<any>();
|
||||
async function* freshGenerator() {
|
||||
const next = await input[Symbol.asyncIterator]().next();
|
||||
yield userEcho(next.value);
|
||||
yield running();
|
||||
// The continuation asks for permission in the fresh query.
|
||||
await agent.canUseTool("test-session")(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{
|
||||
signal: new AbortController().signal,
|
||||
suggestions: [],
|
||||
toolUseID: "toolu_ls",
|
||||
requestId: "request-ls",
|
||||
},
|
||||
);
|
||||
yield assistantText("Implemented");
|
||||
yield resultMessage({ stop_reason: "max_tokens" });
|
||||
yield idle();
|
||||
}
|
||||
agent.sessions["test-session"] = mockSessionState({
|
||||
query: wrapQuery(freshGenerator()),
|
||||
input,
|
||||
modes: { currentModeId: options.permissionMode, availableModes: [] },
|
||||
fastModeEnabled: false,
|
||||
});
|
||||
return { sessionId: "test-session" };
|
||||
},
|
||||
);
|
||||
injectGeneratorSession(
|
||||
agent,
|
||||
(input) => {
|
||||
async function* messageGenerator() {
|
||||
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||
yield* heldTurnPlanningFollowup(first);
|
||||
yield idle();
|
||||
}
|
||||
return messageGenerator();
|
||||
},
|
||||
{
|
||||
creationParams: { cwd: "/test", mcpServers: [] },
|
||||
pendingExitPlanContextReset: {
|
||||
toolUseId: "tool-plan",
|
||||
plan: "Implement it",
|
||||
mode: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "plan" }] });
|
||||
|
||||
// The turn's work ends at its result, though v1's answer is held. The
|
||||
// follow-up that plans is a cycle, which the continuation takes over
|
||||
// without a new `running`. The restart replaces the session behind the
|
||||
// id; the work stays with the id.
|
||||
expect(foreground).toEqual([
|
||||
"running",
|
||||
"ended end_turn 15",
|
||||
"running",
|
||||
"awaitingUser",
|
||||
"running",
|
||||
// The turn's usage: the carried 15 and the continuation's.
|
||||
"ended max_tokens 30",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cancels the held turn when the user keeps planning", async () => {
|
||||
const agent = createMockAgent();
|
||||
const createSession = vi.spyOn(agent as any, "createSession");
|
||||
@@ -23561,40 +23294,6 @@ describe("deferred settlement for live background subagents (issues #864/#866)",
|
||||
expect(agent.sessions["test-session"].pendingExitPlanModeInterruption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ends the follow-up cycle cancelled, not failed, when the user keeps planning", async () => {
|
||||
const foreground: string[] = [];
|
||||
const agent = new ClaudeAcpAgent(
|
||||
{ sessionUpdate: async () => {} } as unknown as AcpClient,
|
||||
{ log: () => {}, error: () => {} },
|
||||
{
|
||||
foreground: () => ({
|
||||
running: () => foreground.push("running"),
|
||||
awaitingUser: () => foreground.push("awaitingUser"),
|
||||
ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`),
|
||||
failed: () => foreground.push("failed"),
|
||||
}),
|
||||
},
|
||||
);
|
||||
injectGeneratorSession(agent, (input) => {
|
||||
async function* messageGenerator() {
|
||||
const { value: first } = await input[Symbol.asyncIterator]().next();
|
||||
yield* heldTurnPlanningFollowup(first);
|
||||
yield idle();
|
||||
}
|
||||
return messageGenerator();
|
||||
});
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "plan" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("cancelled");
|
||||
// The turn's work ended at its result; the follow-up that planned is a
|
||||
// cycle, which the error-shaped diagnostic of the answer ends cancelled.
|
||||
expect(foreground).toEqual(["running", "ended end_turn", "running", "ended cancelled"]);
|
||||
});
|
||||
|
||||
describe("with backgroundSubagents", () => {
|
||||
it("asks out of the turn without a clear-context option and drops the interrupt", async () => {
|
||||
let request: RequestPermissionRequest | undefined;
|
||||
@@ -28436,7 +28135,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork("test-session", false),
|
||||
background: new SessionBackgroundWork("test-session"),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
@@ -30346,7 +30045,7 @@ describe("agent selection config option", () => {
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* Every scenario of `acp-scenarios/scenarios.ts` on the experimental ACP v2
|
||||
* surface: through the protocol router, with the SDK's v2 client.
|
||||
* core (`src/v2/`): through the protocol router, with the SDK's v2
|
||||
* client.
|
||||
*
|
||||
* - Status: each scenario passes, every prompt turn ending with a stop reason,
|
||||
* unless {@link V2_STOPS} names why it stops. Every scenario passes today;
|
||||
@@ -11,15 +12,15 @@
|
||||
* `tool_call`, so a report of a tool call that the client has not seen
|
||||
* creates it, where v1 drops it.
|
||||
* - Golden files: `acp-scenarios/__snapshots__/v2/<scenario>.jsonl` for each
|
||||
* passing scenario. Run `npx vitest run src/tests/acp-scenarios-v2.test.ts -u`
|
||||
* to update them.
|
||||
* passing scenario, every message of the run. Run
|
||||
* `npx vitest run src/tests/acp-scenarios-v2.test.ts -u` to update them.
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { AgentHandle } from "../v2/agent.js";
|
||||
import {
|
||||
resetIds,
|
||||
runScenarioV2,
|
||||
@@ -61,8 +62,8 @@ const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const PASSES = "passes";
|
||||
|
||||
/**
|
||||
* The scenarios that stop on the v2 surface, each with the reason, which names
|
||||
* the step of `docs/acp-v2.md` that removes it. Every other scenario passes.
|
||||
* The scenarios that stop on the v2 surface, each with the reason. Every
|
||||
* other scenario passes.
|
||||
*/
|
||||
const V2_STOPS: Record<string, string> = {};
|
||||
|
||||
@@ -86,12 +87,15 @@ beforeAll(async () => {
|
||||
const { acpProtocolRouter } = await import("../serve.js");
|
||||
for (const scenario of SCENARIOS) {
|
||||
resetIds();
|
||||
const agents: ClaudeAcpAgent[] = [];
|
||||
const agents: { dispose(): Promise<void> }[] = [];
|
||||
const quiet = { log: () => {}, error: () => {} };
|
||||
runs.set(
|
||||
scenario.name,
|
||||
await runScenarioV2(
|
||||
(stream) => acpProtocolRouter(quiet, (agent) => agents.push(agent)).connect(stream),
|
||||
(stream) =>
|
||||
acpProtocolRouter(quiet, (agent) => agents.push(agent), "auto", {
|
||||
onV2Agent: (agent: AgentHandle) => agents.push(agent),
|
||||
}).connect(stream),
|
||||
scenario,
|
||||
),
|
||||
);
|
||||
@@ -163,10 +167,10 @@ describe("ACP v2 scenarios", () => {
|
||||
const passing = SCENARIOS.map((scenario) => scenario.name).filter(
|
||||
(scenario) => statusOf(scenario) === PASSES,
|
||||
);
|
||||
const golden = (scenario: string) =>
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`);
|
||||
it.each(passing)("%s", async (scenario) => {
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(
|
||||
path.join(here, "acp-scenarios", "__snapshots__", "v2", `${scenario}.jsonl`),
|
||||
);
|
||||
await expect(toJsonLines(run(scenario).normalized)).toMatchFileSnapshot(golden(scenario));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,6 +94,7 @@ export const PROFILES: Record<Profile["name"], Profile> = {
|
||||
type QueryOptions = {
|
||||
sessionId?: string;
|
||||
resume?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
canUseTool?: (
|
||||
toolName: string,
|
||||
input: Record<string, unknown>,
|
||||
@@ -234,6 +235,16 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
);
|
||||
},
|
||||
};
|
||||
// With `CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS`, Claude Code reports that it
|
||||
// works once it takes a prompt in, and that it is idle after the result.
|
||||
const stateEvents = options.env?.CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS === "1";
|
||||
const state = (value: "running" | "idle") => ({
|
||||
type: "system",
|
||||
subtype: "session_state_changed",
|
||||
state: value,
|
||||
uuid: `state-${value}`,
|
||||
session_id: sessionId,
|
||||
});
|
||||
async function* run() {
|
||||
const iterator = args.prompt[Symbol.asyncIterator]();
|
||||
for (const turn of script.scenario.turns) {
|
||||
@@ -241,6 +252,7 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
if (next.done) return;
|
||||
const user = next.value;
|
||||
ctx.promptUuid = user.uuid;
|
||||
if (stateEvents) yield state("running");
|
||||
yield {
|
||||
type: "user",
|
||||
message: user.message,
|
||||
@@ -250,6 +262,7 @@ export function mockedQuery(args: { prompt: AsyncIterable<any>; options: QueryOp
|
||||
isReplay: true,
|
||||
};
|
||||
yield* turn(ctx);
|
||||
if (stateEvents) yield state("idle");
|
||||
}
|
||||
// Stay open like a live Claude Code process.
|
||||
await iterator.next();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -176,25 +176,19 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the PreToolUse hook of the change previews only on a v2 connection", async () => {
|
||||
it("registers no PreToolUse hook of change previews", async () => {
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
// A v1 client gets the input's diff, so Claude Code waits on no hook.
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toBeUndefined();
|
||||
|
||||
const v2Agent = new ClaudeAcpAgent(createMockClient(), undefined, { v2: true });
|
||||
await v2Agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toEqual([
|
||||
{ matcher: "Edit|Write", hooks: [expect.any(Function)] },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([false, true])("reports child settings through registered hooks (v2: %s)", async (v2) => {
|
||||
it("reports child settings through registered hooks", async () => {
|
||||
const updates: AcpSessionNotification[] = [];
|
||||
const client = createMockClient();
|
||||
client.sessionUpdate = async (notification) => {
|
||||
updates.push(notification);
|
||||
};
|
||||
const observedAgent = new ClaudeAcpAgent(client, undefined, { v2 });
|
||||
const observedAgent = new ClaudeAcpAgent(client);
|
||||
await observedAgent.initialize({ protocolVersion: 1, clientCapabilities: { subagents: {} } });
|
||||
const { sessionId } = await observedAgent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const session = observedAgent.sessions[sessionId]!;
|
||||
|
||||
@@ -34,6 +34,7 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
mcpServerStatus: async () => [],
|
||||
setMcpServers: async () => ({ added: [], removed: [], errors: {} }),
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
// No ultracode fields: session creation falls back to settings and the
|
||||
// model's effort levels, as with a CLI that does not report them.
|
||||
|
||||
@@ -74,10 +74,72 @@ describe("resolveModelPreference - resolvedModel matching", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]");
|
||||
});
|
||||
|
||||
it("returns null for a preference that matches nothing", () => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
|
||||
// Words match whole: "a" is inside "Default", "son" inside "sonnet".
|
||||
it.each(["claude-gpt-99", "not-a-model", "a", "son", "foo-sonnet", "not-default-model"])(
|
||||
"returns null for %s, which names no model",
|
||||
(preference) => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["us.anthropic.claude-sonnet-5-v1:0", "sonnet"],
|
||||
["global.anthropic.claude-haiku-4-5-20251001-v1:0", "haiku"],
|
||||
["anthropic/claude-sonnet-5:batch", "sonnet"],
|
||||
["claude-opus-4-8@20260101", "opus[1m]"],
|
||||
["claude-sonnet-5-fast", "sonnet"],
|
||||
["claude-haiku-4-5", "haiku"],
|
||||
["opusplan", "opus[1m]"],
|
||||
["opus", "opus[1m]"],
|
||||
["Sonnet 5", "sonnet"],
|
||||
// The CLI's `best` alias runs Opus and ignores a `[1m]` hint.
|
||||
["best", "opus[1m]"],
|
||||
["BEST", "opus[1m]"],
|
||||
["best[1m]", "opus[1m]"],
|
||||
])("resolves the spelling %s to %s", (preference, value) => {
|
||||
expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)?.value).toBe(value);
|
||||
});
|
||||
|
||||
it("resolves best to the Opus entry rather than the default when the default runs Sonnet", () => {
|
||||
const models: ModelInfo[] = [
|
||||
{
|
||||
value: "default",
|
||||
resolvedModel: "claude-sonnet-5",
|
||||
displayName: "Default (recommended)",
|
||||
description: "Use the default model (currently Sonnet 5)",
|
||||
},
|
||||
{ value: "opus", resolvedModel: "claude-opus-4-8", displayName: "Opus", description: "" },
|
||||
{ value: "sonnet", resolvedModel: "claude-sonnet-5", displayName: "Sonnet", description: "" },
|
||||
];
|
||||
expect(resolveModelPreference(models, "best")?.value).toBe("opus");
|
||||
});
|
||||
|
||||
// A gateway listing of dated ids, as `ANTHROPIC_MODEL`, `settings.model` or
|
||||
// `set_config_option` may spell them.
|
||||
const DATED_MODELS: ModelInfo[] = [
|
||||
{ value: "claude-3-7-sonnet-20250219", displayName: "Claude 3.7 Sonnet", description: "" },
|
||||
{ value: "claude-3-5-haiku-20241022", displayName: "Claude 3.5 Haiku", description: "" },
|
||||
{ value: "claude-sonnet-4-5-20250929", displayName: "Claude Sonnet 4.5", description: "" },
|
||||
];
|
||||
|
||||
it.each([
|
||||
["claude-3-7-sonnet-latest", "claude-3-7-sonnet-20250219"],
|
||||
["claude-3-5-haiku-latest", "claude-3-5-haiku-20241022"],
|
||||
["openrouter/anthropic/claude-sonnet-4.5", "claude-sonnet-4-5-20250929"],
|
||||
["vertex_ai/claude-sonnet-4-5@20250929", "claude-sonnet-4-5-20250929"],
|
||||
["litellm/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"],
|
||||
["publishers/anthropic/models/claude-sonnet-4-5", "claude-sonnet-4-5-20250929"],
|
||||
])("resolves the gateway spelling %s to %s", (preference, value) => {
|
||||
expect(resolveModelPreference(DATED_MODELS, preference)?.value).toBe(value);
|
||||
});
|
||||
|
||||
it.each(["openai/gpt-5", "openrouter/foo-sonnet", "claude-3-5-opus-latest"])(
|
||||
"returns null for the gateway spelling %s, which names no listed model",
|
||||
(preference) => {
|
||||
expect(resolveModelPreference(DATED_MODELS, preference)).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => {
|
||||
// Without hint canonicalization the exact tier misses and the substring
|
||||
// tier lands on the bare 200k sibling, silently downgrading a 1M pin.
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("module runtime exports", () => {
|
||||
it("acp-agent.ts exports the expected names", async () => {
|
||||
expect(Object.keys(await import("../acp-agent.js")).sort()).toEqual([
|
||||
"AGENT_CONFIG_ID",
|
||||
"AUTONOMOUS_RESULT_ORIGINS",
|
||||
"BUILTIN_AGENT_NAMES",
|
||||
"ClaudeAcpAgent",
|
||||
"DEFAULT_AGENT_ID",
|
||||
@@ -49,6 +50,8 @@ describe("module runtime exports", () => {
|
||||
"createFastModeConfigOption",
|
||||
"discoverCustomAgents",
|
||||
"fastModeStateEnabled",
|
||||
"getAvailableSlashCommands",
|
||||
"isAbortedResult",
|
||||
"isLocalCommandMetadata",
|
||||
"isLoginRequiredText",
|
||||
"isReplayHiddenMetaMessage",
|
||||
|
||||
@@ -3,10 +3,8 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import { initializeClient, makeMockQuery } from "./helpers.js";
|
||||
import { successfulResultMessage, userEcho } from "./session-doubles.js";
|
||||
|
||||
@@ -747,50 +745,4 @@ describe("provider updates preserve session configuration", { timeout: 10_000 },
|
||||
expect(lastQuery().state).toMatchObject({ model: "opus", effort: "high" });
|
||||
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe("https://recovered.example");
|
||||
});
|
||||
|
||||
it("publishes the restored configuration through the v2 protocol router", async () => {
|
||||
await agent.dispose();
|
||||
const toAgent = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
const toClient = new TransformStream<v2.AnyMessage, v2.AnyMessage>();
|
||||
acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({
|
||||
readable: toAgent.readable,
|
||||
writable: toClient.writable,
|
||||
});
|
||||
const received: v2.UpdateSessionNotification[] = [];
|
||||
await v2
|
||||
.client({ name: "provider-test" })
|
||||
.onNotification(v2.methods.client.session.update, ({ params }) => void received.push(params))
|
||||
.connectWith({ readable: toClient.readable, writable: toAgent.writable }, async (client) => {
|
||||
await client.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "provider-test", version: "1" },
|
||||
capabilities: AIR_CAPABILITIES,
|
||||
});
|
||||
await client.request(v2.methods.agent.providers.set, PROVIDER);
|
||||
const { sessionId } = await client.request(v2.methods.agent.session.new, { cwd });
|
||||
for (const [configId, value] of [
|
||||
["model", "opus"],
|
||||
["effort", "high"],
|
||||
]) {
|
||||
await client.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId,
|
||||
value,
|
||||
type: "id",
|
||||
});
|
||||
}
|
||||
|
||||
await client.request(v2.methods.agent.providers.set, PROVIDER);
|
||||
|
||||
const config = received.findLast(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(config?.update).toMatchObject({
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ configId: "model", currentValue: "opus" }),
|
||||
expect.objectContaining({ configId: "effort", currentValue: "high" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { loggableBaseUrl } from "../providers.js";
|
||||
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
@@ -922,3 +923,14 @@ describe("providers", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("loggableBaseUrl", () => {
|
||||
it.each([
|
||||
["https://user:secret@gateway.example.com/v1?key=abc", "https://gateway.example.com"],
|
||||
["https://gateway.example.com:8443/proxy/sk-123", "https://gateway.example.com:8443"],
|
||||
["not a url", "invalid"],
|
||||
[undefined, "native"],
|
||||
])("logs %s as %s", (baseUrl, logged) => {
|
||||
expect(loggableBaseUrl(baseUrl)).toBe(logged);
|
||||
});
|
||||
});
|
||||
|
||||
64
src/tests/result-end.test.ts
Normal file
64
src/tests/result-end.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { resultEnd, type ResultEnd } from "../result-end.js";
|
||||
|
||||
const LIMITS = ["error_max_turns", "error_max_budget_usd", "error_max_structured_output_retries"];
|
||||
|
||||
/** A result of `subtype`, with the error text `text` on `success` and `["a", "b"]` otherwise. */
|
||||
function result(
|
||||
subtype: string,
|
||||
stopReason: string | null,
|
||||
isError: boolean,
|
||||
text = "API Error: 500",
|
||||
): SDKResultMessage {
|
||||
return {
|
||||
type: "result",
|
||||
subtype,
|
||||
stop_reason: stopReason,
|
||||
is_error: isError,
|
||||
...(subtype === "success" ? { result: text } : { errors: ["a", "b"] }),
|
||||
} as unknown as SDKResultMessage;
|
||||
}
|
||||
|
||||
const failure = (error: string, loginRequired = false): ResultEnd => ({ error, loginRequired });
|
||||
|
||||
/** The subtype, `stop_reason` and `is_error` of a result, and how it ends the work. */
|
||||
type Row = [string, string | null, boolean, ResultEnd];
|
||||
|
||||
describe("resultEnd", () => {
|
||||
it.each<Row>([
|
||||
...["success", "error_during_execution", ...LIMITS].flatMap((subtype) =>
|
||||
[true, false].map((isError): Row => [subtype, "refusal", isError, { stopReason: "refusal" }]),
|
||||
),
|
||||
["success", "max_tokens", true, { stopReason: "max_tokens" }],
|
||||
["success", "max_tokens", false, { stopReason: "max_tokens" }],
|
||||
["success", "end_turn", true, failure("API Error: 500")],
|
||||
["success", null, false, { stopReason: "end_turn" }],
|
||||
["error_during_execution", "max_tokens", true, { stopReason: "max_tokens" }],
|
||||
["error_during_execution", "max_tokens", false, { stopReason: "max_tokens" }],
|
||||
["error_during_execution", "end_turn", true, failure("a, b")],
|
||||
["error_during_execution", null, false, { stopReason: "end_turn" }],
|
||||
// A limit ignores `max_tokens`.
|
||||
...LIMITS.flatMap((subtype) =>
|
||||
["max_tokens", null].flatMap((stopReason): Row[] => [
|
||||
[subtype, stopReason, true, failure("a, b")],
|
||||
[subtype, stopReason, false, { stopReason: "max_turn_requests" }],
|
||||
]),
|
||||
),
|
||||
])("reads %s with stop_reason %s and is_error %s", (subtype, stopReason, isError, expected) => {
|
||||
expect(resultEnd(result(subtype, stopReason, isError))).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([null, "max_tokens"])(
|
||||
"fails a success error with the /login text, also with stop_reason %s",
|
||||
(stopReason) => {
|
||||
const text = "Not logged in · Please run /login";
|
||||
expect(resultEnd(result("success", stopReason, true, text))).toEqual(failure(text, true));
|
||||
},
|
||||
);
|
||||
|
||||
it("fails a limit error with its subtype when Claude Code lists no errors", () => {
|
||||
const limit = { ...result("error_max_turns", null, true), errors: [] } as SDKResultMessage;
|
||||
expect(resultEnd(limit)).toEqual(failure("error_max_turns"));
|
||||
});
|
||||
});
|
||||
@@ -330,18 +330,28 @@ describe("session config options", () => {
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
it("throws for completely invalid model value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "gpt-4",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: -32602,
|
||||
message: expect.stringContaining("Invalid value for config option model: gpt-4"),
|
||||
});
|
||||
});
|
||||
// "a" is a substring of "Claude", "son" of "sonnet": only whole words match.
|
||||
it.each(["gpt-4", "not-a-model", "son", "foo-sonnet"])(
|
||||
"rejects the model %s and leaves the model as it was",
|
||||
async (value) => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({ sessionId: SESSION_ID, configId: "model", value }),
|
||||
).rejects.toMatchObject({
|
||||
code: -32602,
|
||||
message: expect.stringContaining(`Invalid value for config option model: ${value}`),
|
||||
});
|
||||
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
const session = (
|
||||
agent as unknown as {
|
||||
sessions: Record<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).sessions[SESSION_ID];
|
||||
expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe(
|
||||
"claude-opus-4-5",
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a non-string value for a select option with invalid params", async () => {
|
||||
await expect(
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { vi } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SessionBackgroundWork } from "../background-work.js";
|
||||
import { PromptSuggestions } from "../prompt-suggestions.js";
|
||||
import { SessionTitles } from "../session-titles.js";
|
||||
|
||||
/** Stand-in agent for a `SessionTitles` whose test doesn't care about titles:
|
||||
@@ -93,11 +94,15 @@ export function mockSessionState(
|
||||
toolUseCache: {},
|
||||
emittedToolCalls: new Set(),
|
||||
liveBackgroundTasks: new Map(),
|
||||
background: new SessionBackgroundWork(sessionId, false),
|
||||
background: new SessionBackgroundWork(sessionId),
|
||||
emittedAssistantText: false,
|
||||
owedTrailingIdles: 0,
|
||||
messageIdToUuid: new Map(),
|
||||
sessionFailureState: { epoch: randomUUID(), revisions: new Map(), active: new Map() },
|
||||
// As `createSession` gives it to the session of an AIR client.
|
||||
...(agent?.toolCallCapabilities?.air?.client
|
||||
? { promptSuggestions: new PromptSuggestions() }
|
||||
: {}),
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ async function writeTranscript(options: TranscriptOptions): Promise<{ id: string
|
||||
return { id, file };
|
||||
}
|
||||
|
||||
function createAgent(options: { v2?: boolean } = {}) {
|
||||
function createAgent() {
|
||||
const notifications: { method: string; params: Record<string, unknown> }[] = [];
|
||||
const updates: unknown[] = [];
|
||||
const client = {
|
||||
@@ -175,7 +175,7 @@ function createAgent(options: { v2?: boolean } = {}) {
|
||||
notifications.push({ method, params });
|
||||
},
|
||||
} as unknown as AcpClient;
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} }, options);
|
||||
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
return { agent, notifications, updates };
|
||||
}
|
||||
|
||||
@@ -317,16 +317,6 @@ describe("sessionIndex negotiation", () => {
|
||||
expect(nonAir._meta).toEqual({ steering: { supported: true } });
|
||||
});
|
||||
|
||||
it("is not advertised under ACP v2", async () => {
|
||||
const response = await createAgent({ v2: true }).agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: air("sessionIndex"),
|
||||
});
|
||||
for (const capability of indexCapabilities) {
|
||||
expect(airCapabilities(response)).not.toContain(capability);
|
||||
}
|
||||
});
|
||||
|
||||
it("answers method-not-found to the new methods without the capability", async () => {
|
||||
// Declaring sessionArchive or sessionRename without sessionIndex enables
|
||||
// nothing either.
|
||||
@@ -745,15 +735,6 @@ describe("_session/batch_get", () => {
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects under ACP v2 even when sessionIndex was declared", async () => {
|
||||
const { agent } = createAgent({ v2: true });
|
||||
await initializeClient(agent, air("sessionIndex"));
|
||||
await expect(agent.batchGetSessions({ sessionIds: [] })).rejects.toMatchObject({
|
||||
code: -32601,
|
||||
});
|
||||
await agent.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/list of a sessionIndex client", () => {
|
||||
|
||||
@@ -115,6 +115,12 @@ describe("session titles", () => {
|
||||
return new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
/** Sends the current title of `test-session` (see `SessionTitles.publishCurrent`). */
|
||||
async function publishCurrentSessionInfo(agent: ClaudeAcpAgent) {
|
||||
const session = agent.sessions["test-session"]!;
|
||||
await session.titles.publishCurrent(session);
|
||||
}
|
||||
|
||||
const LONG_PROMPT = "Explain what the add function in hello.py does, in one sentence";
|
||||
|
||||
it("pushes a session_info_update when the SDK generates a title at turn-end", async () => {
|
||||
@@ -198,8 +204,8 @@ describe("session titles", () => {
|
||||
agent,
|
||||
);
|
||||
|
||||
await agent.publishCurrentSessionInfo("test-session");
|
||||
await agent.publishCurrentSessionInfo("test-session");
|
||||
await publishCurrentSessionInfo(agent);
|
||||
await publishCurrentSessionInfo(agent);
|
||||
|
||||
const current = {
|
||||
sessionUpdate: "session_info_update",
|
||||
@@ -221,11 +227,11 @@ describe("session titles", () => {
|
||||
|
||||
// No file and no title yet: nothing to send.
|
||||
vi.mocked(getSessionInfo).mockResolvedValue(undefined);
|
||||
await agent.publishCurrentSessionInfo("test-session");
|
||||
await publishCurrentSessionInfo(agent);
|
||||
expect(titles()).toEqual([]);
|
||||
|
||||
await agent.sessions["test-session"]!.titles.setExplicitTitle("Renamed", async () => undefined);
|
||||
await agent.publishCurrentSessionInfo("test-session");
|
||||
await publishCurrentSessionInfo(agent);
|
||||
expect(titles()).toEqual(["Renamed", "Renamed"]);
|
||||
});
|
||||
|
||||
@@ -245,7 +251,7 @@ describe("session titles", () => {
|
||||
summary: "please fix the build",
|
||||
lastModified: 1_700_000_000_000,
|
||||
} as any);
|
||||
await agent.publishCurrentSessionInfo("test-session");
|
||||
await publishCurrentSessionInfo(agent);
|
||||
|
||||
expect(titles()).toEqual(["Renamed", "Renamed"]);
|
||||
});
|
||||
|
||||
@@ -4,12 +4,9 @@
|
||||
* Claude Code: it echoes each prompt, then runs the turn's script.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
RequestPermissionRequest,
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { ForegroundEvents, TurnEvents } from "../turn-events.js";
|
||||
import type { TurnEvents } from "../turn-events.js";
|
||||
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -195,41 +192,19 @@ function system(subtype: string, sessionId: string, fields: Record<string, unkno
|
||||
|
||||
describe("turn events", () => {
|
||||
let agent: ClaudeAcpAgent;
|
||||
let permissionRequests: RequestPermissionRequest[];
|
||||
/** The user answers a request for this tool call only once it resolves. */
|
||||
let backgroundAnswer: PromiseWithResolvers<void>;
|
||||
/** Sees each session update; the agent waits until it returns. */
|
||||
let onSessionUpdate: (notification: SessionNotification) => Promise<void> | void;
|
||||
/** What the agent reported of the session's foreground work. */
|
||||
let foreground: string[];
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
permissionRequests = [];
|
||||
backgroundAnswer = Promise.withResolvers<void>();
|
||||
onSessionUpdate = () => {};
|
||||
foreground = [];
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const foregroundEvents: ForegroundEvents = {
|
||||
running: () => foreground.push("running"),
|
||||
awaitingUser: () => foreground.push("awaitingUser"),
|
||||
ended: (outcome) => foreground.push(`ended ${outcome.stopReason}`),
|
||||
failed: () => foreground.push("failed"),
|
||||
};
|
||||
agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (notification: SessionNotification) => onSessionUpdate(notification),
|
||||
extNotification: async () => {},
|
||||
requestPermission: async (request: RequestPermissionRequest) => {
|
||||
permissionRequests.push(request);
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
if (request.toolCall.toolCallId === "toolu_background") await backgroundAnswer.promise;
|
||||
const allow = request.options.find((option) => option.kind === "allow_once");
|
||||
return { outcome: { outcome: "selected", optionId: allow!.optionId } };
|
||||
},
|
||||
} as unknown as AcpClient,
|
||||
undefined,
|
||||
{ foreground: () => foregroundEvents },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -333,172 +308,6 @@ describe("turn events", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports waiting on a permission request, then resuming", async () => {
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
await options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
|
||||
);
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
const turn = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
|
||||
await turn.done;
|
||||
|
||||
expect(permissionRequests).toHaveLength(1);
|
||||
expect(turn.log.slice(1)).toEqual(["ended end_turn"]);
|
||||
expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("reports waiting on a request that Claude Code opens for a started turn before its echo", async () => {
|
||||
let whileOpen: string[] = [];
|
||||
scriptQuery(async function* (input, options) {
|
||||
const { value } = await input.next();
|
||||
yield lifecycle(value.uuid, "started", options.sessionId!);
|
||||
// Claude Code asks for the prompt before the consumer reaches its echo.
|
||||
// The user answers it only once the test resolves `backgroundAnswer`.
|
||||
const decision = options.canUseTool!(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
|
||||
);
|
||||
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
|
||||
whileOpen = [...foreground];
|
||||
backgroundAnswer.resolve();
|
||||
await decision;
|
||||
yield echo(value, options.sessionId!);
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_background", content: "README.md" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
yield result(options.sessionId!);
|
||||
await input.next();
|
||||
});
|
||||
const sessionId = await newSession();
|
||||
const turn = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
|
||||
await turn.done;
|
||||
|
||||
expect(whileOpen).toEqual(["running", "awaitingUser"]);
|
||||
expect(foreground).toEqual(["running", "awaitingUser", "running", "ended end_turn"]);
|
||||
});
|
||||
|
||||
it("reports work that starts while a request is open as waiting at once", async () => {
|
||||
let atStart: string[] = [];
|
||||
scriptQuery(async function* (input, options) {
|
||||
const { value } = await input.next();
|
||||
// A request that no work opened, such as a background task's. The user
|
||||
// answers it only once the test resolves `backgroundAnswer`.
|
||||
const background = options.canUseTool!(
|
||||
"Bash",
|
||||
{ command: "sleep 60" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
|
||||
);
|
||||
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
|
||||
yield lifecycle(value.uuid, "started", options.sessionId!);
|
||||
// Before the prompt's echo, which would report the request too.
|
||||
atStart = [...foreground];
|
||||
backgroundAnswer.resolve();
|
||||
await background;
|
||||
yield echo(value, options.sessionId!);
|
||||
yield result(options.sessionId!);
|
||||
await input.next();
|
||||
});
|
||||
const sessionId = await newSession();
|
||||
const turn = recordEvents();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "list files"), turn.events);
|
||||
await turn.done;
|
||||
|
||||
expect(atStart).toEqual(["running", "awaitingUser"]);
|
||||
});
|
||||
|
||||
it("awaits the user while any request in the session is open, also one opened before the turn", async () => {
|
||||
const first = recordEvents();
|
||||
const second = recordEvents();
|
||||
let backgroundRequest: Promise<unknown> | undefined;
|
||||
let atStart: string[] = [];
|
||||
let afterOwnAnswer: string[] | undefined;
|
||||
scriptTurns([
|
||||
async function* (options) {
|
||||
yield result(options.sessionId!);
|
||||
await first.done;
|
||||
// A request that no active turn opened, such as a background task's.
|
||||
backgroundRequest = options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "sleep 60" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_background" },
|
||||
);
|
||||
},
|
||||
async function* (options) {
|
||||
atStart = [...foreground];
|
||||
await options.canUseTool(
|
||||
"Bash",
|
||||
{ command: "ls" },
|
||||
{ signal: new AbortController().signal, suggestions: [], toolUseID: "toolu_ls" },
|
||||
);
|
||||
afterOwnAnswer = [...foreground];
|
||||
await backgroundRequest;
|
||||
yield {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "toolu_ls", content: "README.md" }],
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId!,
|
||||
};
|
||||
yield result(options.sessionId!);
|
||||
},
|
||||
]);
|
||||
const sessionId = await newSession();
|
||||
|
||||
await agent.startTurn(prompt(sessionId, "start a background task"), first.events);
|
||||
await vi.waitFor(() => expect(permissionRequests).toHaveLength(1));
|
||||
await agent.startTurn(prompt(sessionId, "list files"), second.events);
|
||||
await vi.waitFor(() => expect(afterOwnAnswer).toBeDefined());
|
||||
backgroundAnswer.resolve();
|
||||
await second.done;
|
||||
|
||||
expect(first.log.slice(1)).toEqual(["ended end_turn"]);
|
||||
// The first turn's work ended before the background request opened, and
|
||||
// the second turn waits from insertion, through its own answered request,
|
||||
// until the earlier request is answered too.
|
||||
const firstWork = ["running", "ended end_turn"];
|
||||
expect(atStart).toEqual([...firstWork, "running", "awaitingUser"]);
|
||||
expect(afterOwnAnswer).toEqual([...firstWork, "running", "awaitingUser"]);
|
||||
expect(foreground).toEqual([
|
||||
...firstWork,
|
||||
"running",
|
||||
"awaitingUser",
|
||||
"running",
|
||||
"ended end_turn",
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends a queued turn that is cancelled before it is inserted, without inserting it", async () => {
|
||||
const release = Promise.withResolvers<void>();
|
||||
scriptTurns([
|
||||
|
||||
179
src/tests/v2-integration.test.ts
Normal file
179
src/tests/v2-integration.test.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
import { spawn, spawnSync, type ChildProcess } from "node:child_process";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { nodeToWebReadable, nodeToWebWritable } from "../utils.js";
|
||||
|
||||
/**
|
||||
* The ACP v2 core (`src/v2/`) against the bundled Claude Code
|
||||
* CLI, in the adapter's own process as a client starts it. The doubles of
|
||||
* `v2.test.ts` send the frames the core builds its turn model on
|
||||
* (`command_lifecycle`, `session_state_changed`, `interrupt({ cancelQueued })`);
|
||||
* these cases check that the real CLI still sends them. They cost API usage.
|
||||
*/
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("v2 core with Claude Code", () => {
|
||||
let child: ChildProcess;
|
||||
let agent: v2.ClientContext;
|
||||
let cwd: string;
|
||||
const updates: v2.UpdateSessionNotification[] = [];
|
||||
const waiters: Array<() => void> = [];
|
||||
let permissionAnswer: v2.PermissionOptionKind = "allow_once";
|
||||
|
||||
beforeAll(() => {
|
||||
if (spawnSync("npx", ["tsc"], { stdio: "inherit" }).status) {
|
||||
throw new Error("failed to compile");
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(path.join(os.tmpdir(), "v2-integration-"));
|
||||
await writeFile(path.join(cwd, "file.txt"), "one\n");
|
||||
updates.length = 0;
|
||||
permissionAnswer = "allow_once";
|
||||
child = spawn("node", [path.resolve("dist/index.js")], {
|
||||
cwd,
|
||||
env: { ...process.env, CLAUDE_AGENT_ACP_PROTOCOL: "v2" },
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
});
|
||||
const connection = v2
|
||||
.client({ name: "v2-integration" })
|
||||
.onNotification(v2.methods.client.session.update, ({ params }) => {
|
||||
updates.push(params);
|
||||
for (const notify of waiters.splice(0)) notify();
|
||||
})
|
||||
.onRequest(v2.methods.client.session.requestPermission, ({ params }) => {
|
||||
const option = params.options.find((candidate) => candidate.kind === permissionAnswer);
|
||||
return option
|
||||
? { outcome: { outcome: "selected", optionId: option.optionId } }
|
||||
: { outcome: { outcome: "cancelled" } };
|
||||
})
|
||||
.connect(v2.ndJsonStream(nodeToWebWritable(child.stdin!), nodeToWebReadable(child.stdout!)));
|
||||
agent = connection.agent;
|
||||
await agent.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "v2-integration", version: "1" },
|
||||
capabilities: {},
|
||||
});
|
||||
}, 60_000);
|
||||
|
||||
afterEach(async () => {
|
||||
// Not `connection.close()`: it cancels the stdout stream, and the socket's
|
||||
// later `end` then closes it again (`nodeToWebReadable`), which throws.
|
||||
child.kill();
|
||||
await rm(cwd, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
afterAll(() => child?.kill());
|
||||
|
||||
/** The states of the root session's `state_update`s, in order. */
|
||||
const states = (sessionId: string) =>
|
||||
updates.flatMap(({ sessionId: id, update }) =>
|
||||
id === sessionId && update.sessionUpdate === "state_update" ? [update] : [],
|
||||
);
|
||||
|
||||
/** Resolves once `test` holds for the updates received so far. */
|
||||
async function until(test: () => boolean, ms = 120_000): Promise<void> {
|
||||
const deadline = Date.now() + ms;
|
||||
while (!test()) {
|
||||
if (Date.now() > deadline) throw new Error("timed out waiting for the session's updates");
|
||||
await new Promise<void>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
setTimeout(resolve, 1000);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** A new session on its haiku model, the cheapest, in `mode`. */
|
||||
async function newSession(mode: string): Promise<string> {
|
||||
const { sessionId, configOptions } = await agent.request(v2.methods.agent.session.new, {
|
||||
cwd,
|
||||
});
|
||||
type Entry = { value: string; name: string; options?: Entry[] };
|
||||
const model = configOptions?.find((option) => option.configId === "model") as
|
||||
{ options?: Entry[] } | undefined;
|
||||
const values = (model?.options ?? []).flatMap((entry) => entry.options ?? [entry]);
|
||||
const haiku = values.find((entry) => /haiku/i.test(`${entry.value} ${entry.name}`));
|
||||
if (haiku) {
|
||||
await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId: "model",
|
||||
type: "id",
|
||||
value: haiku.value,
|
||||
});
|
||||
}
|
||||
await agent.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId: "mode",
|
||||
type: "id",
|
||||
value: mode,
|
||||
});
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const prompt = (sessionId: string, text: string) =>
|
||||
agent.request(v2.methods.agent.session.prompt, {
|
||||
sessionId,
|
||||
prompt: [{ type: "text", text }],
|
||||
});
|
||||
|
||||
it("answers a prompt at insertion and reports running, then idle", async () => {
|
||||
const sessionId = await newSession("default");
|
||||
await prompt(sessionId, "Reply with exactly the single word: pong");
|
||||
// The prompt answers at insertion, before the turn's idle.
|
||||
expect(states(sessionId).some((update) => update.state === "idle")).toBe(false);
|
||||
await until(() => states(sessionId).some((update) => update.state === "idle"));
|
||||
expect(states(sessionId).map((update) => update.state)).toEqual(["running", "idle"]);
|
||||
expect(states(sessionId).at(-1)).toMatchObject({ stopReason: "end_turn" });
|
||||
}, 180_000);
|
||||
|
||||
it("reports requires_action while a permission request waits", async () => {
|
||||
const sessionId = await newSession("default");
|
||||
await prompt(
|
||||
sessionId,
|
||||
"Use the Edit tool on file.txt to replace the word one with the word two. Then reply with just: done",
|
||||
);
|
||||
await until(() => states(sessionId).some((update) => update.state === "idle"));
|
||||
expect(states(sessionId).map((update) => update.state)).toEqual([
|
||||
"running",
|
||||
"requires_action",
|
||||
"running",
|
||||
"idle",
|
||||
]);
|
||||
}, 180_000);
|
||||
|
||||
it("fails a queued prompt with -32800 at a cancel and goes idle with cancelled", async () => {
|
||||
const sessionId = await newSession("bypassPermissions");
|
||||
await prompt(
|
||||
sessionId,
|
||||
"Use the Bash tool in the foreground (not run_in_background) to run exactly: ping -c 30 127.0.0.1 > /dev/null . Then reply with just: woke",
|
||||
);
|
||||
await until(() =>
|
||||
updates.some(
|
||||
({ update }) =>
|
||||
update.sessionUpdate === "terminal_update" &&
|
||||
/ping/.test((update as v2.TerminalUpdate).command ?? ""),
|
||||
),
|
||||
);
|
||||
// Claude Code takes in a prompt sent while it works only at its next
|
||||
// step, so this one is still queued at the cancel.
|
||||
// It answers when Claude Code starts it, so the cancel fails it instead.
|
||||
const queued = prompt(sessionId, "Reply with just: queued");
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await agent.notify(v2.methods.agent.session.cancel, { sessionId });
|
||||
await expect(queued).rejects.toMatchObject({ code: -32800 });
|
||||
await until(() => states(sessionId).some((update) => update.state === "idle"));
|
||||
expect(states(sessionId).at(-1)).toMatchObject({ state: "idle", stopReason: "cancelled" });
|
||||
|
||||
// The next prompt runs normally.
|
||||
const before = states(sessionId).length;
|
||||
await prompt(sessionId, "Reply with exactly the single word: ok");
|
||||
await until(() =>
|
||||
states(sessionId)
|
||||
.slice(before)
|
||||
.some((update) => update.state === "idle"),
|
||||
);
|
||||
expect(states(sessionId).slice(before).at(-1)).toMatchObject({ stopReason: "end_turn" });
|
||||
}, 240_000);
|
||||
});
|
||||
3966
src/tests/v2.test.ts
Normal file
3966
src/tests/v2.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,10 +27,10 @@ export class ClientCapabilities {
|
||||
readonly air: AirCapabilities = NO_AIR,
|
||||
/**
|
||||
* The client speaks ACP v2. Its diffs carry structured `changes` and an
|
||||
* optional git patch, which a v1 diff cannot express. Only the v2 surface
|
||||
* sets this, never the capabilities that a client sends. A v2 client also
|
||||
* takes the terminal extension (`terminalOutput`), which the v2 surface
|
||||
* sends as display terminals.
|
||||
* optional git patch, which a v1 diff cannot express. Only the v2 core
|
||||
* (`src/v2/`) sets this, never the capabilities that a client
|
||||
* sends. A v2 client also takes the terminal extension (`terminalOutput`),
|
||||
* which the v2 core sends as display terminals.
|
||||
*/
|
||||
readonly v2: boolean = false,
|
||||
) {}
|
||||
|
||||
@@ -29,13 +29,12 @@ export type ToolCallUpdate = SessionNotification["update"];
|
||||
const PLAN_FILE_CLEAR_SUFFIX = ":plan-file-clear";
|
||||
|
||||
/**
|
||||
* ACP v2's `cancelled` and `paused` tool call statuses. v1's statuses have no
|
||||
* such values, and the agent builds its reports in v1 types, so they travel as
|
||||
* v1 statuses. Only a v2 client gets them, and the v2 surface sends them as
|
||||
* they are (`src/v2/tool-call.ts`).
|
||||
* ACP v2's `cancelled` tool call status. v1's statuses have no such value, and
|
||||
* the shared mapping builds its reports in v1 types, so it travels as a v1
|
||||
* status. Only a v2 client gets it, and the v2 core sends it as it is
|
||||
* (`src/v2/tool-call.ts`).
|
||||
*/
|
||||
export const V2_CANCELLED = "cancelled" as unknown as ToolCallStatus;
|
||||
export const V2_PAUSED = "paused" as unknown as ToolCallStatus;
|
||||
|
||||
/** Whether `status` ends a tool call: `completed`, `failed`, or, on v2, `cancelled`. */
|
||||
export function isFinalToolCallStatus(status: ToolCallStatus | null | undefined): boolean {
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
/**
|
||||
* A prompt turn and the foreground work of a session, as the adapter
|
||||
* understands them, independent of the ACP version.
|
||||
*
|
||||
* The agent reports how each prompt is answered through {@link TurnEvents}:
|
||||
* ACP v1 answers `session/prompt` when the turn ends or fails, and ACP v2 when
|
||||
* the prompt is inserted. ACP v2 also reports the session's work as state,
|
||||
* which the agent reports through {@link ForegroundEvents}.
|
||||
* A prompt turn of `ClaudeAcpAgent`. The agent reports how each prompt is
|
||||
* answered through {@link TurnEvents}: its `session/prompt` answers when the
|
||||
* turn ends or fails.
|
||||
*
|
||||
* The agent calls these synchronously while it updates its own state, so a
|
||||
* handler must not throw, nor call back into the agent before it returns.
|
||||
@@ -13,8 +9,9 @@
|
||||
export interface TurnEvents {
|
||||
/**
|
||||
* Claude Code took the prompt in as the user message `messageId` of the
|
||||
* conversation. Reported at most once per turn, and never after
|
||||
* {@link ended} or {@link failed}.
|
||||
* conversation, and the turn is the session's foreground work from then on.
|
||||
* Reported at most once per turn, and never after {@link ended} or
|
||||
* {@link failed}.
|
||||
*
|
||||
* When Claude Code reports command lifecycles (`msg_lifecycle_v1`), that is
|
||||
* when the prompt starts a turn, before the turn's output. Otherwise, and
|
||||
@@ -37,44 +34,6 @@ export interface TurnEvents {
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The foreground work of a session: what a client would wait for before it
|
||||
* sends the next prompt. One piece of work runs at a time, and the next starts
|
||||
* with {@link running}. The work is either:
|
||||
*
|
||||
* - A prompt's turn, from {@link TurnEvents.inserted} until its result, or
|
||||
* until it fails or is cancelled. A turn that v1 holds open for the
|
||||
* background subagents it spawned (`Turn.deferredSettle`) ends its work at
|
||||
* its result all the same: the hold only delays v1's answer.
|
||||
* - A model cycle that Claude Code starts on its own while no work runs: for
|
||||
* example, its follow-up after a background command or subagent finished,
|
||||
* or its answer to a message from another session. It runs until its result.
|
||||
*
|
||||
* A prompt that Claude Code takes in while a cycle runs takes the cycle over:
|
||||
* the cycle reports nothing more, and the turn reports from there.
|
||||
*/
|
||||
export interface ForegroundEvents {
|
||||
/**
|
||||
* The work started, or works again after it waited for the user. Not after
|
||||
* a wait that a cancel ended: Claude Code's interrupt withdrew the last
|
||||
* request, so the work is being stopped, and {@link ended} follows at once.
|
||||
*/
|
||||
running(): void;
|
||||
/**
|
||||
* The work waits for the user: a permission request or a question is open in
|
||||
* the session. Requests do not say which work they are for, so this includes
|
||||
* one opened before the work started.
|
||||
*/
|
||||
awaitingUser(): void;
|
||||
/** The work ended. */
|
||||
ended(outcome: TurnOutcome): void;
|
||||
/**
|
||||
* The work failed with `error`, the JSON-RPC error that v1 answers the
|
||||
* prompt with.
|
||||
*/
|
||||
failed(error: unknown): void;
|
||||
}
|
||||
|
||||
/** Why a turn ended. Every ACP version has these reasons. */
|
||||
export type StopReason = "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled";
|
||||
|
||||
|
||||
1360
src/v2/agent.ts
1360
src/v2/agent.ts
File diff suppressed because it is too large
Load Diff
66
src/v2/air.ts
Normal file
66
src/v2/air.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* The JetBrains AIR extensions (`docs/air-extensions.md`) that the v2
|
||||
* core serves, and their negotiation. Only a client that declares
|
||||
* `_meta.jetbrains.air` in `initialize` gets them, with the wire output that
|
||||
* the v1 core gives AIR. The extensions that ACP v2 covers on its own are
|
||||
* not served (see "ACP v2" in docs/air-extensions.md).
|
||||
*/
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import {
|
||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||
airCapabilityMeta,
|
||||
clientSupportsAirCapability,
|
||||
isAirClient,
|
||||
} from "../air-extension.js";
|
||||
import {
|
||||
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
|
||||
supportsAgentFileChangeReport,
|
||||
} from "../file-change-audit.js";
|
||||
|
||||
/** What the connection's client takes of the AIR extensions. */
|
||||
export interface AirSupport {
|
||||
/**
|
||||
* The client is AIR. It gets the permission presentation, the AIR
|
||||
* permission tool call `_meta`, AskUserQuestion's custom answer marker and
|
||||
* the predicted follow-up prompt.
|
||||
*/
|
||||
readonly client: boolean;
|
||||
/** `recommendedValue`: concrete model and effort rows with a recommendation. */
|
||||
readonly recommendedValue: boolean;
|
||||
/** `agentFileChangeReport`: the changed files of a prompt's turn. */
|
||||
readonly fileChangeReport: boolean;
|
||||
}
|
||||
|
||||
/** A client that is not AIR. */
|
||||
export const NO_AIR: AirSupport = {
|
||||
client: false,
|
||||
recommendedValue: false,
|
||||
fileChangeReport: false,
|
||||
};
|
||||
|
||||
/** What the client declared in `initialize`. */
|
||||
export function airSupport(capabilities: v2.ClientCapabilities | null | undefined): AirSupport {
|
||||
if (!isAirClient(capabilities)) return NO_AIR;
|
||||
return {
|
||||
client: true,
|
||||
recommendedValue: clientSupportsAirCapability(
|
||||
capabilities,
|
||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||
),
|
||||
fileChangeReport: supportsAgentFileChangeReport(capabilities),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `initialize` response `_meta` of an AIR client: the capabilities this
|
||||
* core serves, which the v1 core advertises too. `loadReplayEnd` is served
|
||||
* (see `setup.ts`) but not listed: the client declares it, and the agent does
|
||||
* not advertise it. Undefined for every other client.
|
||||
*/
|
||||
export function airInitializeMeta(air: AirSupport): Record<string, unknown> | undefined {
|
||||
if (!air.client) return undefined;
|
||||
return airCapabilityMeta(
|
||||
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
|
||||
AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY,
|
||||
);
|
||||
}
|
||||
295
src/v2/auth.ts
Normal file
295
src/v2/auth.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* The connection's auth and LLM routing on the v2 core: the `authStatus`
|
||||
* extension (`_meta.authStatus` and `_auth/status_update`), the gateway logins
|
||||
* of `auth/login`, `auth/logout`, and `providers/list|set|disable`.
|
||||
*
|
||||
* The state and its rules are the v1 core's (`AuthStatusReporter`,
|
||||
* `providers.ts`). The identity is read at the same moments as there: after
|
||||
* `initialize`, from each session's account, after a login or a logout, and at
|
||||
* the start of each prompt (see `auth-status.ts`).
|
||||
*
|
||||
* A gateway login routes the sessions started after it.
|
||||
* `providers/set`, `providers/disable`, and a logout that drops a provider or
|
||||
* a gateway login also route the live sessions: each one's query restarts at
|
||||
* its next turn boundary (see `restarts.ts`). The request answers once the
|
||||
* sessions at a turn boundary restarted, and does not wait for the busy ones.
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { AccountInfo, ModelInfo, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AuthStatusKind } from "../auth-status.js";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import { terminalAuthMethods } from "../auth-methods.js";
|
||||
import {
|
||||
AUTH_STATUS_UPDATE_METHOD,
|
||||
authStatusCapability,
|
||||
gatewayAuthStatus,
|
||||
notLoggedInAuthStatus,
|
||||
} from "../auth-status.js";
|
||||
import { AuthStatusReporter, cliLogout, type AuthStatusSettings } from "../auth-status-reporter.js";
|
||||
import { clearContextWindowCache } from "../context-window.js";
|
||||
import {
|
||||
createEnvForProvider,
|
||||
defaultProviderConfig,
|
||||
type GatewayAuthRequest,
|
||||
gatewayAuthMethods,
|
||||
gatewayRequestToProviderConfig,
|
||||
isGatewayAuthMethod,
|
||||
listProvidersResponse,
|
||||
PROVIDER_ID,
|
||||
type ProviderConfig,
|
||||
providerConfigFromSetRequest,
|
||||
validatedGatewayAuthRequest,
|
||||
withListedModels,
|
||||
} from "../providers.js";
|
||||
import { narrowToGatewayModels } from "../session-model.js";
|
||||
import { v2AuthMethod } from "./initialize.js";
|
||||
|
||||
/** The route of a session that starts now. */
|
||||
export interface SessionRouting {
|
||||
/** Added to the query's env. Empty without a gateway login or a provider. */
|
||||
env: Record<string, string>;
|
||||
/**
|
||||
* The programmatic settings of the query, with the route's env too: Claude
|
||||
* Code applies the env of settings.json after the process env, so user or
|
||||
* project settings could restore another `ANTHROPIC_BASE_URL` otherwise.
|
||||
*/
|
||||
settings?: Settings;
|
||||
/** The models the session offers: those the provider's gateway listed. */
|
||||
models(models: ModelInfo[]): ModelInfo[];
|
||||
/** Whether {@link models} narrows the models to a gateway's list. */
|
||||
narrowsModels: boolean;
|
||||
}
|
||||
|
||||
/** What the auth state tells the rest of the core. */
|
||||
export interface AuthEvents {
|
||||
/**
|
||||
* The route of live sessions changed: resolves once the sessions at a turn
|
||||
* boundary restarted (see `SessionRestarts.reroute`).
|
||||
*/
|
||||
routeChanged(): Promise<void>;
|
||||
/** An auth status probe read the identity `kind`. */
|
||||
probed(kind: AuthStatusKind): void;
|
||||
}
|
||||
|
||||
export class V2Auth {
|
||||
private gatewayLogin?: GatewayAuthRequest;
|
||||
private provider?: ProviderConfig;
|
||||
/** Runs `providers/set` and `providers/disable` one at a time, in arrival order. */
|
||||
private providerUpdate: Promise<unknown> = Promise.resolve();
|
||||
/** How many of {@link providerUpdate} have not settled. */
|
||||
private providerUpdates = 0;
|
||||
/** The settings of each live session, for the probe at the start of its prompts. */
|
||||
private readonly sessionSettings = new Map<string, AuthStatusSettings>();
|
||||
private readonly status: AuthStatusReporter;
|
||||
/**
|
||||
* Counts the route changes that restarted the live sessions. A session
|
||||
* whose start read {@link routing} before one is not live yet then, so it
|
||||
* restarts once its setup answered (see `SessionRestarts.track`).
|
||||
*/
|
||||
private routeChanges = 0;
|
||||
|
||||
constructor(
|
||||
ctx: v2.AgentContext,
|
||||
private readonly logger: Logger,
|
||||
private readonly events: AuthEvents,
|
||||
) {
|
||||
this.status = new AuthStatusReporter({
|
||||
push: (authStatus) => ctx.notify(AUTH_STATUS_UPDATE_METHOD, { authStatus }),
|
||||
logger,
|
||||
gatewayLogin: () => this.gatewayLogin !== undefined,
|
||||
providerOverride: () => this.provider !== undefined,
|
||||
onProbe: (kind) => events.probed(kind),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The auth fields of the `initialize` response. It starts the probe, which
|
||||
* nothing waits for: its push goes out after the response.
|
||||
*/
|
||||
initialize(capabilities: v2.ClientCapabilities | null | undefined): {
|
||||
authMethods: v2.AuthMethod[];
|
||||
_meta: { authStatus: Record<string, never> };
|
||||
} {
|
||||
void this.status.probe();
|
||||
return {
|
||||
authMethods: [
|
||||
// v2 advertises terminal logins only to a client with `auth.terminal`.
|
||||
...(capabilities?.auth?.terminal != null
|
||||
? terminalAuthMethods({ terminal: true, metaTerminal: false })
|
||||
: []),
|
||||
// Only offered when the client advertises `auth._meta.gateway`.
|
||||
...(capabilities?.auth?._meta?.gateway === true ? gatewayAuthMethods() : []),
|
||||
].map(v2AuthMethod),
|
||||
// The `authStatus` marker: this agent pushes its identity.
|
||||
_meta: { authStatus: authStatusCapability() },
|
||||
};
|
||||
}
|
||||
|
||||
/** `auth/login`: a gateway login. The terminal logins are the client's to run. */
|
||||
login(params: v2.LoginAuthRequest): v2.LoginAuthResponse {
|
||||
if (!isGatewayAuthMethod(params.methodId)) {
|
||||
const terminal = terminalAuthMethods({ terminal: true, metaTerminal: false });
|
||||
if (terminal.some((method) => method.id === params.methodId)) {
|
||||
throw RequestError.invalidParams(
|
||||
{ methodId: params.methodId },
|
||||
`${params.methodId} is a terminal login: run it in a terminal, then retry the request`,
|
||||
);
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
{ methodId: params.methodId },
|
||||
`Unsupported authentication method: ${params.methodId}`,
|
||||
);
|
||||
}
|
||||
this.gatewayLogin = validatedGatewayAuthRequest(params);
|
||||
// The gateway holds the credentials from here on. A probe that started
|
||||
// before this login is stale.
|
||||
this.status.changed();
|
||||
this.status.set(gatewayAuthStatus(gatewayRequestToProviderConfig(this.gatewayLogin)?.baseUrl));
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* `auth/logout`: drops the gateway login and the provider, runs `claude auth
|
||||
* logout`, and reports the identity that remains (an API key from the env
|
||||
* survives it).
|
||||
*/
|
||||
async logout(): Promise<v2.LogoutAuthResponse> {
|
||||
// A `providers/set` that is still listing would set a provider too.
|
||||
const routed = this.overridesRoute() || this.providerUpdates > 0;
|
||||
this.gatewayLogin = undefined;
|
||||
this.status.changed();
|
||||
await this.updateProvider(async () => {
|
||||
this.provider = undefined;
|
||||
});
|
||||
// The windows learned under this login must not seed sessions under the next.
|
||||
clearContextWindowCache();
|
||||
try {
|
||||
await cliLogout();
|
||||
} finally {
|
||||
// The live sessions leave the dropped route, also when the CLI logout
|
||||
// failed. A plain CLI login needs no restart: the running CLI picks up
|
||||
// the logout by itself.
|
||||
if (routed) await this.rerouted();
|
||||
}
|
||||
// The read starts after the credentials were cleared, so it does not join
|
||||
// a probe in flight. If it cannot read them, the logout still happened.
|
||||
if (!(await this.status.probe({ fresh: true }))) {
|
||||
this.status.set(notLoggedInAuthStatus());
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
listProviders(): v2.ListProvidersResponse {
|
||||
return listProvidersResponse(this.provider ?? defaultProviderConfig(this.gatewayLogin));
|
||||
}
|
||||
|
||||
async setProvider(params: v2.SetProviderRequest): Promise<v2.SetProviderResponse> {
|
||||
const config = providerConfigFromSetRequest(params);
|
||||
await this.updateProvider(async () => {
|
||||
this.provider = await withListedModels(config, this.provider, this.logger);
|
||||
});
|
||||
await this.rerouted();
|
||||
return {};
|
||||
}
|
||||
|
||||
async disableProvider(params: v2.DisableProviderRequest): Promise<v2.DisableProviderResponse> {
|
||||
// An unknown provider: idempotent success.
|
||||
if (params.providerId === PROVIDER_ID) {
|
||||
await this.updateProvider(async () => {
|
||||
this.provider = undefined;
|
||||
});
|
||||
await this.rerouted();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
/** Whether a provider or a gateway login routes the traffic, rather than the CLI's own login. */
|
||||
overridesRoute(): boolean {
|
||||
return (this.provider ?? gatewayRequestToProviderConfig(this.gatewayLogin)) != null;
|
||||
}
|
||||
|
||||
/** The number of route changes so far: read it right before {@link routing}. */
|
||||
get routeGeneration(): number {
|
||||
return this.routeChanges;
|
||||
}
|
||||
|
||||
/** The route changed: restarts the live sessions (see `SessionRestarts.reroute`). */
|
||||
private rerouted(): Promise<void> {
|
||||
// In the same call as the reroute's look at the live sessions: a session
|
||||
// either is among them or sees the new count when it is tracked.
|
||||
this.routeChanges++;
|
||||
return this.events.routeChanged();
|
||||
}
|
||||
|
||||
/** Settles once the provider changes that came so far have applied. */
|
||||
async routeSettled(): Promise<void> {
|
||||
await this.providerUpdate.catch(() => undefined);
|
||||
}
|
||||
|
||||
/** The route of a session that starts now: the provider, else the gateway login. */
|
||||
routing(): SessionRouting {
|
||||
const config = this.provider ?? gatewayRequestToProviderConfig(this.gatewayLogin);
|
||||
const env = createEnvForProvider(config);
|
||||
return {
|
||||
env,
|
||||
...(config ? { settings: { apiKeyHelper: "", env } } : {}),
|
||||
models: (models) =>
|
||||
config?.listedModels
|
||||
? narrowToGatewayModels(models, config.listedModels, config.bedrockRegionPrefix)
|
||||
: models,
|
||||
narrowsModels: config?.listedModels !== undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A session starts under `settings`: probes run in them from now on. Call
|
||||
* the result with the session's account once Claude Code reports it. It
|
||||
* reports nothing when a login or a logout came in between.
|
||||
*/
|
||||
sessionStarting(
|
||||
settings: AuthStatusSettings,
|
||||
): (sessionId: string, account: AccountInfo | undefined) => void {
|
||||
this.status.useSettings(settings);
|
||||
const epoch = this.status.epoch;
|
||||
return (sessionId, account) => {
|
||||
this.sessionSettings.set(sessionId, settings);
|
||||
if (epoch === this.status.epoch) {
|
||||
this.status.publishSessionAccount(account, settings.getApiKeyHelper());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt of `sessionId` starts: reads the identity again, without waiting.
|
||||
* This finds a login or a logout done in another terminal.
|
||||
*/
|
||||
promptStarted(sessionId: string): void {
|
||||
const settings = this.sessionSettings.get(sessionId);
|
||||
if (settings) this.status.useSettings(settings);
|
||||
void this.status.probe();
|
||||
}
|
||||
|
||||
/** Reports the account that the query of `sessionId` cached, as the identity. */
|
||||
publishAccount(sessionId: string, account: AccountInfo): void {
|
||||
this.status.publishSessionAccount(
|
||||
account,
|
||||
this.sessionSettings.get(sessionId)?.getApiKeyHelper(),
|
||||
);
|
||||
}
|
||||
|
||||
sessionEnded(sessionId: string): void {
|
||||
this.sessionSettings.delete(sessionId);
|
||||
}
|
||||
|
||||
private updateProvider(update: () => Promise<void>): Promise<void> {
|
||||
const next = this.providerUpdate.catch(() => undefined).then(update);
|
||||
this.providerUpdate = next;
|
||||
this.providerUpdates++;
|
||||
const settled = () => {
|
||||
this.providerUpdates--;
|
||||
};
|
||||
next.then(settled, settled);
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,35 @@
|
||||
/**
|
||||
* The `background` lists of the v2 sessions of a connection (see
|
||||
* `background-work.ts`, which builds them).
|
||||
* The `background` lists of a v2 session and of its subagent sessions (see
|
||||
* `tasks.ts`, which builds them).
|
||||
*
|
||||
* A session that reports its background work gets its list on every
|
||||
* `state_update` of its own stream, as v2 requires, from the list the agent
|
||||
* `state_update` of its own stream, as v2 requires, from the list the core
|
||||
* reports when the session starts. A subagent session of such a session
|
||||
* reports its own list from its announcement on, also in the parent's mirror
|
||||
* of its state (`subagent_update.state`).
|
||||
*/
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { BackgroundItem, BackgroundReport, WorkStateName } from "../background-work.js";
|
||||
import type { V2Terminals } from "./terminal.js";
|
||||
|
||||
/** An item of a `background` list. */
|
||||
export type BackgroundItem =
|
||||
| { type: "terminal"; terminalId: string }
|
||||
| { type: "tool_call"; toolCallId: string }
|
||||
| { type: "subagent"; sessionId: string };
|
||||
|
||||
/** The work state of a session, as its `state_update` names it. */
|
||||
export type WorkStateName = "idle" | "running" | "requires_action" | "unknown";
|
||||
|
||||
/** A new list of the session `sessionId`, with what its repeated state needs. */
|
||||
export interface BackgroundReport {
|
||||
sessionId: string;
|
||||
items: BackgroundItem[];
|
||||
/** The session's work state now, or undefined when it has none yet. */
|
||||
state: WorkStateName | undefined;
|
||||
/** The parent of a subagent session, which mirrors the child's state. */
|
||||
parentSessionId?: string;
|
||||
}
|
||||
|
||||
/** `state` as the current work state of a session, with no stop reason. */
|
||||
function currentState(state: WorkStateName): v2.StateUpdate {
|
||||
if (state === "idle") return { state: "idle" };
|
||||
@@ -34,14 +52,8 @@ function withBackground(state: v2.StateUpdate, background: BackgroundItem[]): v2
|
||||
const FINAL_STATUSES = new Set(["completed", "failed", "cancelled"]);
|
||||
|
||||
export class V2Background {
|
||||
/**
|
||||
* The latest list of each session that reports one, whether a state of the
|
||||
* session carried it, and the parent of a subagent session.
|
||||
*/
|
||||
private readonly sessions = new Map<
|
||||
string,
|
||||
{ items: BackgroundItem[]; reported: boolean; parent?: string }
|
||||
>();
|
||||
/** The latest list of each session that reports one, and whether a state of the session carried it. */
|
||||
private readonly sessions = new Map<string, { items: BackgroundItem[]; reported: boolean }>();
|
||||
|
||||
/**
|
||||
* The tool calls that advertise `cancel`, by session and tool call. Like a
|
||||
@@ -78,7 +90,7 @@ export class V2Background {
|
||||
const subagent = update as Extract<v2.SessionUpdate, { sessionUpdate: "subagent_update" }>;
|
||||
let child = this.sessions.get(subagent.sessionId);
|
||||
if (!child) {
|
||||
child = { items: [], reported: false, parent: sessionId };
|
||||
child = { items: [], reported: false };
|
||||
this.sessions.set(subagent.sessionId, child);
|
||||
}
|
||||
if (subagent.state) {
|
||||
@@ -107,11 +119,7 @@ export class V2Background {
|
||||
const { sessionId, items, state, parentSessionId } = report;
|
||||
const session = this.sessions.get(sessionId);
|
||||
const unchanged = session && JSON.stringify(session.items) === JSON.stringify(items);
|
||||
this.sessions.set(sessionId, {
|
||||
items,
|
||||
reported: session?.reported ?? false,
|
||||
parent: parentSessionId ?? session?.parent,
|
||||
});
|
||||
this.sessions.set(sessionId, { items, reported: session?.reported ?? false });
|
||||
const known = new Set((session?.items ?? []).map((item) => JSON.stringify(item)));
|
||||
const updates: v2.UpdateSessionNotification[] = items.flatMap((item) =>
|
||||
known.has(JSON.stringify(item)) ? [] : this.advertiseCancel(sessionId, item, false),
|
||||
@@ -131,8 +139,7 @@ export class V2Background {
|
||||
/**
|
||||
* The current `terminal_update` of each terminal that the session
|
||||
* `sessionId` lists, and the `cancel` capability of each tool call that it
|
||||
* lists, whose other fields the agent sends (see
|
||||
* `ClaudeAcpAgent.publishBackgroundToolCalls`). v2 requires them before the
|
||||
* lists, whose other fields the core sends. v2 requires them before the
|
||||
* state snapshot of `session/resume`, as a replay may not have delivered
|
||||
* them.
|
||||
*/
|
||||
@@ -142,13 +149,6 @@ export class V2Background {
|
||||
);
|
||||
}
|
||||
|
||||
/** The tool calls that the session `sessionId` lists. */
|
||||
listedToolCalls(sessionId: string): string[] {
|
||||
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) =>
|
||||
item.type === "tool_call" ? [item.toolCallId] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The update that lets the client cancel the listed terminal or tool call
|
||||
* `item`, unless it advertises that already. `again` sends it anyway, as
|
||||
@@ -178,22 +178,4 @@ export class V2Background {
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/** The subagent sessions that the session `sessionId` lists. */
|
||||
listedSubagents(sessionId: string): string[] {
|
||||
return (this.sessions.get(sessionId)?.items ?? []).flatMap((item) =>
|
||||
item.type === "subagent" ? [item.sessionId] : [],
|
||||
);
|
||||
}
|
||||
|
||||
/** Forgets the list of a session, and those of its subagent sessions. */
|
||||
forget(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
for (const key of this.cancellableToolCalls) {
|
||||
if (key.startsWith(`${sessionId} `)) this.cancellableToolCalls.delete(key);
|
||||
}
|
||||
for (const [child, { parent }] of this.sessions) {
|
||||
if (parent === sessionId) this.forget(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
129
src/v2/child-config.ts
Normal file
129
src/v2/child-config.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The configuration of each child session, as the v1 core reports it
|
||||
* (`NativeSubagentRuntime.reportConfiguration`): the options that the
|
||||
* child's hooks report (its mode, model after a `PostModelSwitch`, effort and
|
||||
* agent type, see `subagentHookOptions`) and the model of its responses. Each
|
||||
* report merges into the child's options, by id, and the client gets all of
|
||||
* them again when any changed, in `orderedSubagentConfiguration`'s order.
|
||||
*
|
||||
* A hook can come before the child's announcement: its options wait for it.
|
||||
*/
|
||||
import type { SessionConfigOption, SessionModeState } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AgentInfo, HookCallbackMatcher, HookInput } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SubagentModels } from "../native-subagents.js";
|
||||
import { orderedSubagentConfiguration, subagentHookOptions } from "../subagent-configuration.js";
|
||||
|
||||
/** The most children not announced yet whose options wait. */
|
||||
const MAX_PENDING_CHILDREN = 64;
|
||||
|
||||
/** What the configurations need of their root session. */
|
||||
export interface ConfigurationHost {
|
||||
/** The root's modes, for the name of a child's mode. */
|
||||
modes(): SessionModeState["availableModes"];
|
||||
/** The entries of the root's `model` option, for a child's `model`. */
|
||||
models(): SubagentModels;
|
||||
/** Whether `agentId` is an announced child session. */
|
||||
announced(agentId: string): boolean;
|
||||
}
|
||||
|
||||
export class ChildConfigurations {
|
||||
/** Each child's options, by id, and the snapshot that the client last got. */
|
||||
private readonly children = new Map<
|
||||
string,
|
||||
{ options: Map<string, SessionConfigOption>; published?: string }
|
||||
>();
|
||||
/** The custom agents of the session, for the description of a child's agent type. */
|
||||
private agents: readonly AgentInfo[] = [];
|
||||
|
||||
constructor(private readonly host: ConfigurationHost) {}
|
||||
|
||||
/** The session's custom agents, from Claude Code's initialization (see `customAgents`). */
|
||||
configureAgents(agents: readonly AgentInfo[]): void {
|
||||
this.agents = agents;
|
||||
}
|
||||
|
||||
/** The options that a hook of a subagent reports of it. */
|
||||
hookOptions(input: HookInput): SessionConfigOption[] {
|
||||
return subagentHookOptions(input, {
|
||||
availableModes: this.host.modes(),
|
||||
models: this.host.models(),
|
||||
agents: this.agents,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes options of the child `agentId`. Those of a child that is not
|
||||
* announced wait for it, for at most {@link MAX_PENDING_CHILDREN} children.
|
||||
* Returns whether they were kept.
|
||||
*/
|
||||
take(agentId: string, options: SessionConfigOption[]): boolean {
|
||||
if (options.length === 0) return false;
|
||||
let config = this.children.get(agentId);
|
||||
if (!config) {
|
||||
const pending = [...this.children.keys()].filter((id) => !this.host.announced(id)).length;
|
||||
if (!this.host.announced(agentId) && pending >= MAX_PENDING_CHILDREN) return false;
|
||||
config = { options: new Map() };
|
||||
this.children.set(agentId, config);
|
||||
}
|
||||
for (const option of options) config.options.set(option.id, option);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's configuration to send, or undefined when the client holds it
|
||||
* already. It counts as sent.
|
||||
*/
|
||||
unsent(agentId: string): SessionConfigOption[] | undefined {
|
||||
const config = this.children.get(agentId);
|
||||
if (!config) return undefined;
|
||||
const options = orderedSubagentConfiguration(config.options.values());
|
||||
const snapshot = JSON.stringify(options);
|
||||
if (config.published === snapshot) return undefined;
|
||||
config.published = snapshot;
|
||||
return options;
|
||||
}
|
||||
|
||||
/** The configuration that the client last got for the child `agentId`, if any. */
|
||||
published(agentId: string): SessionConfigOption[] | undefined {
|
||||
const published = this.children.get(agentId)?.published;
|
||||
return published === undefined ? undefined : (JSON.parse(published) as SessionConfigOption[]);
|
||||
}
|
||||
|
||||
/** A child that stays unexposed: its options go nowhere. */
|
||||
forget(agentId: string): void {
|
||||
this.children.delete(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hook that reports a subagent's hooks to `report`, for each hook event
|
||||
* that reports them. The hook makes no decision.
|
||||
*/
|
||||
export function subagentHooks(
|
||||
report: (input: HookInput) => void,
|
||||
): Record<
|
||||
| "SubagentStart"
|
||||
| "SubagentStop"
|
||||
| "PreToolUse"
|
||||
| "PostToolUse"
|
||||
| "PostToolUseFailure"
|
||||
| "PostModelSwitch",
|
||||
HookCallbackMatcher
|
||||
> {
|
||||
const matcher: HookCallbackMatcher = {
|
||||
hooks: [
|
||||
async (input) => {
|
||||
if (input.agent_id) report(input);
|
||||
return { continue: true };
|
||||
},
|
||||
],
|
||||
};
|
||||
return {
|
||||
SubagentStart: matcher,
|
||||
SubagentStop: matcher,
|
||||
PreToolUse: matcher,
|
||||
PostToolUse: matcher,
|
||||
PostToolUseFailure: matcher,
|
||||
PostModelSwitch: matcher,
|
||||
};
|
||||
}
|
||||
254
src/v2/clear-context.ts
Normal file
254
src/v2/clear-context.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
/**
|
||||
* ExitPlanMode's "Yes, clear context" option on the v2 core: the
|
||||
* approved plan runs in a fresh context, in the same turn.
|
||||
*
|
||||
* The answer denies ExitPlanMode with an interrupt (`permissions/effects.ts`),
|
||||
* so the plan does not run in the old context. Claude Code ends that cycle
|
||||
* with an interruption result (`isExitPlanInterruptionResult`). The turn stays
|
||||
* open, and the session sends Claude Code `/clear`, which mounts a fresh
|
||||
* conversation (`conversation_reset`), sets the mode the user picked, and
|
||||
* sends the plan as the prompt "Implement the following plan". The turn ends
|
||||
* with that prompt's result, with the usage of the whole turn.
|
||||
*
|
||||
* The query stays, unlike v1's fresh query (`clear-context-coordinator.ts`):
|
||||
* `/clear` is Claude Code's own way to start a fresh conversation, and the
|
||||
* query's tasks, MCP servers and settings stay. The client sees the accepted plan's tool call `completed`, the
|
||||
* options with the new mode, a `usage_update` of an empty context, the plan
|
||||
* as the session's markdown plan, then the plan's run in the open turn, with
|
||||
* no `user_message` for the adapter's prompts.
|
||||
*/
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import type { ClearContextReset } from "../clear-context-coordinator.js";
|
||||
import {
|
||||
acceptedPlanToolResult,
|
||||
isExitPlanInterruptionResult,
|
||||
observeExitPlanToolResults,
|
||||
} from "../exit-plan.js";
|
||||
import type { PermissionMode, SDKResultMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { quotaMeta, type AccumulatedUsage, type ModelTokenTally } from "../session-usage.js";
|
||||
import type { TurnEnd } from "./outcome.js";
|
||||
|
||||
type Uuid = `${string}-${string}-${string}-${string}-${string}`;
|
||||
|
||||
/** What the session does for a fresh context. */
|
||||
export interface ClearContextHost {
|
||||
/** Sends Claude Code the prompt `text` with `uuid`, as the adapter's own prompt. */
|
||||
push(text: string, uuid: Uuid): void;
|
||||
/** Sets the permission mode on the query and in the options. */
|
||||
enterMode(mode: PermissionMode): Promise<void>;
|
||||
/** Sends the fresh context's options, its empty usage, and the plan. */
|
||||
publish(plan: string): void;
|
||||
logError(message: string, error: unknown): void;
|
||||
}
|
||||
|
||||
/** The turn while it continues in a fresh context. */
|
||||
type Continuation = {
|
||||
reset: ClearContextReset;
|
||||
clearUuid: Uuid;
|
||||
planUuid: Uuid;
|
||||
/** The results of the turn so far: the old context's, and `/clear`'s. */
|
||||
carried: TurnEnd[];
|
||||
/** Whether the plan's prompt went out: once the fresh context is mounted. */
|
||||
planSent: boolean;
|
||||
/** Whether the plan's prompt started: its result ends the turn. */
|
||||
planStarted: boolean;
|
||||
};
|
||||
|
||||
export class ClearContext {
|
||||
/** The plan the user accepted with clearing the context, until its interruption. */
|
||||
private accepted: ClearContextReset | undefined;
|
||||
/** An ExitPlanMode answer that interrupts the cycle, and whether its tool result came. */
|
||||
private interruption: { toolUseId: string; toolResultSeen: boolean } | undefined;
|
||||
private continuation: Continuation | undefined;
|
||||
/** The tool calls of the old context's tool uses, by id (`toolUseCache`). */
|
||||
private readonly toolUses: () => Record<string, { name: string } | undefined>;
|
||||
|
||||
constructor(
|
||||
private readonly host: ClearContextHost,
|
||||
toolUses: () => Record<string, { name: string } | undefined>,
|
||||
private readonly newUuid: () => Uuid,
|
||||
) {
|
||||
this.toolUses = toolUses;
|
||||
}
|
||||
|
||||
/**
|
||||
* An ExitPlanMode answer that interrupts the cycle: "No, keep planning",
|
||||
* or a plan accepted with clearing the context (`reset`).
|
||||
*/
|
||||
interrupting(toolUseId: string, reset: ClearContextReset | undefined): void {
|
||||
this.interruption = { toolUseId, toolResultSeen: false };
|
||||
this.accepted = reset;
|
||||
}
|
||||
|
||||
/**
|
||||
* A user message of the main conversation. Returns the rewrite of its
|
||||
* updates: the accepted plan's tool result, which Claude Code reports as a
|
||||
* rejection, goes out `completed`, without the rejection's text.
|
||||
*/
|
||||
toolResults(message: {
|
||||
type: string;
|
||||
tool_result_meta?: unknown;
|
||||
message?: { content?: unknown };
|
||||
}): ((notification: AcpSessionNotification) => AcpSessionNotification) | undefined {
|
||||
const state = {
|
||||
toolUseCache: this.toolUses(),
|
||||
pendingExitPlanModeInterruption: this.interruption,
|
||||
pendingExitPlanContextReset: this.accepted,
|
||||
};
|
||||
const acceptedToolUse = observeExitPlanToolResults(message, message.message?.content, state);
|
||||
this.interruption = state.pendingExitPlanModeInterruption;
|
||||
if (!acceptedToolUse) return undefined;
|
||||
return (notification) =>
|
||||
acceptedPlanToolResult(
|
||||
notification as Parameters<typeof acceptedPlanToolResult>[0],
|
||||
acceptedToolUse,
|
||||
) as AcpSessionNotification;
|
||||
}
|
||||
|
||||
/**
|
||||
* A result of the open turn. Returns whether the turn goes on: it is the
|
||||
* interruption of a plan accepted with clearing the context, or `/clear`'s.
|
||||
* The fresh context starts with the first.
|
||||
*/
|
||||
holdsResult(message: SDKResultMessage, end: TurnEnd): boolean {
|
||||
const continuation = this.continuation;
|
||||
if (continuation) {
|
||||
if (continuation.planStarted) return false;
|
||||
continuation.carried.push(end);
|
||||
// Without a `conversation_reset` (an older Claude Code), `/clear`'s result shows it ran.
|
||||
this.mounted();
|
||||
return true;
|
||||
}
|
||||
const interruption = this.interruption;
|
||||
if (!interruption || !isExitPlanInterruptionResult(message, interruption)) return false;
|
||||
this.interruption = undefined;
|
||||
const reset = this.accepted;
|
||||
this.accepted = undefined;
|
||||
// "No, keep planning": the turn ends.
|
||||
if (!reset || reset.toolUseId !== interruption.toolUseId) return false;
|
||||
const clearUuid = this.newUuid();
|
||||
this.continuation = {
|
||||
reset,
|
||||
clearUuid,
|
||||
planUuid: this.newUuid(),
|
||||
carried: [end],
|
||||
planSent: false,
|
||||
planStarted: false,
|
||||
};
|
||||
this.host.push("/clear", clearUuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Claude Code mounted a fresh conversation: the plan goes out in it. */
|
||||
conversationReset(): void {
|
||||
this.mounted();
|
||||
}
|
||||
|
||||
/** Whether the open turn waits for the plan's prompt to start. */
|
||||
holdsTurn(): boolean {
|
||||
return this.continuation !== undefined && !this.continuation.planStarted;
|
||||
}
|
||||
|
||||
/** Claude Code took in the prompt `uuid`. Returns whether it is the plan's prompt. */
|
||||
promptStarted(uuid: string): boolean {
|
||||
if (this.continuation?.planUuid !== uuid) return false;
|
||||
this.continuation.planStarted = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Whether `uuid` is one of the adapter's prompts for the fresh context: the client gets no `user_message`. */
|
||||
ownsPrompt(uuid: string): boolean {
|
||||
return this.continuation?.clearUuid === uuid || this.continuation?.planUuid === uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* The end of the turn that `end`'s result ends: with the usage of the
|
||||
* results before it in the turn, the old context's and `/clear`'s.
|
||||
*/
|
||||
turnEnd(end: TurnEnd): TurnEnd {
|
||||
const carried = this.continuation?.carried;
|
||||
this.continuation = undefined;
|
||||
return carried && carried.length > 0 ? combinedEnd([...carried, end]) : end;
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn ends otherwise (a cancel, a close): the fresh context goes on
|
||||
* without the plan. Returns the uuids of the adapter's prompts that Claude
|
||||
* Code may still run, whose work the client did not see.
|
||||
*/
|
||||
stop(): string[] {
|
||||
this.interruption = undefined;
|
||||
this.accepted = undefined;
|
||||
const continuation = this.continuation;
|
||||
this.continuation = undefined;
|
||||
if (!continuation || continuation.planStarted) return [];
|
||||
return continuation.planSent
|
||||
? [continuation.clearUuid, continuation.planUuid]
|
||||
: [continuation.clearUuid];
|
||||
}
|
||||
|
||||
/** The fresh context is mounted: the mode, the plan, and the plan's prompt. */
|
||||
private mounted(): void {
|
||||
const continuation = this.continuation;
|
||||
if (!continuation || continuation.planSent) return;
|
||||
continuation.planSent = true;
|
||||
const { mode, plan } = continuation.reset;
|
||||
// Nothing orders the mode before the plan's prompt but this chain: the
|
||||
// prompt goes out once Claude Code took the mode.
|
||||
void this.host
|
||||
.enterMode(mode)
|
||||
.catch((error: unknown) => this.host.logError("Failed to set the plan's mode", error))
|
||||
.then(() => {
|
||||
if (this.continuation !== continuation) return;
|
||||
this.host.publish(plan);
|
||||
this.host.push(`Implement the following plan:\n\n${plan}`, continuation.planUuid);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** The end of a turn of several results: the last result, with their usage summed. */
|
||||
function combinedEnd(ends: TurnEnd[]): TurnEnd {
|
||||
const total: AccumulatedUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
};
|
||||
const rows: ModelTokenTally = {};
|
||||
for (const { _meta } of ends) {
|
||||
add(total, _meta.quota.token_count);
|
||||
for (const row of _meta.quota.model_usage) {
|
||||
add(
|
||||
(rows[row.model] ??= {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
}),
|
||||
row.token_count,
|
||||
);
|
||||
}
|
||||
}
|
||||
const last = ends[ends.length - 1];
|
||||
const meta = quotaMeta(total, rows);
|
||||
return {
|
||||
result: last.result,
|
||||
usage: { ...total, totalTokens: meta.quota.token_count.totalTokens },
|
||||
_meta: meta,
|
||||
};
|
||||
}
|
||||
|
||||
function add(
|
||||
into: AccumulatedUsage,
|
||||
count: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cachedInputTokens: number;
|
||||
cachedWriteTokens: number;
|
||||
},
|
||||
): void {
|
||||
into.inputTokens += count.inputTokens;
|
||||
into.outputTokens += count.outputTokens;
|
||||
into.cachedReadTokens += count.cachedInputTokens;
|
||||
into.cachedWriteTokens += count.cachedWriteTokens;
|
||||
}
|
||||
85
src/v2/commands.ts
Normal file
85
src/v2/commands.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* The slash commands of a v2 session: the list that setup responses
|
||||
* and `available_commands_update` carry, and the adapter's own `/mcp`.
|
||||
*
|
||||
* The list is built by the v1 core's `getAvailableSlashCommands`, so both
|
||||
* cores advertise the same commands: Claude Code's commands without the
|
||||
* unsupported and terminal-bound ones, MCP prompts as `mcp:<server>:<name>`,
|
||||
* the adapter's `/mcp`, and the adapter's `/remote-control` when the query
|
||||
* serves Remote Control and Claude Code offers no command of that name (see
|
||||
* `remote-control.ts`).
|
||||
*
|
||||
* `/mcp` is served as on v1 (`runMcpCommand`): Claude Code runs the command,
|
||||
* and the adapter's server list replaces Claude Code's text in the turn's
|
||||
* output.
|
||||
*/
|
||||
import type { ContentBlock } from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { Query, SDKSystemMessage, SlashCommand } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { getAvailableSlashCommands } from "../acp-agent.js";
|
||||
import { parseMcpCommand, runMcpCommand, type McpCommand } from "../mcp-command.js";
|
||||
import { v2AvailableCommands } from "./session-requests.js";
|
||||
|
||||
export class SessionCommands {
|
||||
/** Claude Code's terminal-bound commands, from the turn's `system/init`. */
|
||||
private terminalCommands: string[] | undefined;
|
||||
|
||||
constructor(private readonly query: Query) {}
|
||||
|
||||
/** The session's commands, in v2 form. */
|
||||
async list(): Promise<v2.AvailableCommand[]> {
|
||||
return this.render(await this.query.supportedCommands());
|
||||
}
|
||||
|
||||
/** The `available_commands_update` of Claude Code's `commands_changed` list. */
|
||||
changed(commands: SlashCommand[]): v2.SessionUpdate {
|
||||
return { sessionUpdate: "available_commands_update", availableCommands: this.render(commands) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the terminal-bound commands of a `system/init`. Returns whether
|
||||
* they changed, so the list must go out again.
|
||||
*/
|
||||
init(message: SDKSystemMessage): boolean {
|
||||
const terminal = message.terminal_slash_commands;
|
||||
if (!terminal || JSON.stringify(terminal) === JSON.stringify(this.terminalCommands)) {
|
||||
return false;
|
||||
}
|
||||
this.terminalCommands = terminal;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Claude Code's terminal-bound commands, which it does not offer. */
|
||||
terminal(): readonly string[] | undefined {
|
||||
return this.terminalCommands;
|
||||
}
|
||||
|
||||
/** The `/mcp` command that a prompt is, if it is one. */
|
||||
localCommand(prompt: ContentBlock[]): McpCommand | undefined {
|
||||
const [first] = prompt;
|
||||
if (prompt.length !== 1 || first?.type !== "text") return undefined;
|
||||
return parseMcpCommand(first.text) ?? undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The text that replaces Claude Code's `output` of `command`, or `output`
|
||||
* when the adapter cannot build it (a status read failed, or `signal`
|
||||
* aborted).
|
||||
*/
|
||||
async replace(
|
||||
query: Parameters<typeof runMcpCommand>[0],
|
||||
command: McpCommand,
|
||||
output: string,
|
||||
signal: AbortSignal,
|
||||
logError: (message: string) => void,
|
||||
): Promise<string> {
|
||||
const { markdown } = await runMcpCommand(query, command, output, signal, logError);
|
||||
return markdown ?? output;
|
||||
}
|
||||
|
||||
private render(commands: SlashCommand[]): v2.AvailableCommand[] {
|
||||
return v2AvailableCommands(
|
||||
getAvailableSlashCommands(commands, this.terminalCommands, undefined, undefined, this.query),
|
||||
);
|
||||
}
|
||||
}
|
||||
696
src/v2/config.ts
Normal file
696
src/v2/config.ts
Normal file
@@ -0,0 +1,696 @@
|
||||
/**
|
||||
* The config options of a v2 session: its permission mode, model,
|
||||
* effort, Fast mode, ultracode and main-thread agent. Claude Code owns them
|
||||
* all. The session sets each on the query when the client picks a value, and
|
||||
* follows the changes it learns of (an approved plan's mode, a model switch
|
||||
* that the adapter did not make, the Fast mode state that Claude Code
|
||||
* reports).
|
||||
*
|
||||
* The options are built and changed by the v1 core's rules, from the shared
|
||||
* modules (`session-config-options.ts`, `session-effort.ts`,
|
||||
* `session-fast-mode.ts`, `session-ultracode.ts`, `session-model.ts`,
|
||||
* `session-mode.ts`). Fast mode and ultracode are boolean options: every v2
|
||||
* client takes them. For an AIR client that declares `recommendedValue`, the
|
||||
* model and effort options carry no `default` row and name a recommendation
|
||||
* (`_meta.jetbrains.air.recommendedValue`), and the effort they show is set
|
||||
* on the query at the start and at each model switch, as on v1.
|
||||
*
|
||||
* The mode follows `SessionModeManager`: a model whose `ModelInfo` does not
|
||||
* report `supportsAutoMode` runs `auto` as `acceptEdits`, with a notice,
|
||||
* whether the client picks `auto`, an answer to ExitPlanMode does, or a model
|
||||
* switch leaves the session in it.
|
||||
*
|
||||
* The changes of one session run one at a time, in the order they came, as
|
||||
* the v1 core's `serializeConfigurationChange` runs them: a change awaits
|
||||
* Claude Code between its steps, and a model switch rebuilds every option at
|
||||
* its end, so a change that ran meanwhile would be lost.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type SessionConfigOption,
|
||||
type SessionModeState,
|
||||
type SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type {
|
||||
AgentInfo,
|
||||
FastModeDisabledReason,
|
||||
FastModeState,
|
||||
HookCallbackMatcher,
|
||||
ModelInfo,
|
||||
PermissionMode,
|
||||
PermissionResult,
|
||||
Query,
|
||||
Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import type { SubagentModels } from "../native-subagents.js";
|
||||
import { DEFAULT_AGENT_ID } from "../session-config-ids.js";
|
||||
import {
|
||||
AGENT_CONFIG_ID,
|
||||
buildConfigOptions,
|
||||
configOptionValue,
|
||||
discoverCustomAgents,
|
||||
} from "../session-config-options.js";
|
||||
import {
|
||||
EFFORT_CONFIG_ID,
|
||||
effortFlagSettings,
|
||||
effortForModelSwitch,
|
||||
settleEffortAfterModelSwitch,
|
||||
settingsEffortForModel,
|
||||
type EffortState,
|
||||
} from "../session-effort.js";
|
||||
import {
|
||||
createFastModeConfigOption,
|
||||
FAST_MODE_CONFIG_ID,
|
||||
fastModeStateChange,
|
||||
fastModeStateEnabled,
|
||||
normalizeFastModeDisabledReason,
|
||||
} from "../session-fast-mode.js";
|
||||
import {
|
||||
MODEL_CONFIG_ID,
|
||||
modelDisplayName,
|
||||
modelEntryFor,
|
||||
resolveModelPreference,
|
||||
type SessionModelState,
|
||||
} from "../session-model.js";
|
||||
import {
|
||||
MODE_CONFIG_ID,
|
||||
SessionModeManager,
|
||||
type ModeAvailability,
|
||||
type SessionMode,
|
||||
} from "../session-mode.js";
|
||||
import { noticeUpdate } from "../session-notices.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
readUltracodeSettings,
|
||||
ultracodeSupported,
|
||||
ultracodeWorkflowsEnabled,
|
||||
} from "../session-ultracode.js";
|
||||
import { v1SetSessionConfigOptionRequest, v2ConfigOptions } from "./session-requests.js";
|
||||
|
||||
type ConfigQuery = Pick<
|
||||
Query,
|
||||
"setPermissionMode" | "setModel" | "applyFlagSettings" | "supportedAgents"
|
||||
>;
|
||||
|
||||
/** Every v2 client takes boolean config options. */
|
||||
const BOOLEAN_OPTIONS = true;
|
||||
|
||||
/** The session's options and what they are built from, as `SessionModeManager` reads and updates them. */
|
||||
type ConfigSession = SessionMode & {
|
||||
query: ConfigQuery;
|
||||
models: SessionModelState;
|
||||
/** The options as the client has them, in v1 form. */
|
||||
configOptions: SessionConfigOption[];
|
||||
};
|
||||
|
||||
/** What a session starts with besides its mode and models (see {@link SessionConfig.start}). */
|
||||
export interface ConfigSetup {
|
||||
/** The `fast_mode_state` and `fast_mode_disabled_reason` of the query's initialization. */
|
||||
fastMode: { state?: FastModeState; disabledReason?: FastModeDisabledReason };
|
||||
/** The client's `_meta.claudeCode.options`: its `effort` and `agent`. */
|
||||
client: { effort?: string; agent?: string } | undefined;
|
||||
/** The settings that effort resolves from (`mergeEffortSettings`), read at each model switch. */
|
||||
effortSettings(): Settings;
|
||||
/** The client declared AIR's `recommendedValue` (see the module doc). */
|
||||
recommendedValue: boolean;
|
||||
}
|
||||
|
||||
export class SessionConfig {
|
||||
private readonly session: ConfigSession;
|
||||
private readonly rules: SessionModeManager<ConfigSession>;
|
||||
private readonly agents: AgentInfo[];
|
||||
private currentAgent: string;
|
||||
/** Whether the client picked the agent (`session/set_config_option`), which a restart restores. */
|
||||
private agentSelected = false;
|
||||
private effort: EffortState;
|
||||
private fastModeEnabled: boolean;
|
||||
private fastModeDisabledReason: FastModeDisabledReason | undefined;
|
||||
private ultracodeEnabled: boolean;
|
||||
private readonly ultracodeWorkflowsEnabled: boolean | undefined;
|
||||
/** The end of the last change (see the module doc). */
|
||||
private changes: Promise<void> = Promise.resolve();
|
||||
|
||||
private constructor(
|
||||
private readonly sessionId: string,
|
||||
query: ConfigQuery,
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
/** What Claude Code reports of each model: its descriptions and capabilities. */
|
||||
private readonly modelInfos: ModelInfo[],
|
||||
state: {
|
||||
agents: AgentInfo[];
|
||||
currentAgent: string;
|
||||
effort: EffortState;
|
||||
fastModeEnabled: boolean;
|
||||
fastModeDisabledReason: FastModeDisabledReason | undefined;
|
||||
ultracodeEnabled: boolean;
|
||||
ultracodeWorkflowsEnabled: boolean | undefined;
|
||||
configOptions: SessionConfigOption[];
|
||||
},
|
||||
private readonly effortSettings: () => Settings,
|
||||
/** The client declared AIR's `recommendedValue` (see the module doc). */
|
||||
private readonly recommendedValue: boolean,
|
||||
/** Sends an update of the session, such as the Auto mode fallback's notice. */
|
||||
private readonly notify: (notification: SessionNotification) => void,
|
||||
private readonly logger: Logger,
|
||||
autoModeFallbackWarningPending: boolean,
|
||||
) {
|
||||
this.session = {
|
||||
query,
|
||||
modes,
|
||||
models,
|
||||
modelInfos,
|
||||
configOptions: state.configOptions,
|
||||
autoModeFallbackWarningPending,
|
||||
};
|
||||
this.agents = state.agents;
|
||||
this.currentAgent = state.currentAgent;
|
||||
this.effort = state.effort;
|
||||
this.fastModeEnabled = state.fastModeEnabled;
|
||||
this.fastModeDisabledReason = state.fastModeDisabledReason;
|
||||
this.ultracodeEnabled = state.ultracodeEnabled;
|
||||
this.ultracodeWorkflowsEnabled = state.ultracodeWorkflowsEnabled;
|
||||
this.rules = new SessionModeManager<ConfigSession>({
|
||||
getSession: () => this.session,
|
||||
sessionEndedMessage: "The session ended",
|
||||
updateConfigOption: async () => {},
|
||||
sessionUpdate: async (notification) => notify(notification),
|
||||
// Every v2 client takes `notice` updates.
|
||||
supportsNotices: () => true,
|
||||
logError: (...args) => logger.error(...args),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The config of a session that starts in `mode`, as the v1 core starts one
|
||||
* (`ClaudeAcpAgent.createSession`): a mode the model cannot serve falls
|
||||
* back, and the fallback's notice goes out with the first prompt. The
|
||||
* custom agents are discovered, Fast mode follows the reported state, and
|
||||
* ultracode follows Claude Code's `get_settings` report, or the settings
|
||||
* without one. The client's `effort` pins the effort when the model offers
|
||||
* it.
|
||||
*/
|
||||
static async start(
|
||||
sessionId: string,
|
||||
query: ConfigQuery & Query,
|
||||
mode: { mode: PermissionMode; builtInDefault: boolean } & ModeAvailability,
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
currentModelInfo: ModelInfo | undefined,
|
||||
setup: ConfigSetup,
|
||||
notify: (notification: SessionNotification) => void,
|
||||
logger: Logger,
|
||||
): Promise<SessionConfig> {
|
||||
const rules = new SessionModeManager({
|
||||
getSession: () => undefined,
|
||||
sessionEndedMessage: "",
|
||||
updateConfigOption: async () => {},
|
||||
sessionUpdate: async () => {},
|
||||
logError: (...args) => logger.error(...args),
|
||||
});
|
||||
const { modes, autoModeFallbackWarningPending } = await rules.initialize({
|
||||
query,
|
||||
requestedMode: mode.mode,
|
||||
builtInDefault: mode.builtInDefault,
|
||||
currentModelInfo,
|
||||
currentModelId: models.currentModelId,
|
||||
allowBypass: mode.allowBypass,
|
||||
allowAuto: mode.allowAuto,
|
||||
});
|
||||
const agents = await discoverCustomAgents(query);
|
||||
// Only a listed agent is selected: another would leave the option's
|
||||
// value outside its own entries.
|
||||
const requestedAgent = setup.client?.agent;
|
||||
const currentAgent =
|
||||
requestedAgent && agents.some((agent) => agent.name === requestedAgent)
|
||||
? requestedAgent
|
||||
: DEFAULT_AGENT_ID;
|
||||
const fastModeEnabled =
|
||||
setup.fastMode.state !== undefined && fastModeStateEnabled(setup.fastMode.state);
|
||||
const fastModeDisabledReason = fastModeEnabled
|
||||
? undefined
|
||||
: normalizeFastModeDisabledReason(setup.fastMode.disabledReason);
|
||||
const settings = setup.effortSettings();
|
||||
const ultracodeReport = await readUltracodeSettings(query);
|
||||
const ultracodeEnabled = ultracodeReport?.requested ?? settings.ultracode === true;
|
||||
const workflowsEnabled = ultracodeWorkflowsEnabled(ultracodeReport, currentModelInfo);
|
||||
const clientEffort = setup.client?.effort;
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
modelInfos,
|
||||
clientEffort ?? settingsEffortForModel(settings, currentModelInfo),
|
||||
agents,
|
||||
currentAgent,
|
||||
{
|
||||
supported: currentModelInfo?.supportsFastMode ?? false,
|
||||
enabled: fastModeEnabled,
|
||||
useBooleanOption: BOOLEAN_OPTIONS,
|
||||
disabledReason: fastModeDisabledReason,
|
||||
},
|
||||
{ useRecommendedValue: setup.recommendedValue },
|
||||
{
|
||||
supported:
|
||||
ultracodeReport?.available ?? ultracodeSupported(workflowsEnabled, currentModelInfo),
|
||||
enabled: ultracodeEnabled,
|
||||
useBooleanOption: BOOLEAN_OPTIONS,
|
||||
},
|
||||
);
|
||||
const shownEffort = configOptions.find(
|
||||
(option) => option.id === EFFORT_CONFIG_ID,
|
||||
)?.currentValue;
|
||||
// The client's effort is a pin when the model offers it.
|
||||
const pinned =
|
||||
clientEffort !== undefined && shownEffort === clientEffort ? clientEffort : undefined;
|
||||
// With `recommendedValue`, the shown effort is a concrete level, which
|
||||
// Claude Code's own default need not be: it is set on the query, as on
|
||||
// v1. A failure fails the session's start, as on v1.
|
||||
const applied =
|
||||
setup.recommendedValue && typeof shownEffort === "string" ? shownEffort : pinned;
|
||||
if (setup.recommendedValue && typeof shownEffort === "string") {
|
||||
await query.applyFlagSettings(effortFlagSettings(shownEffort, ultracodeEnabled));
|
||||
}
|
||||
return new SessionConfig(
|
||||
sessionId,
|
||||
query,
|
||||
modes,
|
||||
models,
|
||||
modelInfos,
|
||||
{
|
||||
agents,
|
||||
currentAgent,
|
||||
effort: { pinned, applied },
|
||||
fastModeEnabled,
|
||||
fastModeDisabledReason,
|
||||
ultracodeEnabled,
|
||||
ultracodeWorkflowsEnabled: workflowsEnabled,
|
||||
configOptions,
|
||||
},
|
||||
setup.effortSettings,
|
||||
setup.recommendedValue,
|
||||
notify,
|
||||
logger,
|
||||
autoModeFallbackWarningPending,
|
||||
);
|
||||
}
|
||||
|
||||
/** The options, in v2 form. */
|
||||
options(): v2.SessionConfigOption[] {
|
||||
return v2ConfigOptions(this.session.configOptions);
|
||||
}
|
||||
|
||||
/** The entries of the `model` option, for the `model` of a subagent's session. */
|
||||
subagentModels(): SubagentModels {
|
||||
return {
|
||||
entry: (model) => modelEntryFor(this.session.configOptions, this.modelInfos, model),
|
||||
name: (model) => modelDisplayName(this.modelInfos, model),
|
||||
};
|
||||
}
|
||||
|
||||
/** The current model, and what Claude Code reports of it. */
|
||||
currentModel(): { id: string; info: ModelInfo | undefined } {
|
||||
const id = this.session.models.currentModelId;
|
||||
return { id, info: this.modelInfos.find((model) => model.value === id) };
|
||||
}
|
||||
|
||||
availableModeIds(): string[] {
|
||||
return this.session.modes.availableModes.map((mode) => mode.id);
|
||||
}
|
||||
|
||||
/** The session's modes, for the name of a subagent's mode. */
|
||||
availableModes(): SessionModeState["availableModes"] {
|
||||
return this.session.modes.availableModes;
|
||||
}
|
||||
|
||||
/** The mode the session left when it entered plan mode, while it is in plan. */
|
||||
prePlanMode(): string | undefined {
|
||||
return this.session.prePlanMode;
|
||||
}
|
||||
|
||||
/** A prompt goes out: the notice of a fallback at the session's start goes with the first. */
|
||||
async promptSent(): Promise<void> {
|
||||
if (this.session.autoModeFallbackWarningPending) {
|
||||
await this.rules.publishFallbackWarning(this.sessionId, this.session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the value of `params` on the query, and records it once Claude Code
|
||||
* took it. The value is checked against the option as the session
|
||||
* advertised it (`configOptionValue`): a select takes an `id` of its
|
||||
* entries (or a model alias), a boolean takes a boolean.
|
||||
*/
|
||||
set(params: v2.SetSessionConfigOptionRequest): Promise<void> {
|
||||
return this.serialized(() => this.apply(params));
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the choices of `previous`, the config of the query that this one
|
||||
* replaced (see `restarts.ts`), as the v1 core's `restoreSessionChoices`
|
||||
* does: the model, then the mode, which the model decides, and its
|
||||
* pre-plan mode, then ultracode, Fast mode, the effort pin (after
|
||||
* ultracode, which its flag carries) and the agent the client picked. A
|
||||
* value the new query refuses is logged.
|
||||
*/
|
||||
restoreFrom(previous: SessionConfig): Promise<void> {
|
||||
return this.serialized(async () => {
|
||||
const restore = async (choice: string, apply: () => Promise<void>) => {
|
||||
try {
|
||||
await apply();
|
||||
} catch (error) {
|
||||
this.logger.error(`Could not restore ${choice} after the query restart:`, error);
|
||||
}
|
||||
};
|
||||
const select = (configId: string, value: string) =>
|
||||
restore(`${configId} "${value}"`, () =>
|
||||
this.apply({ sessionId: this.sessionId, configId, type: "id", value }),
|
||||
);
|
||||
const model = previous.session.models.currentModelId;
|
||||
if (this.session.models.currentModelId !== model) await select(MODEL_CONFIG_ID, model);
|
||||
// After the model: the start falls back from `auto` on a model without Auto.
|
||||
const mode = previous.session.modes.currentModeId;
|
||||
if (this.session.modes.currentModeId !== mode) {
|
||||
await select(MODE_CONFIG_ID, mode);
|
||||
if (this.session.modes.currentModeId === mode) {
|
||||
this.session.autoModeFallbackWarningPending = false;
|
||||
}
|
||||
}
|
||||
if (this.session.modes.currentModeId === "plan") {
|
||||
this.session.prePlanMode = previous.session.prePlanMode;
|
||||
}
|
||||
// The user's intent, not the option: a model without support hides it.
|
||||
if (this.ultracodeEnabled !== previous.ultracodeEnabled) {
|
||||
await restore("ultracode", () => this.setUltracode(previous.ultracodeEnabled));
|
||||
}
|
||||
if (this.fastModeEnabled !== previous.fastModeEnabled) {
|
||||
await restore("Fast mode", () => this.setFastMode(previous.fastModeEnabled));
|
||||
}
|
||||
if (previous.effort.pinned !== undefined) {
|
||||
await select(EFFORT_CONFIG_ID, previous.effort.pinned);
|
||||
}
|
||||
// Even when unchanged: Default must clear an agent that the setup's
|
||||
// `_meta` picked. Default is valid without an `agent` option too.
|
||||
if (previous.agentSelected) {
|
||||
if (previous.currentAgent === DEFAULT_AGENT_ID) {
|
||||
await restore("Default agent", () => this.selectAgent(DEFAULT_AGENT_ID));
|
||||
} else {
|
||||
await select(AGENT_CONFIG_ID, previous.currentAgent);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private serialized<T>(change: () => Promise<T>): Promise<T> {
|
||||
const result = this.changes.then(change);
|
||||
this.changes = result.then(
|
||||
() => {},
|
||||
() => {},
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
private async apply(params: v2.SetSessionConfigOptionRequest): Promise<void> {
|
||||
const request = v1SetSessionConfigOptionRequest(params);
|
||||
const option = this.session.configOptions.find((entry) => entry.id === request.configId);
|
||||
if (!option) {
|
||||
throw RequestError.invalidParams(
|
||||
{ configId: request.configId },
|
||||
`Unknown config option: ${request.configId}`,
|
||||
);
|
||||
}
|
||||
const selected = configOptionValue(option, request, this.modelInfos);
|
||||
if ("enabled" in selected) {
|
||||
if (request.configId === FAST_MODE_CONFIG_ID) await this.setFastMode(selected.enabled);
|
||||
else await this.setUltracode(selected.enabled);
|
||||
return;
|
||||
}
|
||||
const value = selected.value;
|
||||
switch (request.configId) {
|
||||
case MODE_CONFIG_ID:
|
||||
await this.applyMode(value as PermissionMode);
|
||||
return;
|
||||
case MODEL_CONFIG_ID:
|
||||
await this.session.query.setModel(value);
|
||||
await this.recordModel(value);
|
||||
return;
|
||||
case EFFORT_CONFIG_ID:
|
||||
// Applied first, so a rejected control request leaves the option as it was.
|
||||
await this.session.query.applyFlagSettings(
|
||||
effortFlagSettings(value, this.ultracodeEnabled),
|
||||
);
|
||||
this.setCurrentValue(EFFORT_CONFIG_ID, value);
|
||||
// "Default" hands effort back to the CLI's per-model settings: it un-pins.
|
||||
this.effort =
|
||||
value !== "default"
|
||||
? { pinned: value, applied: value }
|
||||
: { pinned: undefined, applied: undefined };
|
||||
return;
|
||||
case AGENT_CONFIG_ID:
|
||||
await this.selectAgent(value);
|
||||
return;
|
||||
default:
|
||||
throw RequestError.invalidParams(
|
||||
{ configId: request.configId },
|
||||
`Unknown config option: ${request.configId}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the mode `requested` on the query, and records it once Claude Code
|
||||
* took it: `auto` that the model cannot serve runs as its fallback, with a
|
||||
* notice.
|
||||
*/
|
||||
enterMode(requested: PermissionMode): Promise<void> {
|
||||
return this.serialized(() => this.applyMode(requested));
|
||||
}
|
||||
|
||||
/** The mode that `requested` runs as on the current model (see the module doc). */
|
||||
effectiveMode(requested: PermissionMode): PermissionMode {
|
||||
return this.rules.effectiveMode(this.session, requested);
|
||||
}
|
||||
|
||||
private async applyMode(requested: PermissionMode): Promise<void> {
|
||||
const mode = this.rules.effectiveMode(this.session, requested);
|
||||
await this.session.query.setPermissionMode(mode);
|
||||
this.rules.syncConfig(this.session, mode);
|
||||
if (mode !== requested) await this.rules.publishFallbackWarning(this.sessionId, this.session);
|
||||
}
|
||||
|
||||
/** Claude Code switched the mode itself. Returns whether the option changed. */
|
||||
modeChanged(mode: PermissionMode): boolean {
|
||||
if (this.session.modes.currentModeId === mode || !this.availableModeIds().includes(mode)) {
|
||||
return false;
|
||||
}
|
||||
this.rules.syncConfig(this.session, mode);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code switched the model without the adapter: a typed `/model`, or
|
||||
* a refusal fallback. Returns whether the options changed. A model that the
|
||||
* option does not list is recorded by its id, which the option then lists.
|
||||
*/
|
||||
modelSwitched(model: string): Promise<boolean> {
|
||||
return this.serialized(async () => {
|
||||
const value = resolveModelPreference(this.modelInfos, model)?.value ?? model;
|
||||
if (this.session.models.currentModelId === value) return false;
|
||||
await this.recordModel(value);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code reported a Fast mode state (`system`/init, a result). Returns
|
||||
* whether the option changed. Without a Fast mode option the model does not
|
||||
* support it, and the state says nothing of the user's intent, which stays
|
||||
* for a model that does (see `fastModeStateChange`).
|
||||
*/
|
||||
fastModeReported(
|
||||
state: FastModeState | undefined,
|
||||
reason: FastModeDisabledReason | undefined,
|
||||
): boolean {
|
||||
if (!this.session.configOptions.some((option) => option.id === FAST_MODE_CONFIG_ID)) {
|
||||
return false;
|
||||
}
|
||||
const change = fastModeStateChange(
|
||||
{ enabled: this.fastModeEnabled, disabledReason: this.fastModeDisabledReason },
|
||||
state,
|
||||
reason,
|
||||
);
|
||||
if (!change) return false;
|
||||
this.fastModeEnabled = change.enabled;
|
||||
this.fastModeDisabledReason = change.disabledReason;
|
||||
this.refreshFastModeOption();
|
||||
if (change.notice) {
|
||||
this.notify({ sessionId: this.sessionId, update: noticeUpdate(change.notice.notice) });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* An allowed permission answer, with an `auto` mode that the model cannot
|
||||
* serve set as its fallback (see the module doc).
|
||||
*/
|
||||
async permissionAllowed(result: PermissionResult): Promise<PermissionResult> {
|
||||
const { permissionResult, fallbackApplied } = this.rules.applyPermissionFallback(
|
||||
this.session,
|
||||
result,
|
||||
);
|
||||
if (fallbackApplied) await this.rules.publishFallbackWarning(this.sessionId, this.session);
|
||||
return permissionResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* `null` clears the flag layer back to the standard Claude Code agent. The
|
||||
* change takes effect on the next turn. Applied first, so a rejected
|
||||
* control request leaves the option as it was.
|
||||
*/
|
||||
private async selectAgent(value: string): Promise<void> {
|
||||
await this.session.query.applyFlagSettings({
|
||||
agent: value === DEFAULT_AGENT_ID ? null : value,
|
||||
});
|
||||
this.currentAgent = value;
|
||||
this.agentSelected = true;
|
||||
this.setCurrentValue(AGENT_CONFIG_ID, value);
|
||||
}
|
||||
|
||||
/** Applied first, so a rejected control request leaves the option as it was. */
|
||||
private async setFastMode(enabled: boolean): Promise<void> {
|
||||
await this.session.query.applyFlagSettings({ fastMode: enabled });
|
||||
this.fastModeEnabled = enabled;
|
||||
this.refreshFastModeOption();
|
||||
}
|
||||
|
||||
/**
|
||||
* An explicit boolean leaves the effort level alone (`null` would reset the
|
||||
* flag layer). Applied first, so a rejected control request leaves the
|
||||
* option as it was.
|
||||
*/
|
||||
private async setUltracode(enabled: boolean): Promise<void> {
|
||||
await this.session.query.applyFlagSettings({ ultracode: enabled });
|
||||
this.ultracodeEnabled = enabled;
|
||||
this.replaceOption(createUltracodeConfigOption(enabled, BOOLEAN_OPTIONS));
|
||||
}
|
||||
|
||||
private refreshFastModeOption(): void {
|
||||
this.replaceOption(
|
||||
createFastModeConfigOption(
|
||||
this.fastModeEnabled,
|
||||
BOOLEAN_OPTIONS,
|
||||
this.fastModeDisabledReason,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private replaceOption(option: SessionConfigOption): void {
|
||||
this.session.configOptions = this.session.configOptions.map((entry) =>
|
||||
entry.id === option.id ? option : entry,
|
||||
);
|
||||
}
|
||||
|
||||
private setCurrentValue(configId: string, value: string): void {
|
||||
this.session.configOptions = this.session.configOptions.map((entry) =>
|
||||
entry.id === configId && typeof entry.currentValue === "string"
|
||||
? { ...entry, currentValue: value }
|
||||
: entry,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Records the model `value`, as the v1 core does (`applyConfigOptionValue`):
|
||||
* an `auto` mode it cannot serve falls back, and the options are rebuilt
|
||||
* for it. Effort follows `effortForModelSwitch`; Fast mode and ultracode
|
||||
* show only on a model that supports them, with the user's retained
|
||||
* choice.
|
||||
*/
|
||||
private async recordModel(value: string): Promise<void> {
|
||||
const info = this.modelInfos.find((model) => model.value === value);
|
||||
this.session.models = { ...this.session.models, currentModelId: value };
|
||||
const modeDowngraded = await this.rules.reconcileForModel(this.session, info);
|
||||
// `model_not_allowed` described the model the session left. The other
|
||||
// reasons are account- or environment-scoped and stay true.
|
||||
if (this.fastModeDisabledReason === "model_not_allowed") {
|
||||
this.fastModeDisabledReason = undefined;
|
||||
}
|
||||
const displayed = this.session.configOptions.find(
|
||||
(option) => option.id === EFFORT_CONFIG_ID,
|
||||
)?.currentValue;
|
||||
const effortSwitch = effortForModelSwitch({
|
||||
state: this.effort,
|
||||
displayed: typeof displayed === "string" ? displayed : undefined,
|
||||
newModelInfo: info,
|
||||
modelId: value,
|
||||
settings: this.effortSettings(),
|
||||
useRecommendedValue: this.recommendedValue,
|
||||
});
|
||||
const options = buildConfigOptions(
|
||||
this.session.modes,
|
||||
this.session.models,
|
||||
this.modelInfos,
|
||||
effortSwitch.seed,
|
||||
this.agents,
|
||||
this.currentAgent,
|
||||
{
|
||||
supported: info?.supportsFastMode ?? false,
|
||||
enabled: this.fastModeEnabled,
|
||||
useBooleanOption: BOOLEAN_OPTIONS,
|
||||
disabledReason: this.fastModeDisabledReason,
|
||||
},
|
||||
{ useRecommendedValue: this.recommendedValue },
|
||||
{
|
||||
supported: ultracodeSupported(this.ultracodeWorkflowsEnabled, info),
|
||||
enabled: this.ultracodeEnabled,
|
||||
useBooleanOption: BOOLEAN_OPTIONS,
|
||||
},
|
||||
);
|
||||
const effort = await settleEffortAfterModelSwitch(
|
||||
effortSwitch,
|
||||
options.find((option) => option.id === EFFORT_CONFIG_ID),
|
||||
this.ultracodeEnabled,
|
||||
this.recommendedValue,
|
||||
(settings) => this.session.query.applyFlagSettings(settings),
|
||||
);
|
||||
this.effort = effort.state;
|
||||
this.session.configOptions = options.flatMap((option) =>
|
||||
option.id !== EFFORT_CONFIG_ID ? [option] : effort.option ? [effort.option] : [],
|
||||
);
|
||||
// The model switch already happened: a failed effort sync only logs.
|
||||
if ("error" in effort) {
|
||||
this.logger.error(
|
||||
`Session ${this.sessionId}: failed to synchronize effort after the switch to ${value}`,
|
||||
effort.error,
|
||||
);
|
||||
}
|
||||
if (modeDowngraded) await this.rules.publishFallbackWarning(this.sessionId, this.session);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The PostModelSwitch hook: a switch of the root's model that the adapter
|
||||
* did not make (`source` other than `sdk`, its own `setModel`, and `resume`,
|
||||
* which the setup read already) updates the `model` option. A subagent's
|
||||
* switch (`agent_id`) is not the session's.
|
||||
*/
|
||||
export function modelSwitchHook(switched: (model: string) => void): HookCallbackMatcher {
|
||||
return {
|
||||
hooks: [
|
||||
async (input) => {
|
||||
if (
|
||||
input.hook_event_name === "PostModelSwitch" &&
|
||||
!input.agent_id &&
|
||||
input.source !== "sdk" &&
|
||||
input.source !== "resume"
|
||||
) {
|
||||
// After the hook's answer: Claude Code waits for it, and the
|
||||
// session's control requests share its channel.
|
||||
const model = input.to_model;
|
||||
setImmediate(() => switched(model));
|
||||
}
|
||||
return { continue: true };
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
232
src/v2/elicitation.ts
Normal file
232
src/v2/elicitation.ts
Normal file
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Claude Code's requests for user input that are not permission checks, as
|
||||
* ACP v2 elicitations of one session:
|
||||
*
|
||||
* - AskUserQuestion: a form, on the session of the agent that asks (the root
|
||||
* or a subagent's child session), with the question's `toolCallId`.
|
||||
* - An MCP server's elicitation (`onElicitation`): a form or a URL. A mode
|
||||
* that the client did not advertise is answered `cancel`.
|
||||
* - The refusal fallback's consent prompt (`onUserDialog`): a form.
|
||||
* - MCP OAuth: a URL, sent after the setup response (see `mcp-oauth.ts`).
|
||||
*
|
||||
* The first three are open requests of their session while they wait
|
||||
* (`requires_action`), and a cancel or close withdraws them with
|
||||
* `$/cancel_request`, as it does a permission request. An MCP server's URL
|
||||
* request that the user accepts stays open until Claude Code reports it
|
||||
* complete: `accept` only consents to open the URL, and the work waits for
|
||||
* the user to finish there. Building the elicitations and reading the
|
||||
* answers is shared with v1 (`elicitation.ts`).
|
||||
*/
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { CanUseTool, Options, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import {
|
||||
answerAskUserQuestion,
|
||||
answerMcpElicitation,
|
||||
answerUserDialog,
|
||||
elicitUnlessWithdrawn,
|
||||
REFUSAL_FALLBACK_DIALOG_KIND,
|
||||
type Elicit,
|
||||
type ElicitationSupport,
|
||||
} from "../elicitation.js";
|
||||
import { startMcpServerAuthentication } from "../mcp-oauth.js";
|
||||
import type { RequestTarget } from "./tasks.js";
|
||||
|
||||
/** What the elicitations need of the connection and the session. */
|
||||
export interface ElicitationHost {
|
||||
readonly sessionId: string;
|
||||
readonly logger: Logger;
|
||||
/** The modes that the client advertised in `initialize`. */
|
||||
readonly support: ElicitationSupport;
|
||||
/** The client is JetBrains AIR: AskUserQuestion's "Other" fields carry AIR's custom answer marker. */
|
||||
readonly airClient: boolean;
|
||||
createElicitation(
|
||||
request: CreateElicitationRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
/** The session of the agent `agentId` (see `V2Session.target`). */
|
||||
target(agentId: string | undefined): Promise<RequestTarget>;
|
||||
/** The session's query, once it started. */
|
||||
query(): Query | undefined;
|
||||
}
|
||||
|
||||
export class SessionElicitations {
|
||||
/** Whether Claude Code takes `annotations[question].unanswered` (`ask_user_question_unanswered_v1`). */
|
||||
private reportUnanswered = false;
|
||||
/**
|
||||
* Aborted by `session/close`: stops the MCP OAuth runs. A query that ended
|
||||
* on its own fails their next `mcpServerStatus`, which ends them too.
|
||||
*/
|
||||
private readonly closed = new AbortController();
|
||||
/** The accepted URL requests that wait for their `elicitation_complete`, by elicitation id. */
|
||||
private readonly awaitingCompletion = new Map<string, Set<() => void>>();
|
||||
/** The elicitation ids that Claude Code reported complete in the open turn. */
|
||||
private readonly completedIds = new Set<string>();
|
||||
|
||||
constructor(private readonly host: ElicitationHost) {}
|
||||
|
||||
/**
|
||||
* The query options: the elicitation callbacks for the modes the client
|
||||
* renders. Without form elicitation AskUserQuestion is disallowed, and the
|
||||
* refusal fallback is not declared, so Claude Code ends the turn with the
|
||||
* refusal instead.
|
||||
*/
|
||||
queryOptions(): Pick<
|
||||
Options,
|
||||
"onElicitation" | "onUserDialog" | "supportedDialogKinds" | "disallowedTools"
|
||||
> {
|
||||
const { support, sessionId, logger } = this.host;
|
||||
return {
|
||||
...(support.form || support.url
|
||||
? {
|
||||
onElicitation: async (request, { signal }) =>
|
||||
answerMcpElicitation(request, {
|
||||
sessionId,
|
||||
support,
|
||||
logger,
|
||||
elicit: this.elicit(await this.host.target(undefined), signal, true),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(support.form
|
||||
? {
|
||||
onUserDialog: async (request, { signal }) =>
|
||||
answerUserDialog(request, {
|
||||
sessionId,
|
||||
logger,
|
||||
elicit: this.elicit(await this.host.target(undefined), signal),
|
||||
}),
|
||||
supportedDialogKinds: [REFUSAL_FALLBACK_DIALOG_KIND],
|
||||
}
|
||||
: { disallowedTools: ["AskUserQuestion"] }),
|
||||
};
|
||||
}
|
||||
|
||||
/** `canUseTool` that asks AskUserQuestion's questions, and leaves the other tools to `permissions`. */
|
||||
canUseTool(permissions: CanUseTool): CanUseTool {
|
||||
return async (toolName, toolInput, options) => {
|
||||
if (toolName !== "AskUserQuestion" || !this.host.support.form) {
|
||||
return permissions(toolName, toolInput, options);
|
||||
}
|
||||
const target = await this.host.target(options.agentID);
|
||||
return answerAskUserQuestion(toolInput, {
|
||||
sessionId: target.sessionId,
|
||||
toolUseID: options.toolUseID,
|
||||
airClient: this.host.airClient,
|
||||
reportUnanswered: () => this.reportUnanswered,
|
||||
logger: this.host.logger,
|
||||
elicit: this.elicit(target, options.signal),
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/** A `system/init` of the session: it lists Claude Code's capabilities. */
|
||||
init(capabilities: readonly string[] | undefined): void {
|
||||
if (capabilities?.includes("ask_user_question_unanswered_v1")) this.reportUnanswered = true;
|
||||
}
|
||||
|
||||
/** Claude Code's `elicitation_complete`: a URL elicitation of an MCP server finished. */
|
||||
completed(elicitationId: string): void {
|
||||
this.completedIds.add(elicitationId);
|
||||
for (const release of this.awaitingCompletion.get(elicitationId) ?? []) release();
|
||||
if (!this.host.support.url || this.closed.signal.aborted) return;
|
||||
this.host.completeElicitation({ elicitationId }).catch((error) => {
|
||||
this.host.logger.error(`Failed to complete elicitation: ${error}`);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts OAuth in the background for the servers of `serverNames` that
|
||||
* Claude Code reports as `needs-auth`. Call it only once the setup
|
||||
* response is sent: v2 asks for no elicitation before it.
|
||||
*/
|
||||
authenticateMcpServers(serverNames: readonly string[]): void {
|
||||
startMcpServerAuthentication(
|
||||
{
|
||||
urlElicitation: this.host.support.url,
|
||||
createElicitation: (request, signal) => this.host.createElicitation(request, signal),
|
||||
completeElicitation: (params) => this.host.completeElicitation(params),
|
||||
logger: this.host.logger,
|
||||
},
|
||||
{
|
||||
key: this,
|
||||
sessionId: this.host.sessionId,
|
||||
currentQuery: () => (this.closed.signal.aborted ? undefined : this.host.query()),
|
||||
closed: this.closed.signal,
|
||||
},
|
||||
new Set(serverNames),
|
||||
);
|
||||
}
|
||||
|
||||
/** The session closes: the OAuth runs stop, and withdraw their elicitations. */
|
||||
close(): void {
|
||||
this.closed.abort();
|
||||
}
|
||||
|
||||
/**
|
||||
* The open turn ended: its accepted URL requests no longer hold it. A
|
||||
* server that never reports its completion holds only its turn.
|
||||
*/
|
||||
turnEnded(): void {
|
||||
for (const releases of [...this.awaitingCompletion.values()]) {
|
||||
for (const release of [...releases]) release();
|
||||
}
|
||||
this.completedIds.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an elicitation as an open request of `target`. With
|
||||
* `holdAcceptedUrl`, an accepted URL request then stays open (see the
|
||||
* module doc).
|
||||
*/
|
||||
private elicit(target: RequestTarget, toolSignal: AbortSignal, holdAcceptedUrl = false): Elicit {
|
||||
return (request) =>
|
||||
target.openRequest(toolSignal, async (signal) => {
|
||||
const outcome = await elicitUnlessWithdrawn(signal, (signal) =>
|
||||
this.host.createElicitation(request, signal),
|
||||
);
|
||||
const elicitationId = (request as { elicitationId?: unknown }).elicitationId;
|
||||
if (
|
||||
holdAcceptedUrl &&
|
||||
request.mode === "url" &&
|
||||
typeof elicitationId === "string" &&
|
||||
"response" in outcome &&
|
||||
outcome.response.action === "accept"
|
||||
) {
|
||||
// Opened before this request settles, so the state stays
|
||||
// `requires_action`. Claude Code gets the answer at once: the MCP
|
||||
// server waits for the user's interaction itself.
|
||||
void target.openRequest(toolSignal, (signal) =>
|
||||
this.untilCompleted(elicitationId, signal),
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves once Claude Code reports the elicitation `elicitationId`
|
||||
* complete, `signal` aborts (the tool use ended, or a cancel or close
|
||||
* withdrew it), or the turn ends.
|
||||
*/
|
||||
private untilCompleted(elicitationId: string, signal: AbortSignal): Promise<void> {
|
||||
if (this.completedIds.has(elicitationId) || signal.aborted) return Promise.resolve();
|
||||
return new Promise((resolve) => {
|
||||
const releases = this.awaitingCompletion.get(elicitationId) ?? new Set<() => void>();
|
||||
this.awaitingCompletion.set(elicitationId, releases);
|
||||
const release = () => {
|
||||
signal.removeEventListener("abort", release);
|
||||
releases.delete(release);
|
||||
if (releases.size === 0) this.awaitingCompletion.delete(elicitationId);
|
||||
resolve();
|
||||
};
|
||||
signal.addEventListener("abort", release, { once: true });
|
||||
releases.add(release);
|
||||
});
|
||||
}
|
||||
}
|
||||
134
src/v2/file-changes.ts
Normal file
134
src/v2/file-changes.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* AIR's agent file-change report (`agentFileChangeReport`) on a v2
|
||||
* session: a `session/prompt` asks for it in `_meta.jetbrains.air`, and the
|
||||
* turn that serves the prompt ends with one `session_info_update` that lists
|
||||
* the files it changed.
|
||||
*
|
||||
* v1 sends the report before the prompt's response, which comes at the
|
||||
* turn's end. A v2 prompt is answered at its insertion, so the report goes
|
||||
* out at the end of the turn that the prompt started or joined, before the
|
||||
* turn's idle. Building the report is shared with v1 (`file-change-audit.ts`):
|
||||
* a dry-run rewind of the prompt's checkpoint, and the file-tool edits of
|
||||
* subagents, which no checkpoint holds.
|
||||
*/
|
||||
import type { Query, SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import {
|
||||
createNativeFileChangeReporter,
|
||||
recordSubagentFileEdits,
|
||||
type FileChangeReportUnavailableReason,
|
||||
type NativeFileChangeReporter,
|
||||
type NativeFileChangeReportTurn,
|
||||
} from "../file-change-audit.js";
|
||||
|
||||
type ToolUses = Record<string, { name: string; input: unknown } | undefined>;
|
||||
|
||||
export class TurnFileChanges {
|
||||
/** The reports of the prompts that are not inserted yet, by prompt uuid. */
|
||||
private readonly pending = new Map<string, NativeFileChangeReportTurn>();
|
||||
/** The reports of the prompts that the open turn serves. */
|
||||
private readonly turn: NativeFileChangeReportTurn[] = [];
|
||||
/** The tool uses of subagents in the open turn, for their file edits. */
|
||||
private subagentToolUses: ToolUses = {};
|
||||
|
||||
private constructor(private readonly reporter: NativeFileChangeReporter) {}
|
||||
|
||||
/**
|
||||
* The reports of a session, or undefined when the client did not declare
|
||||
* `agentFileChangeReport`. `publish` sends one report's `_meta`.
|
||||
*/
|
||||
static create(
|
||||
supported: boolean,
|
||||
options: Parameters<typeof createNativeFileChangeReporter>[0],
|
||||
): TurnFileChanges | undefined {
|
||||
return supported ? new TurnFileChanges(createNativeFileChangeReporter(options)) : undefined;
|
||||
}
|
||||
|
||||
/** A `session/prompt` came: its `_meta` may ask for a report. */
|
||||
requested(promptUuid: string, meta: unknown): void {
|
||||
const fileChangeReport = this.reporter.request(meta);
|
||||
if (fileChangeReport) this.pending.set(promptUuid, { promptUuid, fileChangeReport });
|
||||
}
|
||||
|
||||
/** The prompt failed before Claude Code inserted it: -32800 for a cancel, or another error. */
|
||||
promptFailed(promptUuid: string, error: unknown): void {
|
||||
const report = this.pending.get(promptUuid);
|
||||
if (!report) return;
|
||||
this.pending.delete(promptUuid);
|
||||
const cancelled =
|
||||
error instanceof RequestError && error.code === RequestError.requestCancelled().code;
|
||||
this.reporter.finish(report.fileChangeReport, cancelled ? "cancelled" : "providerError");
|
||||
}
|
||||
|
||||
/** Claude Code inserted the prompt: the open turn serves it. */
|
||||
promptStarted(promptUuid: string): void {
|
||||
const report = this.pending.get(promptUuid);
|
||||
if (!report) return;
|
||||
this.pending.delete(promptUuid);
|
||||
this.turn.push(report);
|
||||
}
|
||||
|
||||
/**
|
||||
* The open turn goes on in a fresh context (`clear-context.ts`), with the
|
||||
* plan's prompt `planUuid`. `/clear` reset Claude Code's file history, so
|
||||
* the checkpoints of the turn's own prompts are gone: its reports read the
|
||||
* plan prompt's checkpoint. v1 sends the plan with the turn's prompt uuid
|
||||
* instead (`clear-context-coordinator.ts`).
|
||||
*/
|
||||
contextCleared(planUuid: string): void {
|
||||
for (const report of this.turn) report.promptUuid = planUuid;
|
||||
}
|
||||
|
||||
/** A message of the query: a subagent's file-tool edits go into the open turn's reports. */
|
||||
message(message: SDKMessage): void {
|
||||
if (this.turn.length === 0) return;
|
||||
if (message.type !== "assistant" && message.type !== "user") return;
|
||||
const parent = message.parent_tool_use_id;
|
||||
if (parent === null) return;
|
||||
const content = message.message.content;
|
||||
if (!Array.isArray(content)) return;
|
||||
if (message.type === "assistant") {
|
||||
for (const block of content) {
|
||||
if (block.type === "tool_use") {
|
||||
this.subagentToolUses[block.id] = { name: block.name, input: block.input };
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const report of this.turn) {
|
||||
recordSubagentFileEdits(report, parent, content, this.subagentToolUses);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The open turn ends with `stopReason`. A turn that ended normally reads
|
||||
* each report from its prompt's checkpoint, which takes up to
|
||||
* `AGENT_FILE_CHANGE_REPORT_TIMEOUT_MS`: the returned promise resolves once
|
||||
* every report went out, and the turn's idle waits for it. A cancelled or
|
||||
* failed turn sends its reports `unavailable` at once.
|
||||
*/
|
||||
turnEnding(
|
||||
stopReason: string,
|
||||
query: Pick<Query, "rewindFiles"> | undefined,
|
||||
): Promise<void> | undefined {
|
||||
if (this.turn.length === 0) return undefined;
|
||||
if (stopReason === "cancelled" || stopReason === "error" || !query) {
|
||||
this.turnClosed(stopReason === "cancelled");
|
||||
return undefined;
|
||||
}
|
||||
const reports = this.turn.splice(0);
|
||||
this.subagentToolUses = {};
|
||||
return Promise.all(reports.map((report) => this.reporter.report(report, query))).then(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The open turn closed. The reports that {@link turnEnding} did not read,
|
||||
* such as of a turn that ended without a result, go out `unavailable`:
|
||||
* `cancelled` after a cancel, else `providerError`.
|
||||
*/
|
||||
turnClosed(cancelled: boolean): void {
|
||||
const reason: FileChangeReportUnavailableReason = cancelled ? "cancelled" : "providerError";
|
||||
for (const report of this.turn.splice(0)) this.reporter.finish(report.fileChangeReport, reason);
|
||||
this.subagentToolUses = {};
|
||||
}
|
||||
}
|
||||
42
src/v2/file-preview.ts
Normal file
42
src/v2/file-preview.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* The PreToolUse hook that shows an Edit's or Write's exact patch before the
|
||||
* tool runs, also when no permission request shows it.
|
||||
*
|
||||
* A v2 diff cannot hold the input's snippet, so without it an auto-approved
|
||||
* Edit would show its change only once it is done. Claude Code runs the tool
|
||||
* only after the hook returns, so the file is still as it was, and the patch
|
||||
* is the one a permission request for the tool would show. A patch whose tool
|
||||
* call is not published yet waits for it (`SessionOutput.preview`). The hook
|
||||
* makes no permission decision, and a failure only leaves the preview out.
|
||||
*/
|
||||
import type { HookCallbackMatcher } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import { previewPatchContent } from "../diff.js";
|
||||
import type { PermissionHost } from "./permissions.js";
|
||||
|
||||
/** The session of a tool use (the root's, or a subagent's), and the tool uses that a cancel drops. */
|
||||
export type PreviewHost = Pick<PermissionHost, "target" | "dropsToolUse">;
|
||||
|
||||
export function fileChangePreviewHook(host: PreviewHost, logger: Logger): HookCallbackMatcher {
|
||||
return {
|
||||
matcher: "Edit|Write",
|
||||
hooks: [
|
||||
async (input, toolUseID, { signal }) => {
|
||||
if (input.hook_event_name !== "PreToolUse" || !toolUseID) return { continue: true };
|
||||
try {
|
||||
// Work that the client was told never ran shows nothing, as in `canUseTool`.
|
||||
if (await host.dropsToolUse(toolUseID, input.agent_id, signal)) return { continue: true };
|
||||
const target = await host.target(input.agent_id);
|
||||
// The work a cancel stopped shows nothing, as in `canUseTool`.
|
||||
if (target.cancelling()) return { continue: true };
|
||||
const toolInput = (input.tool_input ?? {}) as Record<string, unknown>;
|
||||
const content = await previewPatchContent(input.tool_name, toolInput, target.cwd, "v2");
|
||||
if (content) target.output.preview(toolUseID, content);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to preview ${toolUseID}:`, error);
|
||||
}
|
||||
return { continue: true };
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -1,138 +1,21 @@
|
||||
/**
|
||||
* The ACP v2 `initialize` handshake, expressed through the v1 handshake of
|
||||
* `ClaudeAcpAgent`.
|
||||
*
|
||||
* The agent decides its capabilities and auth methods in v1 terms. The v2
|
||||
* request becomes the v1 request that the agent reads, and the v1 response of
|
||||
* the agent becomes the v2 response, so both protocol versions advertise from
|
||||
* one source.
|
||||
* What the ACP v2 `initialize` response advertises: the session capabilities,
|
||||
* and the v2 form of the auth methods, which the shared auth modules build in
|
||||
* v1 terms (`auth-methods.ts`).
|
||||
*/
|
||||
import {
|
||||
PROTOCOL_VERSION as V1_PROTOCOL_VERSION,
|
||||
type AuthMethod,
|
||||
type InitializeRequest,
|
||||
type InitializeResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { AIR_ASYNC_TASKS_CAPABILITY, AIR_GOAL_KEY } from "../air-extension.js";
|
||||
import type { AuthMethod } from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { SYSTEM_PROMPT_CAPABILITY } from "../client-system-prompt.js";
|
||||
|
||||
/** The v1 `initialize` request that a v2 client means. */
|
||||
export function v1InitializeRequest(request: v2.InitializeRequest): InitializeRequest {
|
||||
const capabilities = request.capabilities;
|
||||
return {
|
||||
protocolVersion: V1_PROTOCOL_VERSION,
|
||||
clientInfo: request.info,
|
||||
clientCapabilities: {
|
||||
// v2 has no client file system and no client terminals: the agent
|
||||
// reads, writes, and runs commands itself.
|
||||
fs: { readTextFile: false, writeTextFile: false },
|
||||
terminal: false,
|
||||
// Every v2 client handles boolean config options, notices, and
|
||||
// compaction updates; v1 asks for a marker. With them, the agent reports
|
||||
// advisories as notices rather than as transcript messages, and a
|
||||
// compaction as a compaction entity rather than as a "Compact
|
||||
// conversation" tool call, also on replay.
|
||||
session: { configOptions: { boolean: {} }, notices: {}, compaction: {} },
|
||||
// Every v2 client takes `plan_update`, with the draft's markdown and file
|
||||
// plans, and `plan_removed`; v1 asks for the `plan` marker. With it, the
|
||||
// agent shows an approved ExitPlanMode plan as a markdown plan.
|
||||
plan: {},
|
||||
// Subagent updates are part of v2's baseline session model (the subagents
|
||||
// RFD), so every v2 client takes them; v1 asks for the `subagents`
|
||||
// capability. With it, the agent reports each Agent or Task subagent as a
|
||||
// child session.
|
||||
subagents: {},
|
||||
// v1 marks terminal auth support with a boolean, v2 with an object.
|
||||
auth: {
|
||||
terminal: capabilities?.auth?.terminal != null,
|
||||
...(capabilities?.auth?._meta != null ? { _meta: capabilities.auth._meta } : {}),
|
||||
},
|
||||
...(capabilities?.elicitation != null ? { elicitation: capabilities.elicitation } : {}),
|
||||
...(capabilities?._meta != null ? { _meta: capabilities._meta } : {}),
|
||||
},
|
||||
...(request._meta != null ? { _meta: request._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The top-level `_meta` keys of the agent's v1 `initialize` response that
|
||||
* advertise an extension the v2 surface does not serve yet: `steering`, for
|
||||
* `_session/steering`.
|
||||
*/
|
||||
const UNSERVED_EXTENSION_KEYS: readonly string[] = ["steering"];
|
||||
|
||||
/**
|
||||
* The v2 `initialize` response for the v1 response of the agent.
|
||||
*
|
||||
* It does not advertise what the v2 surface does not serve yet: the
|
||||
* extensions of {@link UNSERVED_EXTENSION_KEYS}, and the AIR extensions of
|
||||
* {@link withoutUnservedAirExtensions}.
|
||||
*/
|
||||
export function v2InitializeResponse(response: InitializeResponse): v2.InitializeResponse {
|
||||
if (!response.agentInfo) {
|
||||
throw new Error("ACP v2 requires agentInfo, and the agent reported none");
|
||||
}
|
||||
const meta = response.agentCapabilities?._meta;
|
||||
const topMeta = withoutUnservedAirExtensions(
|
||||
Object.fromEntries(
|
||||
Object.entries(response._meta ?? {}).filter(
|
||||
([key]) => !UNSERVED_EXTENSION_KEYS.includes(key),
|
||||
),
|
||||
),
|
||||
);
|
||||
return {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: response.agentInfo,
|
||||
// v1 `auth.logout` has no v2 counterpart: a v2 agent that advertises auth
|
||||
// methods must serve `auth/logout`.
|
||||
capabilities: {
|
||||
session: V2_SESSION_CAPABILITIES,
|
||||
// `providers/list`, `set`, and `disable`, which the agent serves for
|
||||
// every client.
|
||||
providers: {},
|
||||
...(meta != null ? { _meta: meta } : {}),
|
||||
},
|
||||
authMethods: (response.authMethods ?? []).map(v2AuthMethod),
|
||||
...(Object.keys(topMeta).length > 0 ? { _meta: topMeta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `meta` without the AIR extensions whose methods the v2 surface does not
|
||||
* register: the `asyncTasks` capability (`_session/async_task/stop`) and the
|
||||
* `goal` entry (`_session/goal`) of `_meta.jetbrains.air`.
|
||||
*/
|
||||
function withoutUnservedAirExtensions(meta: Record<string, unknown>): Record<string, unknown> {
|
||||
const jetbrains = asRecord(meta.jetbrains);
|
||||
const air = asRecord(jetbrains.air);
|
||||
if (Object.keys(air).length === 0) return meta;
|
||||
const served = Object.fromEntries(Object.entries(air).filter(([key]) => key !== AIR_GOAL_KEY));
|
||||
if (Array.isArray(served.capabilities)) {
|
||||
served.capabilities = served.capabilities.filter(
|
||||
(capability) => capability !== AIR_ASYNC_TASKS_CAPABILITY,
|
||||
);
|
||||
}
|
||||
return { ...meta, jetbrains: { ...jetbrains, air: served } };
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* The session capabilities of the v2 surface, which serves each through a v1
|
||||
* method of the agent.
|
||||
* The session capabilities that the v2 core serves.
|
||||
*
|
||||
* - The session baseline: `session/new`, `list`, `resume` (with replay),
|
||||
* `close`, `prompt`, `cancel`, and `update`.
|
||||
* - `session/delete`, `session/fork` (which returns a session the client can
|
||||
* prompt at once), and `additionalDirectories`.
|
||||
* - The prompt content and MCP transports that the agent's v1 `initialize`
|
||||
* lists: images, embedded context, and HTTP servers. Stdio servers are in the
|
||||
* v1 baseline; v2 has no SSE transport.
|
||||
* - The prompt content and MCP transports that the v1 `initialize` lists too:
|
||||
* images, embedded context, and HTTP servers. v2 has no SSE transport.
|
||||
* - `systemPrompt`, in `append` and `override` mode.
|
||||
*/
|
||||
export const V2_SESSION_CAPABILITIES: v2.SessionCapabilities = {
|
||||
@@ -145,7 +28,7 @@ export const V2_SESSION_CAPABILITIES: v2.SessionCapabilities = {
|
||||
};
|
||||
|
||||
/** v2 names the id of a method `methodId`, requires `type`, and lists `env` as name and value pairs. */
|
||||
function v2AuthMethod(method: AuthMethod): v2.AuthMethod {
|
||||
export function v2AuthMethod(method: AuthMethod): v2.AuthMethod {
|
||||
if ("type" in method && method.type === "terminal") {
|
||||
const { id, env, ...terminal } = method;
|
||||
return {
|
||||
|
||||
219
src/v2/outcome.ts
Normal file
219
src/v2/outcome.ts
Normal file
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* How a turn of an ACP v2 session ends: the stop reason, usage, error
|
||||
* and `_meta.quota` of its `idle`. Whether Claude Code's result failed the
|
||||
* turn, and its stop reason or error text, follow the rules that the v1 core
|
||||
* reads its results with too (`resultEnd`). The error is the one v1 answers a
|
||||
* failed prompt with: an authentication failure is `authRequired`
|
||||
* (-32000), and any other carries the assistant message's error kind
|
||||
* (`data.errorKind`).
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKAssistantMessageError,
|
||||
SDKResultMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AUTONOMOUS_RESULT_ORIGINS, isSyntheticLoginMessage } from "../acp-agent.js";
|
||||
import { resultEnd } from "../result-end.js";
|
||||
import { providerFailureCategory } from "../session-failure-extension.js";
|
||||
import {
|
||||
getMatchingModelUsage,
|
||||
modelUsageIncrement,
|
||||
normalizeModelUsage,
|
||||
quotaMeta,
|
||||
resumedFirstResultModelUsage,
|
||||
tallyTotal,
|
||||
type AccumulatedUsage,
|
||||
type ModelTokenTally,
|
||||
} from "../session-usage.js";
|
||||
|
||||
/** A result that ended a turn, with what the turn spent. */
|
||||
export type TurnEnd = {
|
||||
result: SDKResultMessage;
|
||||
usage: v2.Usage;
|
||||
_meta: ReturnType<typeof quotaMeta>;
|
||||
};
|
||||
|
||||
/**
|
||||
* The idle members this core reports. Typed as those members, so the checker
|
||||
* matches each stop reason to its own, which it cannot against the whole
|
||||
* union with its custom and unknown members.
|
||||
*/
|
||||
export type IdleOutcome = Extract<
|
||||
v2.IdleStateUpdate,
|
||||
{
|
||||
stopReason: "end_turn" | "max_tokens" | "max_turn_requests" | "refusal" | "cancelled" | "error";
|
||||
}
|
||||
>;
|
||||
|
||||
/** What a prompt's turn that ended without a result spent. */
|
||||
const NOTHING_SPENT: AccumulatedUsage = {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* The stop reason, usage and `_meta.quota` of an idle, from the result that
|
||||
* ended the turn, and what its assistant messages reported (`report`).
|
||||
* `prompted`: a prompt started the turn. Without a result, such as when an
|
||||
* interrupt stopped the work before its result, a prompt's turn reports zero
|
||||
* usage and a zero quota, and a cycle that Claude Code started on its own
|
||||
* reports neither. A failed turn reports its error instead.
|
||||
*/
|
||||
export function idleOutcome(
|
||||
end: TurnEnd | undefined,
|
||||
cancelled: boolean,
|
||||
report?: TurnReport,
|
||||
prompted = false,
|
||||
): IdleOutcome {
|
||||
const spent = end
|
||||
? { usage: end.usage, _meta: end._meta }
|
||||
: prompted
|
||||
? { usage: { ...NOTHING_SPENT, totalTokens: 0 }, _meta: quotaMeta(NOTHING_SPENT, {}) }
|
||||
: {};
|
||||
if (cancelled) return { stopReason: "cancelled", ...spent };
|
||||
// Claude Code's sign-in message fails the turn.
|
||||
if (report?.loginRequired) return failed(RequestError.authRequired());
|
||||
if (!end) return { stopReason: "end_turn", ...spent };
|
||||
const { result } = end;
|
||||
const ended = resultEnd(result);
|
||||
if (!("error" in ended)) return { stopReason: ended.stopReason, ...spent };
|
||||
const kind = report?.errorKind;
|
||||
// Only these subtypes read the assistant's error kind for the failure's
|
||||
// category.
|
||||
const authFailure =
|
||||
ended.loginRequired ||
|
||||
((result.subtype === "success" || result.subtype === "error_during_execution") &&
|
||||
providerFailureCategory(kind) === "auth_required");
|
||||
return failed(
|
||||
authFailure
|
||||
? RequestError.authRequired()
|
||||
: RequestError.internalError(kind ? { errorKind: kind } : undefined, ended.error),
|
||||
);
|
||||
}
|
||||
|
||||
function failed(error: RequestError): IdleOutcome {
|
||||
return {
|
||||
stopReason: "error",
|
||||
error: {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.data !== undefined ? { data: error.data } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* What the assistant messages of the open turn reported about its end: the
|
||||
* error kind of a failed API request, Claude Code's sign-in message, and a
|
||||
* refusal's explanation. Read from the main conversation's messages.
|
||||
*/
|
||||
export class TurnReport {
|
||||
errorKind: SDKAssistantMessageError | undefined;
|
||||
/** Claude Code showed its sign-in message ("Not logged in · Please run /login"). */
|
||||
loginRequired = false;
|
||||
/** The explanation of the turn's refusal. */
|
||||
refusalExplanation: string | undefined;
|
||||
|
||||
/**
|
||||
* An assistant message of the main conversation. Returns whether it is
|
||||
* Claude Code's sign-in message, which the client is not shown: its
|
||||
* `/login` instruction is for Claude Code's terminal, and the turn's idle
|
||||
* fails with `authRequired` instead.
|
||||
*/
|
||||
assistant(message: SDKAssistantMessage): boolean {
|
||||
if (message.error) this.errorKind = message.error;
|
||||
if (message.message.stop_reason === "refusal") {
|
||||
this.refusalExplanation =
|
||||
message.message.stop_details?.explanation ?? this.refusalExplanation;
|
||||
}
|
||||
if (!isSyntheticLoginMessage(message.message)) return false;
|
||||
this.loginRequired = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code's `model_refusal_no_fallback`: the explanation when the
|
||||
* refused message carried none (older CLIs, gateways that drop
|
||||
* `stop_details`). One for a turn that no user prompt started
|
||||
* (`refused_user_message_uuid: null`) is not this turn's.
|
||||
*/
|
||||
refusalWithoutFallback(message: {
|
||||
api_refusal_explanation?: string | null;
|
||||
refused_user_message_uuid?: string | null;
|
||||
content: string;
|
||||
}): void {
|
||||
if (this.refusalExplanation || message.refused_user_message_uuid === null) return;
|
||||
this.refusalExplanation = message.api_refusal_explanation ?? message.content;
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.errorKind = undefined;
|
||||
this.loginRequired = false;
|
||||
this.refusalExplanation = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `result` is a placeholder: Claude Code answers background-task
|
||||
* notifications that queued up with one model call, and gives each but the
|
||||
* last a result with `num_turns: 0` before that call runs. The cycle goes on
|
||||
* to the last one's result.
|
||||
*/
|
||||
export function isPlaceholderResult(result: SDKResultMessage): boolean {
|
||||
return (
|
||||
result.origin != null &&
|
||||
AUTONOMOUS_RESULT_ORIGINS.has(result.origin.kind) &&
|
||||
result.num_turns === 0 &&
|
||||
!result.is_error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What each result spent. `result.modelUsage` is a running total of the
|
||||
* query, so a result's rows are its increment over the previous reading. A
|
||||
* resumed query's first reading continues the totals of its transcript, so
|
||||
* that result's row is its own `usage`, under the model of the last
|
||||
* assistant message (see `resumedFirstResultModelUsage`).
|
||||
*/
|
||||
export class ResultUsage {
|
||||
private reading: ModelTokenTally | undefined;
|
||||
private model: string | null = null;
|
||||
|
||||
constructor(resumed: boolean) {
|
||||
this.reading = resumed ? undefined : {};
|
||||
}
|
||||
|
||||
/** An assistant message of the main conversation, by `model`. */
|
||||
assistant(model: string | undefined): void {
|
||||
if (model && model !== "<synthetic>") this.model = model;
|
||||
}
|
||||
|
||||
/** What `result` spent. Every result moves the reading on, also a placeholder. */
|
||||
record(result: SDKResultMessage): TurnEnd {
|
||||
const tally: AccumulatedUsage = {
|
||||
inputTokens: result.usage.input_tokens ?? 0,
|
||||
outputTokens: result.usage.output_tokens ?? 0,
|
||||
cachedReadTokens: result.usage.cache_read_input_tokens ?? 0,
|
||||
cachedWriteTokens: result.usage.cache_creation_input_tokens ?? 0,
|
||||
};
|
||||
const reading = normalizeModelUsage(result.modelUsage);
|
||||
const key =
|
||||
this.model === null
|
||||
? null
|
||||
: (getMatchingModelUsage(result.modelUsage ?? {}, this.model)?.key ?? this.model);
|
||||
const rows =
|
||||
this.reading === undefined
|
||||
? resumedFirstResultModelUsage(result.usage, key)
|
||||
: modelUsageIncrement(reading, this.reading);
|
||||
this.reading = reading;
|
||||
return {
|
||||
result,
|
||||
usage: { ...tally, totalTokens: tallyTotal(tally) },
|
||||
_meta: quotaMeta(tally, rows),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* The v2 form of the permission requests that `ClaudeAcpAgent` sends, and the
|
||||
* v1 form of their answers.
|
||||
*/
|
||||
import type { RequestPermissionResponse } from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { AcpPermissionRequest } from "../permissions/presentation.js";
|
||||
import { v2ToolCallPatch } from "./tool-call.js";
|
||||
|
||||
/**
|
||||
* The v2 form of a permission request of the agent.
|
||||
*
|
||||
* v1 asks about a tool call, whose title the prompt shows. v2 gives the prompt
|
||||
* its own `title` and `description`, which the agent sets for a v2 client
|
||||
* (`AcpPermissionRequest`), and asks about a `subject`: here always the tool
|
||||
* call, as the same patch that a `tool_call_update` carries.
|
||||
*/
|
||||
export function v2PermissionRequest(request: AcpPermissionRequest): v2.RequestPermissionRequest {
|
||||
if (request.title === undefined) {
|
||||
throw new Error("An ACP v2 permission request needs a title");
|
||||
}
|
||||
return {
|
||||
sessionId: request.sessionId,
|
||||
title: request.title,
|
||||
...(request.description ? { description: request.description } : {}),
|
||||
subject: { type: "tool_call", toolCall: v2ToolCallPatch(request.toolCall) },
|
||||
options: request.options,
|
||||
...(request._meta != null ? { _meta: request._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v1 form of the answer to a permission request.
|
||||
*
|
||||
* v2 adds outcomes that v1 does not know, which the draft says are no
|
||||
* approval. They become `cancelled`. In v2, `cancelled` also means that the
|
||||
* user dismissed the prompt or the client declined it by policy. So the agent
|
||||
* reads a `cancelled` outcome on v2 as the user refusing the tool, unless a
|
||||
* `session/cancel` is stopping the session's work: then the tool use is
|
||||
* aborted, as on v1.
|
||||
*/
|
||||
export function v1PermissionResponse(
|
||||
response: v2.RequestPermissionResponse,
|
||||
): RequestPermissionResponse {
|
||||
const outcome = response.outcome;
|
||||
return {
|
||||
outcome:
|
||||
outcome.outcome === "selected"
|
||||
? (outcome as Extract<RequestPermissionResponse["outcome"], { outcome: "selected" }>)
|
||||
: { outcome: "cancelled" },
|
||||
...(response._meta != null ? { _meta: response._meta } : {}),
|
||||
};
|
||||
}
|
||||
275
src/v2/permissions.ts
Normal file
275
src/v2/permissions.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
/**
|
||||
* Claude Code's permission checks (`canUseTool`), asked as ACP v2 permission
|
||||
* requests.
|
||||
*
|
||||
* The request is built and its answer read by the same helpers as on v1
|
||||
* (`permissions/*`), so a v2 client gets the same options and the same
|
||||
* effects of each.
|
||||
*/
|
||||
import type { RequestPermissionResponse, ToolCallUpdate } from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { CanUseTool, PermissionMode, PermissionResult } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { previewPatchContent } from "../diff.js";
|
||||
import { normalizeDurablePermissionChangeSet } from "../permissions/normalization.js";
|
||||
import { buildClaudePermissionOptions } from "../permissions/options.js";
|
||||
import {
|
||||
airPermissionMeta,
|
||||
buildClaudePermissionPresentation,
|
||||
permissionToolCallMeta,
|
||||
type AcpPermissionRequest,
|
||||
} from "../permissions/presentation.js";
|
||||
import { decodeClaudePermissionResponse } from "../permissions/response.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import type { ClearContextReset } from "../clear-context-coordinator.js";
|
||||
import type { RequestTarget } from "./tasks.js";
|
||||
import { v2ToolCallPatch } from "./tool-call.js";
|
||||
|
||||
const V2_CAPABILITIES = ClientCapabilities.from(undefined, { v2: true });
|
||||
|
||||
/** What a permission check needs of its root session. */
|
||||
export interface PermissionHost {
|
||||
/**
|
||||
* The client is JetBrains AIR: a request carries the AIR permission
|
||||
* presentation, and its tool call the AIR `_meta.claudeCode` (see
|
||||
* `permissionToolCallMeta`).
|
||||
*/
|
||||
readonly airClient: boolean;
|
||||
/** The ids of the modes that the session offers. */
|
||||
availableModes(): string[];
|
||||
/**
|
||||
* The session that the request goes to: the root, or the child session of
|
||||
* the subagent `agentId`. It counts the request as open while it is
|
||||
* (`requires_action`).
|
||||
*/
|
||||
target(agentId: string | undefined): Promise<RequestTarget>;
|
||||
/** The user picked a mode in the answer, such as ExitPlanMode's "auto-accept edits". */
|
||||
modeChanged(mode: PermissionMode): void;
|
||||
/** The mode the session left when it entered plan mode, while it is in plan. */
|
||||
prePlanMode(): string | undefined;
|
||||
/** An allowed answer, with a mode the model cannot serve set as its fallback (see `config.ts`). */
|
||||
permissionAllowed(result: PermissionResult): Promise<PermissionResult>;
|
||||
/**
|
||||
* Whether ExitPlanMode of the agent `agentId` offers to clear the context
|
||||
* (see `ClearContext`), and how much of the context is in use.
|
||||
*/
|
||||
contextReset(agentId: string | undefined): { offered: boolean; usedPercent: number | undefined };
|
||||
/**
|
||||
* An ExitPlanMode answer that interrupts the cycle: "No, keep planning", or
|
||||
* a plan accepted with clearing the context (`reset`).
|
||||
*/
|
||||
exitPlanInterrupting(toolUseId: string, reset: ClearContextReset | undefined): void;
|
||||
/**
|
||||
* A permission request of `target` is about to show the tool call
|
||||
* `toolCallId` of `toolName` (see `SessionTasks.requestShows`).
|
||||
*/
|
||||
requestShows(target: RequestTarget, toolName: string, toolCallId: string): void;
|
||||
/** The subject's tool call, with the `_meta` that the client holds of it. */
|
||||
toolCallSubject<T extends ToolCallUpdate>(sessionId: string, toolCall: T): T;
|
||||
/**
|
||||
* Whether the tool use is of work that a cancel stopped before the client
|
||||
* saw it (see `StoppedWork`): it is denied without a request (see
|
||||
* {@link denyingStoppedWork}).
|
||||
*/
|
||||
dropsToolUse(
|
||||
toolUseId: string,
|
||||
agentId: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Denies the tool uses of work that a cancel stopped before the client saw it
|
||||
* (see `StoppedWork`), without a request, before `inner` asks the client
|
||||
* anything: a permission request, or AskUserQuestion's questions. The client
|
||||
* was told that this work never ran.
|
||||
*/
|
||||
export function denyingStoppedWork(
|
||||
host: Pick<PermissionHost, "dropsToolUse">,
|
||||
inner: CanUseTool,
|
||||
): CanUseTool {
|
||||
return async (toolName, toolInput, options) => {
|
||||
if (await host.dropsToolUse(options.toolUseID, options.agentID, options.signal)) {
|
||||
return { behavior: "deny", message: "The user cancelled this prompt." };
|
||||
}
|
||||
return inner(toolName, toolInput, options);
|
||||
};
|
||||
}
|
||||
|
||||
export function canUseTool(host: PermissionHost): CanUseTool {
|
||||
return async (
|
||||
toolName,
|
||||
toolInput,
|
||||
{
|
||||
signal,
|
||||
suggestions,
|
||||
toolUseID,
|
||||
matchedAskRule,
|
||||
blockedPath,
|
||||
decisionReason,
|
||||
title,
|
||||
displayName,
|
||||
description,
|
||||
defaultToNo,
|
||||
suppressAlwaysAllowRule,
|
||||
agentID,
|
||||
mcpServer,
|
||||
},
|
||||
) => {
|
||||
// No persistent "always allow" when the user's own ask rule forced the
|
||||
// prompt, or when Claude Code says the rule it would write grants more
|
||||
// than this ask.
|
||||
const noPersistentRule = matchedAskRule !== undefined || suppressAlwaysAllowRule === true;
|
||||
const durableChangeSet = normalizeDurablePermissionChangeSet(suggestions, noPersistentRule);
|
||||
// A v2 diff cannot hold an Edit's snippet: the request shows the exact patch.
|
||||
const previewCwd = (await host.target(agentID)).cwd;
|
||||
const previewContent = await previewPatchContent(toolName, toolInput, previewCwd, "v2");
|
||||
// After the last await: a subagent announced meanwhile gets the request
|
||||
// on its own session.
|
||||
const target = await host.target(agentID);
|
||||
const presentation = buildClaudePermissionPresentation({
|
||||
toolName,
|
||||
input: toolInput,
|
||||
toolUseID,
|
||||
cwd: target.cwd,
|
||||
capabilities: V2_CAPABILITIES,
|
||||
previewContent,
|
||||
blockedPath,
|
||||
title,
|
||||
displayName,
|
||||
description,
|
||||
decisionReason,
|
||||
defaultToNo,
|
||||
});
|
||||
// The tool call keeps the v2 rendering that the stream gave it. An AIR
|
||||
// client also gets the heading and explanation in AIR's `_meta`.
|
||||
if (host.airClient && presentation.title) {
|
||||
presentation._meta = airPermissionMeta({
|
||||
title: presentation.title,
|
||||
description: presentation.description,
|
||||
defaultToNo,
|
||||
});
|
||||
}
|
||||
const toolCallMeta = permissionToolCallMeta({
|
||||
toolName,
|
||||
parentToolUseId: target.parentToolUseId,
|
||||
mcpServer,
|
||||
airClient: host.airClient,
|
||||
});
|
||||
if (toolCallMeta) presentation.toolCall._meta = toolCallMeta;
|
||||
const contextReset = host.contextReset(agentID);
|
||||
const options = buildClaudePermissionOptions({
|
||||
toolName,
|
||||
displayName,
|
||||
input: toolInput,
|
||||
cwd: target.cwd,
|
||||
durableChangeSet,
|
||||
allowPersistentOptions: !noPersistentRule,
|
||||
defaultToNo,
|
||||
availableModes: host.availableModes(),
|
||||
prePlanMode: host.prePlanMode(),
|
||||
allowContextReset: contextReset.offered,
|
||||
contextUsedPercent: contextReset.usedPercent,
|
||||
});
|
||||
// The stream never shows SandboxNetworkAccess's tool call: the client
|
||||
// gets it before the request, and its status after the answer.
|
||||
const networkAccess = toolName === "SandboxNetworkAccess";
|
||||
if (networkAccess) target.output.permissionToolCall(toolName, toolUseID, toolInput);
|
||||
host.requestShows(target, toolName, toolUseID);
|
||||
const toolCall = host.toolCallSubject(target.sessionId, presentation.toolCall);
|
||||
const response = v1PermissionResponse(
|
||||
await target.requestPermission(
|
||||
v2PermissionRequest({ ...presentation, toolCall, options, sessionId: target.sessionId }),
|
||||
signal,
|
||||
),
|
||||
);
|
||||
if (signal.aborted) throw new Error("Tool use aborted");
|
||||
// In v2, `cancelled` is also the user dismissing the prompt. Without a
|
||||
// `session/cancel`, that is a refusal, and the turn goes on.
|
||||
if (response.outcome.outcome === "cancelled") {
|
||||
if (target.cancelling()) throw new Error("Tool use aborted");
|
||||
return {
|
||||
behavior: "deny",
|
||||
message: "User refused permission to run tool",
|
||||
toolUseID,
|
||||
decisionClassification: "user_reject",
|
||||
};
|
||||
}
|
||||
const decision = decodeClaudePermissionResponse(
|
||||
response,
|
||||
toolName,
|
||||
toolInput,
|
||||
toolUseID,
|
||||
options,
|
||||
durableChangeSet,
|
||||
);
|
||||
const decoded = decision.permissionResult;
|
||||
if (toolName === "ExitPlanMode" && decoded.behavior === "deny" && decoded.interrupt === true) {
|
||||
const plan = typeof toolInput.plan === "string" ? toolInput.plan.trim() : "";
|
||||
host.exitPlanInterrupting(
|
||||
toolUseID,
|
||||
decision.contextResetMode && plan
|
||||
? { toolUseId: toolUseID, plan, mode: decision.contextResetMode }
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
const permissionResult = await host.permissionAllowed(decoded);
|
||||
if (networkAccess) {
|
||||
target.output.toolCallStatus(
|
||||
toolUseID,
|
||||
permissionResult.behavior === "allow" ? "completed" : "failed",
|
||||
);
|
||||
}
|
||||
if (permissionResult.behavior === "allow") {
|
||||
const modeUpdate = permissionResult.updatedPermissions?.find(
|
||||
(update) => update.type === "setMode" && update.destination === "session",
|
||||
);
|
||||
if (modeUpdate?.type === "setMode") host.modeChanged(modeUpdate.mode);
|
||||
}
|
||||
return permissionResult;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v2 form of a permission request.
|
||||
*
|
||||
* v1 asks about a tool call, whose title the prompt shows. v2 gives the prompt
|
||||
* its own `title` and `description`, which the presentation sets for a v2
|
||||
* client (`AcpPermissionRequest`), and asks about a `subject`: here always the tool
|
||||
* call, as the same patch that a `tool_call_update` carries.
|
||||
*/
|
||||
export function v2PermissionRequest(request: AcpPermissionRequest): v2.RequestPermissionRequest {
|
||||
if (request.title === undefined) {
|
||||
throw new Error("An ACP v2 permission request needs a title");
|
||||
}
|
||||
return {
|
||||
sessionId: request.sessionId,
|
||||
title: request.title,
|
||||
...(request.description ? { description: request.description } : {}),
|
||||
subject: { type: "tool_call", toolCall: v2ToolCallPatch(request.toolCall) },
|
||||
options: request.options,
|
||||
...(request._meta != null ? { _meta: request._meta } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v1 form of the answer to a permission request.
|
||||
*
|
||||
* v2 adds outcomes that v1 does not know, which the draft says are no
|
||||
* approval. They become `cancelled`. In v2, `cancelled` also means that the
|
||||
* user dismissed the prompt or the client declined it by policy. So the v2
|
||||
* core reads a `cancelled` outcome as the user refusing the tool, unless a
|
||||
* `session/cancel` is stopping the session's work: then the tool use is
|
||||
* aborted, as on v1 (`canUseTool`).
|
||||
*/
|
||||
export function v1PermissionResponse(
|
||||
response: v2.RequestPermissionResponse,
|
||||
): RequestPermissionResponse {
|
||||
const outcome = response.outcome;
|
||||
return {
|
||||
outcome:
|
||||
outcome.outcome === "selected"
|
||||
? (outcome as Extract<RequestPermissionResponse["outcome"], { outcome: "selected" }>)
|
||||
: { outcome: "cancelled" },
|
||||
...(response._meta != null ? { _meta: response._meta } : {}),
|
||||
};
|
||||
}
|
||||
133
src/v2/prompt.ts
133
src/v2/prompt.ts
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* ACP v2 `session/prompt`, served through the turn events of `ClaudeAcpAgent`.
|
||||
*
|
||||
* v2 answers the prompt once Claude Code takes it in, with the id of the user
|
||||
* message it became, and reports the rest of the turn as `state_update`s.
|
||||
* The v1 form of a v2 `session/prompt` and of its content, which the shared
|
||||
* prompt conversion (`promptToClaude`) and the system prompt take.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
@@ -12,134 +10,7 @@ import {
|
||||
type Role,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { isSupportedBlobMimeType } from "../blob-resources.js";
|
||||
import type { ForegroundEvents, StopReason, TurnEvents, TurnOutcome } from "../turn-events.js";
|
||||
|
||||
/**
|
||||
* Starts a turn for `params`, and answers when Claude Code takes the prompt
|
||||
* in, with the id of the user message it became. `send` delivers a session
|
||||
* update of the prompt's session; it must not throw, and must deliver updates
|
||||
* in the order it is called, also relative to the agent's own updates.
|
||||
*
|
||||
* - Taken in: the prompt's `user_message`, then the answer. From then on, the
|
||||
* turn is the session's foreground work, which reports its states (see
|
||||
* {@link v2Foreground}).
|
||||
* - Ended or failed before it was taken in: the answer is a JSON-RPC error,
|
||||
* `-32800` for a prompt that a cancel ended in the queue.
|
||||
*/
|
||||
export function v2Prompt(
|
||||
agent: ClaudeAcpAgent,
|
||||
params: v2.PromptRequest,
|
||||
send: (update: v2.SessionUpdate) => void,
|
||||
): Promise<v2.PromptResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let inserted = false;
|
||||
const events: TurnEvents = {
|
||||
inserted(messageId) {
|
||||
inserted = true;
|
||||
send({ sessionUpdate: "user_message", messageId, content: params.prompt });
|
||||
resolve({ messageId });
|
||||
},
|
||||
ended(outcome) {
|
||||
if (inserted) return;
|
||||
reject(
|
||||
outcome.stopReason === "cancelled"
|
||||
? RequestError.requestCancelled()
|
||||
: RequestError.internalError(
|
||||
{ stopReason: outcome.stopReason },
|
||||
"The turn ended before Claude Code took the prompt in",
|
||||
),
|
||||
);
|
||||
},
|
||||
failed(error) {
|
||||
if (!inserted) reject(error);
|
||||
},
|
||||
};
|
||||
let request: PromptRequest;
|
||||
try {
|
||||
request = v1PromptRequest(params);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
agent.startTurn(request, events).catch(reject);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the foreground work of a session (see {@link ForegroundEvents}) as
|
||||
* its `state_update`s. `send` delivers a session update of that session, as
|
||||
* for {@link v2Prompt}.
|
||||
*
|
||||
* - Started: `running`. For a prompt's turn, right after its `user_message`.
|
||||
* - Waiting on a permission request or a question: `requires_action`, and
|
||||
* `running` once none is open, unless a cancel withdrew the last one: the
|
||||
* work is being stopped then, and its `idle` follows.
|
||||
* - Ended: `idle` with the stop reason, usage, and `_meta` of the work.
|
||||
* - Failed: `idle` with the `error` stop reason and the JSON-RPC error that v1
|
||||
* answers the prompt with, so a client can show its message and handle its
|
||||
* code as for an error response (for example, `-32000` starts the client's
|
||||
* sign-in).
|
||||
*
|
||||
* The work is a prompt's turn or a model cycle that Claude Code started on its
|
||||
* own, such as its follow-up after a background subagent finished. A turn's
|
||||
* work ends at its result, also when v1 holds the turn's answer for the
|
||||
* background subagents it spawned (`Turn.deferredSettle`): v2 allows
|
||||
* background updates while idle, so the subagents' output and requests arrive
|
||||
* after `idle`, and their follow-up is a cycle of its own.
|
||||
*/
|
||||
export function v2Foreground(send: (update: v2.SessionUpdate) => void): ForegroundEvents {
|
||||
return {
|
||||
running() {
|
||||
send({ sessionUpdate: "state_update", state: "running" });
|
||||
},
|
||||
awaitingUser() {
|
||||
send({ sessionUpdate: "state_update", state: "requires_action" });
|
||||
},
|
||||
ended(outcome) {
|
||||
send(idle(outcome));
|
||||
},
|
||||
failed(error) {
|
||||
send({
|
||||
sessionUpdate: "state_update",
|
||||
state: "idle",
|
||||
stopReason: "error",
|
||||
error: jsonRpcError(error),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** The JSON-RPC error that v1 answers a failed prompt with. */
|
||||
function jsonRpcError(error: unknown): { code: number; message: string; data?: unknown } {
|
||||
if (error instanceof RequestError) {
|
||||
return {
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.data !== undefined ? { data: error.data } : {}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: RequestError.internalError().code,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The `idle` of a turn that ended. Every stop reason the agent reports is one
|
||||
* of v2's own, so the state is typed as those members of `IdleStateUpdate`:
|
||||
* the type checker matches each stop reason to its member, which it cannot do
|
||||
* against the whole union with its custom and unknown members.
|
||||
*/
|
||||
function idle({ stopReason, usage, _meta }: TurnOutcome): v2.SessionUpdate {
|
||||
const state: Extract<v2.IdleStateUpdate, { stopReason: StopReason }> = {
|
||||
stopReason,
|
||||
...(usage ? { usage } : {}),
|
||||
...(_meta ? { _meta } : {}),
|
||||
};
|
||||
return { sessionUpdate: "state_update", state: "idle", ...state };
|
||||
}
|
||||
|
||||
/**
|
||||
* A v2 prompt may hold content blocks that the agent does not take: audio,
|
||||
|
||||
89
src/v2/remote-control.ts
Normal file
89
src/v2/remote-control.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* The adapter's `/remote-control` on the v2 core, and the messages that
|
||||
* a person types in another client of the session over Remote Control.
|
||||
*
|
||||
* The command, its listing, and its reply are the v1 core's
|
||||
* (`remote-control.ts`). The command
|
||||
* runs as a local command of its own turn, as v2 asks of one ("Local handling
|
||||
* still uses the foreground lifecycle"): its prompt is inserted as a
|
||||
* live-only `user_message`, the turn reports `running`, the reply goes out,
|
||||
* and the turn ends `idle` with `end_turn`. It waits until no turn is open
|
||||
* and no prompt waits in Claude Code's queue, and the prompts after it wait
|
||||
* for it: v2 asks to hold a prompt rather than reject it while Claude works.
|
||||
*/
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Query, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import {
|
||||
adapterRemoteControlCommand,
|
||||
isRemoteHumanMessage,
|
||||
parseRemoteControlCommand,
|
||||
runRemoteControlCommand,
|
||||
} from "../remote-control.js";
|
||||
|
||||
/** What the command needs of its session. */
|
||||
export interface RemoteControlHost {
|
||||
readonly logger: Logger;
|
||||
query(): Query | undefined;
|
||||
/** Claude Code's terminal-bound commands, which it does not offer. */
|
||||
terminalCommands(): readonly string[] | undefined;
|
||||
/** Resolves `true` once no turn is open and no prompt waits in Claude Code, `false` once the session closed. */
|
||||
idle(): Promise<boolean>;
|
||||
/** Inserts the prompt `uuid` and opens its turn. */
|
||||
begin(uuid: string): void;
|
||||
/** Sends the reply `text`, as agent text of the message `messageId`, and ends the turn. */
|
||||
end(text: string, messageId: string): void;
|
||||
}
|
||||
|
||||
export class SessionRemoteControl {
|
||||
/** The uuids of the prompts that this session sent, until Claude Code echoes them. */
|
||||
private readonly sent = new Set<string>();
|
||||
|
||||
constructor(private readonly host: RemoteControlHost) {}
|
||||
|
||||
/** Whether `prompt` may be the adapter's command, which {@link run} then decides. */
|
||||
mayRun(prompt: Parameters<typeof parseRemoteControlCommand>[0]): boolean {
|
||||
return parseRemoteControlCommand(prompt) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `prompt` as the adapter's command, as its turn, once the session is
|
||||
* idle. Resolves `false` without running it when Claude Code serves the
|
||||
* command itself or the query has no Remote Control: the prompt then goes
|
||||
* to Claude Code. `stillPending` tells whether a cancel or close answered
|
||||
* the prompt meanwhile.
|
||||
*/
|
||||
async run(
|
||||
prompt: Parameters<typeof parseRemoteControlCommand>[0],
|
||||
uuid: string,
|
||||
stillPending: () => boolean,
|
||||
): Promise<boolean> {
|
||||
const query = this.host.query();
|
||||
if (!query) return false;
|
||||
const command = await adapterRemoteControlCommand(prompt, query, this.host.terminalCommands());
|
||||
if (!command) return false;
|
||||
if (!(await this.host.idle()) || !stillPending()) return true;
|
||||
this.host.begin(uuid);
|
||||
// The turn is open across the network call, so the session reads as busy.
|
||||
const text = await runRemoteControlCommand(command.query, command.command, (message) =>
|
||||
this.host.logger.error(message),
|
||||
);
|
||||
this.host.end(text, randomUUID());
|
||||
return true;
|
||||
}
|
||||
|
||||
/** The session sent the prompt `uuid` to Claude Code. */
|
||||
pushed(uuid: string): void {
|
||||
this.sent.add(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* The content of a prompt echo to show as a user message: one that a
|
||||
* person typed in another client of the session. `undefined` for the
|
||||
* echo of a prompt that this session sent, and for any other echo.
|
||||
*/
|
||||
remoteMessage(message: SDKUserMessage): SDKUserMessage["message"]["content"] | undefined {
|
||||
if (message.uuid && this.sent.delete(message.uuid)) return undefined;
|
||||
return isRemoteHumanMessage(message) ? message.message.content : undefined;
|
||||
}
|
||||
}
|
||||
618
src/v2/reports.ts
Normal file
618
src/v2/reports.ts
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* The reports of a v2 session that are not Claude's output: the
|
||||
* context window and cost (`usage_update`), Claude Code's warnings and MCP startup failures (`notice`), compactions
|
||||
* (`compaction_update`), the title (`session_info_update`), the predicted
|
||||
* follow-up prompt of an AIR client (`session_info_update._meta.promptSuggestions`),
|
||||
* and the adapter's own idle compaction.
|
||||
*
|
||||
* They are built as the v1 core builds them, from the shared modules
|
||||
* (`session-usage.ts`, `session-notices.ts`, `context-compaction.ts`,
|
||||
* `session-titles.ts`, `prompt-suggestions.ts`, `idle-compaction.ts`), and go out through the
|
||||
* session's v1-to-v2 translation. The session tells it where its turns start
|
||||
* and end.
|
||||
*/
|
||||
import type { ContentBlock, PromptRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
ModelInfo,
|
||||
ModelUsage,
|
||||
Options,
|
||||
Query,
|
||||
SDKAssistantMessage,
|
||||
SDKMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKResultMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import {
|
||||
ContextCompactionLifecycle,
|
||||
contextCompactionMetadataFromBoundary,
|
||||
} from "../context-compaction.js";
|
||||
import {
|
||||
cacheResultContextWindow,
|
||||
immediateContextWindow,
|
||||
providerCacheKeyFor,
|
||||
readContextWindow,
|
||||
} from "../context-window.js";
|
||||
import { IdleCompaction } from "../idle-compaction.js";
|
||||
import { PromptSuggestions } from "../prompt-suggestions.js";
|
||||
import {
|
||||
informationalNotice,
|
||||
mcpServerStartupNotice,
|
||||
normalizeNoticeText,
|
||||
noticeUpdate,
|
||||
} from "../session-notices.js";
|
||||
import {
|
||||
DEFAULT_CONTEXT_WINDOW,
|
||||
getMatchingModelUsage,
|
||||
inferContextWindowFromModel,
|
||||
snapshotFromUsage,
|
||||
totalTokens,
|
||||
ZERO_USAGE,
|
||||
type UsageSnapshot,
|
||||
} from "../session-usage.js";
|
||||
import { SessionTitles, type TitledSession } from "../session-titles.js";
|
||||
import type { SettingsManager } from "../settings.js";
|
||||
import { createTaskHook } from "../tools.js";
|
||||
import type { SessionOutput } from "./stream.js";
|
||||
|
||||
type UsageUpdate = Extract<AcpSessionNotification["update"], { sessionUpdate: "usage_update" }>;
|
||||
|
||||
/** What the reports need of their session. */
|
||||
export interface ReportsHost {
|
||||
readonly sessionId: string;
|
||||
readonly logger: Logger;
|
||||
/** The client is JetBrains AIR, the only one that gets the predicted follow-up prompt. */
|
||||
readonly airClient: boolean;
|
||||
/** Sends v1-shaped updates of the session, in the order of the calls. */
|
||||
emit(notifications: AcpSessionNotification[]): void;
|
||||
/** For the idle compaction: whether the session is busy, and its `/compact`. */
|
||||
isBusy(): boolean;
|
||||
compact(): Promise<void>;
|
||||
/** The session as titles read it, the same object each time; undefined until it starts and once it ends. */
|
||||
titledSession(): TitledSession | undefined;
|
||||
}
|
||||
|
||||
/** What the session's query was started with (see {@link SessionReports.configure}). */
|
||||
export interface ReportsSetup {
|
||||
/** The session's live Claude Code settings, disposed with the reports. */
|
||||
settings: Pick<SettingsManager, "getSettings" | "dispose">;
|
||||
/** The query's environment. */
|
||||
env: Record<string, string | undefined>;
|
||||
/** The names of the client's MCP servers. */
|
||||
mcpServers: readonly string[];
|
||||
/**
|
||||
* Whether the session asks the user to sign in to an MCP server that needs
|
||||
* it (see `SessionElicitations.authenticateMcpServers`): the client renders
|
||||
* URL elicitations and the query can authenticate. Its startup then sends
|
||||
* no "needs authentication" notice.
|
||||
*/
|
||||
mcpOAuth: boolean;
|
||||
/** The query, for its context window (`getContextUsage`). */
|
||||
query: Pick<Query, "getContextUsage">;
|
||||
/**
|
||||
* The session's model, and its allowlisted entry: the only one whose
|
||||
* identity strings are a trustworthy window signal (see
|
||||
* `withCurrentModelInfo`).
|
||||
*/
|
||||
model: { id: string; info: ModelInfo | undefined };
|
||||
}
|
||||
|
||||
export class SessionReports {
|
||||
private readonly titles: SessionTitles;
|
||||
private readonly compaction: ContextCompactionLifecycle;
|
||||
private idleCompaction: IdleCompaction | undefined;
|
||||
private settings: ReportsSetup["settings"] | undefined;
|
||||
private clientMcpServers = new Set<string>();
|
||||
private mcpOAuth = false;
|
||||
private readonly reportedMcpServers = new Set<string>();
|
||||
/** The tool uses whose `informational` progress went out as a notice. */
|
||||
private readonly noticedToolUses = new Set<string>();
|
||||
|
||||
// The open turn's figures, reset when a turn starts.
|
||||
private lastUsage: UsageSnapshot | null = null;
|
||||
private lastTotal: number | null = null;
|
||||
private lastModel: string | null = null;
|
||||
/** A `rate_limit_event` that came before the turn had usage to report it with. */
|
||||
private pendingRateLimit: unknown = null;
|
||||
/** The normalized texts of the turn's notices (see {@link repeats}). */
|
||||
private noticeTexts: string[] = [];
|
||||
|
||||
/** The context window of the session's model (see `context-window.ts`). */
|
||||
private contextWindow = DEFAULT_CONTEXT_WINDOW;
|
||||
/** Whether {@link contextWindow} came from Claude Code, not from a guess. */
|
||||
private contextWindowAuthoritative = false;
|
||||
/** The model that {@link contextWindow} is of. */
|
||||
private modelId: string | undefined;
|
||||
/** Scopes the cross-session window cache to the query's backend. */
|
||||
private providerCacheKey = "";
|
||||
private query: ReportsSetup["query"] | undefined;
|
||||
private disposed = false;
|
||||
/** The context used after the last main-thread request. */
|
||||
private contextUsed: number | undefined;
|
||||
/** The follow-up prompt the client holds, for an AIR client only. */
|
||||
private suggestions: PromptSuggestions | undefined;
|
||||
|
||||
constructor(private readonly host: ReportsHost) {
|
||||
this.suggestions = host.airClient ? new PromptSuggestions() : undefined;
|
||||
this.titles = new SessionTitles(
|
||||
{
|
||||
client: { sessionUpdate: async (notification) => host.emit([notification]) },
|
||||
logger: host.logger,
|
||||
get sessions() {
|
||||
const session = host.titledSession();
|
||||
return session ? { [host.sessionId]: session } : {};
|
||||
},
|
||||
},
|
||||
host.sessionId,
|
||||
);
|
||||
// Every v2 client takes compaction updates.
|
||||
this.compaction = new ContextCompactionLifecycle(
|
||||
async (notification) => host.emit([notification]),
|
||||
{
|
||||
sessionId: host.sessionId,
|
||||
presentation: "compaction_update",
|
||||
logError: (message, error) => host.logger.error(message, error),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** Called once, before the query's first message. */
|
||||
configure(setup: ReportsSetup): void {
|
||||
this.clientMcpServers = new Set(setup.mcpServers);
|
||||
this.mcpOAuth = setup.mcpOAuth;
|
||||
this.settings = setup.settings;
|
||||
this.query = setup.query;
|
||||
// From the same env the query got.
|
||||
this.providerCacheKey = providerCacheKeyFor(setup.env);
|
||||
this.seedContextWindow(setup.model.id, setup.model.info);
|
||||
this.idleCompaction = new IdleCompaction(
|
||||
{
|
||||
isBusy: () => this.host.isBusy(),
|
||||
contextTokens: () => this.contextUsed,
|
||||
settings: () => setup.settings.getSettings(),
|
||||
compact: () => this.host.compact(),
|
||||
// The logger has no debug level.
|
||||
log: (message) => this.host.logger.log(message),
|
||||
},
|
||||
{ env: setup.env },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's query restarted with the same MCP servers (see
|
||||
* `restarts.ts`): a server already reported is not reported again, and the
|
||||
* prediction the client holds is still cleared at the next prompt.
|
||||
*/
|
||||
restartedFrom(previous: SessionReports): void {
|
||||
for (const server of previous.reportedMcpServers) this.reportedMcpServers.add(server);
|
||||
this.suggestions = previous.suggestions;
|
||||
}
|
||||
|
||||
/** The session's MCP servers changed, as a resume of the active session asks. */
|
||||
mcpServersChanged(names: readonly string[]): void {
|
||||
this.clientMcpServers = new Set(names);
|
||||
}
|
||||
|
||||
// --- Turns ---------------------------------------------------------------
|
||||
|
||||
turnStarted(): void {
|
||||
this.lastUsage = null;
|
||||
this.lastTotal = null;
|
||||
this.lastModel = null;
|
||||
this.noticeTexts = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The turn ends: a compaction that Claude Code left open ends `cancelled`.
|
||||
* Called before the turn's idle.
|
||||
*/
|
||||
turnEnded(): void {
|
||||
void this.compaction.reset();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the text of a result that streamed no model text was shown
|
||||
* already: as a compaction, or as one of the turn's notices (a hook-blocked
|
||||
* turn's result repeats the block reason).
|
||||
*/
|
||||
repeats(resultText: string): boolean {
|
||||
return (
|
||||
this.compaction.hasDeliveredOutput ||
|
||||
this.noticeTexts.includes(normalizeNoticeText(resultText))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code's session state went `idle`: the idle compaction may arm, and
|
||||
* the title may have landed (see {@link SessionTitles.onTurnEnd}).
|
||||
*/
|
||||
idle(): void {
|
||||
this.idleCompaction?.onIdle();
|
||||
const session = this.host.titledSession();
|
||||
if (session) {
|
||||
this.titles.onTurnEnd(session).catch((error) => {
|
||||
this.host.logger.error(`Session ${this.host.sessionId}: failed to update the title`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Titles ----------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A client prompt came: it drops the armed idle compaction, and its text is
|
||||
* for the title. The adapter's `/compact` is no client prompt.
|
||||
*/
|
||||
promptSent(prompt: PromptRequest["prompt"]): void {
|
||||
this.idleCompaction?.disarm("prompt");
|
||||
this.titles.onPrompt(prompt);
|
||||
// The prediction is spent: the user sent it or something else. A turn
|
||||
// that predicts nothing (plan mode, an API error, the usage limit) then
|
||||
// leaves no stale prediction in the composer.
|
||||
this.sendSuggestion(this.suggestions?.clear());
|
||||
}
|
||||
|
||||
/** The client prompt went to Claude Code, which may title the session from it. */
|
||||
promptQueued(prompt: PromptRequest["prompt"]): void {
|
||||
const session = this.host.titledSession();
|
||||
if (session) this.titles.onPromptQueued(session, prompt);
|
||||
}
|
||||
|
||||
/** A live agent text chunk of the root session, for the title. */
|
||||
agentText(content: ContentBlock): void {
|
||||
this.titles.onAssistantText(content);
|
||||
}
|
||||
|
||||
/** Sends the session's current title and `updatedAt`, as `session/resume` asks. */
|
||||
async publishTitle(): Promise<void> {
|
||||
const session = this.host.titledSession();
|
||||
if (!session) return;
|
||||
try {
|
||||
await this.titles.publishCurrent(session);
|
||||
} catch (error) {
|
||||
this.host.logger.error(
|
||||
`Session ${this.host.sessionId}: failed to send the session info`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drops the armed idle compaction: a prompt or a cancel came. */
|
||||
disarmIdleCompaction(reason: string): void {
|
||||
this.idleCompaction?.disarm(reason);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session's model is `id` now: the idle compaction's request describes
|
||||
* the old one, and the context window is seeded for the new one, as on v1
|
||||
* (`applyConfigOptionValue`). Nothing changes for the same model.
|
||||
*/
|
||||
modelChanged(id: string, info: ModelInfo | undefined): void {
|
||||
if (id === this.modelId) return;
|
||||
this.idleCompaction?.disarm("model change");
|
||||
this.seedContextWindow(id, info);
|
||||
}
|
||||
|
||||
/**
|
||||
* A `conversation_reset`: Claude Code mounted a fresh transcript. A
|
||||
* compaction still open ends `cancelled`, the title is evaluated again at
|
||||
* the next idle, and the idle compaction's request and TTL describe the
|
||||
* dropped conversation.
|
||||
*/
|
||||
conversationReset(): void {
|
||||
void this.compaction.reset();
|
||||
// The fresh conversation's context is empty.
|
||||
this.lastUsage = null;
|
||||
this.lastTotal = null;
|
||||
this.contextUsed = 0;
|
||||
this.titles.reset();
|
||||
this.idleCompaction?.reset();
|
||||
// The prediction came from the transcript that the reset dropped.
|
||||
this.sendSuggestion(this.suggestions?.clear());
|
||||
}
|
||||
|
||||
/** Sends the usage of an empty context, as a fresh context for an approved plan starts. */
|
||||
emptyContext(): void {
|
||||
this.host.emit([
|
||||
{
|
||||
sessionId: this.host.sessionId,
|
||||
update: { sessionUpdate: "usage_update", used: 0, size: this.contextWindow },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** The share of the context window in use, in percent, once a request reported it. */
|
||||
contextUsedPercent(): number | undefined {
|
||||
if (this.contextUsed === undefined || this.contextWindow <= 0) return undefined;
|
||||
return Math.max(0, Math.min(100, Math.round((this.contextUsed / this.contextWindow) * 100)));
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
this.idleCompaction?.dispose();
|
||||
this.settings?.dispose();
|
||||
}
|
||||
|
||||
/** The PostCompact hook's summary of the root conversation's compaction. */
|
||||
compactSummary(summary: unknown): void {
|
||||
this.compaction.recordSummary(summary);
|
||||
}
|
||||
|
||||
// --- The query's messages ----------------------------------------------
|
||||
|
||||
/** A root stream event: its usage, and the streamed summary of a compaction. */
|
||||
streamEvent(message: SDKPartialAssistantMessage): void {
|
||||
const { event } = message;
|
||||
const compactionBlock =
|
||||
event.type === "content_block_start" && event.content_block.type === "compaction"
|
||||
? event.content_block
|
||||
: event.type === "content_block_delta" && event.delta.type === "compaction_delta"
|
||||
? event.delta
|
||||
: undefined;
|
||||
// `content` is null on the opening block and on a failed compaction.
|
||||
if (compactionBlock) {
|
||||
void this.compaction.heartbeat(message.uuid, compactionBlock.content ?? undefined);
|
||||
}
|
||||
if (event.type === "message_start") {
|
||||
this.lastUsage = snapshotFromUsage(event.message.usage);
|
||||
const model = event.message.model;
|
||||
if (model && model !== "<synthetic>") {
|
||||
this.lastModel = model;
|
||||
this.idleCompaction?.noteRequest(event.message.usage, event.message.id || undefined);
|
||||
if (!this.contextWindowAuthoritative && this.contextWindow === DEFAULT_CONTEXT_WINDOW) {
|
||||
this.contextWindow = inferContextWindowFromModel(model) ?? this.contextWindow;
|
||||
}
|
||||
}
|
||||
} else if (event.type === "message_delta") {
|
||||
const usage = event.usage;
|
||||
const prev: Readonly<UsageSnapshot> = this.lastUsage ?? ZERO_USAGE;
|
||||
// The delta's counters are cumulative; the nullable ones keep the
|
||||
// previous value when the delta leaves them out.
|
||||
this.lastUsage = {
|
||||
input_tokens: usage.input_tokens ?? prev.input_tokens,
|
||||
output_tokens: usage.output_tokens,
|
||||
cache_read_input_tokens: usage.cache_read_input_tokens ?? prev.cache_read_input_tokens,
|
||||
cache_creation_input_tokens:
|
||||
usage.cache_creation_input_tokens ?? prev.cache_creation_input_tokens,
|
||||
};
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const used = totalTokens(this.lastUsage);
|
||||
if (used === this.lastTotal) return;
|
||||
this.lastTotal = used;
|
||||
this.contextUsed = used;
|
||||
this.sendUsage({ sessionUpdate: "usage_update", used, size: this.contextWindow });
|
||||
}
|
||||
|
||||
/**
|
||||
* A root assistant message: the turn's latest usage and model. A synthetic
|
||||
* message (a CLI banner with an all-zero usage) is not a model response.
|
||||
*/
|
||||
assistant(message: SDKAssistantMessage): void {
|
||||
if (message.message.model === "<synthetic>") return;
|
||||
this.lastUsage = snapshotFromUsage(message.message.usage);
|
||||
this.lastTotal = totalTokens(this.lastUsage);
|
||||
this.contextUsed = this.lastTotal;
|
||||
this.idleCompaction?.noteRequest(message.message.usage, message.message.id || undefined);
|
||||
if (message.message.model) this.lastModel = message.message.model;
|
||||
}
|
||||
|
||||
/**
|
||||
* A result: its cost, and the model's context window. The idle's quota is
|
||||
* `outcome.ts`'s. The CLI predicts the next prompt right after it.
|
||||
*/
|
||||
result(message: SDKResultMessage): void {
|
||||
this.suggestions?.resultArrived();
|
||||
const matching = this.lastModel
|
||||
? getMatchingModelUsage(message.modelUsage, this.lastModel)
|
||||
: undefined;
|
||||
const window = (matching?.usage as ModelUsage | undefined)?.contextWindow;
|
||||
if (matching && typeof window === "number" && window > 0) {
|
||||
this.contextWindow = window;
|
||||
this.contextWindowAuthoritative = true;
|
||||
cacheResultContextWindow(this.providerCacheKey, matching.key, this.lastModel, window);
|
||||
}
|
||||
if (this.lastTotal === null) return;
|
||||
this.sendUsage({
|
||||
sessionUpdate: "usage_update",
|
||||
used: this.lastTotal,
|
||||
size: this.contextWindow,
|
||||
cost: { amount: message.total_cost_usd, currency: "USD" },
|
||||
...(message.origin ? { _meta: { "_claude/origin": message.origin } } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The messages that only report: Claude Code's notices, compaction states,
|
||||
* rate limits and predicted prompts. Returns whether `message` was one.
|
||||
*/
|
||||
handle(message: SDKMessage): boolean {
|
||||
switch (message.type) {
|
||||
case "prompt_suggestion":
|
||||
// AIR shows it as gray text in an empty composer, which Tab fills.
|
||||
this.sendSuggestion(this.suggestions?.suggested(message.suggestion));
|
||||
return true;
|
||||
case "rate_limit_event":
|
||||
this.idleCompaction?.noteRateLimit(message.rate_limit_info);
|
||||
// Sent before the turn's first usage: it goes out with that usage.
|
||||
if (this.lastTotal === null) {
|
||||
this.pendingRateLimit = message.rate_limit_info;
|
||||
} else {
|
||||
this.sendUsage({
|
||||
sessionUpdate: "usage_update",
|
||||
used: this.lastTotal,
|
||||
size: this.contextWindow,
|
||||
_meta: { "_claude/rateLimit": message.rate_limit_info },
|
||||
});
|
||||
}
|
||||
return true;
|
||||
case "system":
|
||||
break;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
switch (message.subtype) {
|
||||
case "init":
|
||||
this.mcpStartupFailures(message.mcp_servers);
|
||||
// The turn goes on: the session opens it.
|
||||
return false;
|
||||
case "status":
|
||||
if (message.status === "compacting") {
|
||||
void this.compaction.start(message.uuid);
|
||||
} else if (message.compact_result === "success") {
|
||||
void this.compaction.finish(message.uuid, "completed");
|
||||
} else if (message.compact_result === "failed") {
|
||||
this.idleCompaction?.noteCompactionFailed(message.compact_error);
|
||||
void this.compaction.finish(message.uuid, "failed", {
|
||||
...(message.compact_error ? { error: message.compact_error } : {}),
|
||||
});
|
||||
}
|
||||
return true;
|
||||
case "compact_boundary": {
|
||||
const metadata = message.compact_metadata;
|
||||
void this.compaction.finish(
|
||||
message.uuid,
|
||||
"completed",
|
||||
metadata
|
||||
? contextCompactionMetadataFromBoundary(metadata, {
|
||||
idleCompaction: this.idleCompaction?.isCompacting,
|
||||
})
|
||||
: {},
|
||||
true,
|
||||
);
|
||||
// The boundary carries the context that the compaction kept. The
|
||||
// window stays: compaction frees context, it does not change the model.
|
||||
const used = metadata?.post_tokens ?? 0;
|
||||
this.lastUsage = null;
|
||||
this.lastTotal = used;
|
||||
this.contextUsed = used;
|
||||
this.sendUsage({ sessionUpdate: "usage_update", used, size: this.contextWindow });
|
||||
return true;
|
||||
}
|
||||
case "informational": {
|
||||
// 'info' shows only in Claude Code's transcript mode: not worth a notice.
|
||||
if (message.level === "info") return true;
|
||||
if (message.tool_use_id) {
|
||||
if (this.noticedToolUses.has(message.tool_use_id)) return true;
|
||||
this.noticedToolUses.add(message.tool_use_id);
|
||||
}
|
||||
this.host.emit([
|
||||
{ sessionId: this.host.sessionId, update: noticeUpdate(informationalNotice(message)) },
|
||||
]);
|
||||
this.noticeTexts.push(normalizeNoticeText(message.content));
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The window of model `id` without IPC: one learned from a result of this
|
||||
* or another session on the same backend, else a guess from the model's
|
||||
* names. A guess is then read from Claude Code in the background
|
||||
* (`readContextWindow`), until a result reports the window.
|
||||
*/
|
||||
private seedContextWindow(id: string, info: ModelInfo | undefined): void {
|
||||
this.modelId = id;
|
||||
const seeded = immediateContextWindow(this.providerCacheKey, id, info);
|
||||
this.contextWindow = seeded.size;
|
||||
this.contextWindowAuthoritative = seeded.authoritative;
|
||||
const query = this.query;
|
||||
if (seeded.authoritative || !query) return;
|
||||
const current = () => !this.disposed && this.modelId === id;
|
||||
// A synchronous throw must not fail the caller either.
|
||||
Promise.resolve()
|
||||
.then(() => readContextWindow(query))
|
||||
.then(
|
||||
(window) => {
|
||||
if (!current() || this.contextWindowAuthoritative || window === undefined) return;
|
||||
this.contextWindow = window;
|
||||
this.contextWindowAuthoritative = true;
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (current()) {
|
||||
this.host.logger.error(
|
||||
`Session ${this.host.sessionId}: failed to read the context window`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** A notice for each client MCP server that did not start, once per server. */
|
||||
private mcpStartupFailures(servers: Array<{ name: string; status: string }> | undefined): void {
|
||||
for (const server of servers ?? []) {
|
||||
if (!this.clientMcpServers.has(server.name) || this.reportedMcpServers.has(server.name)) {
|
||||
continue;
|
||||
}
|
||||
const notice = mcpServerStartupNotice(server, this.mcpOAuth);
|
||||
if (!notice) continue;
|
||||
this.reportedMcpServers.add(server.name);
|
||||
this.host.emit([{ sessionId: this.host.sessionId, update: noticeUpdate(notice) }]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sends the `_meta` of {@link PromptSuggestions}, when it has one. */
|
||||
private sendSuggestion(meta: Record<string, unknown> | undefined): void {
|
||||
if (!meta) return;
|
||||
this.host.emit([
|
||||
{
|
||||
sessionId: this.host.sessionId,
|
||||
update: { sessionUpdate: "session_info_update", _meta: meta },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a `usage_update` with the model the usage belongs to and any
|
||||
* pending rate limit in `_meta`, as ACP's usage_update has no field for them.
|
||||
*/
|
||||
private sendUsage(update: UsageUpdate): void {
|
||||
let meta: Record<string, unknown> = { ...(update._meta ?? {}) };
|
||||
if (this.pendingRateLimit !== null) {
|
||||
meta = { "_claude/rateLimit": this.pendingRateLimit, ...meta };
|
||||
this.pendingRateLimit = null;
|
||||
}
|
||||
if (this.lastModel) meta["_claude/model"] = this.lastModel;
|
||||
this.host.emit([
|
||||
{
|
||||
sessionId: this.host.sessionId,
|
||||
update: { ...update, ...(Object.keys(meta).length > 0 ? { _meta: meta } : {}) },
|
||||
},
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The hooks that feed the reports: the PostCompact summary of the root
|
||||
* conversation's compaction (a subagent's own is not the session's), and
|
||||
* the TaskCreated and TaskCompleted changes of the session's Task* plan.
|
||||
*/
|
||||
export function reportHooks(session: {
|
||||
readonly reports: SessionReports;
|
||||
readonly output: SessionOutput;
|
||||
}): NonNullable<Options["hooks"]> {
|
||||
const taskHook = createTaskHook({
|
||||
taskState: session.output.taskState,
|
||||
onChange: async () => session.output.taskPlanChanged(),
|
||||
});
|
||||
return {
|
||||
PostCompact: [
|
||||
{
|
||||
hooks: [
|
||||
async (input) => {
|
||||
if (input.hook_event_name === "PostCompact" && !input.agent_id) {
|
||||
session.reports.compactSummary(input.compact_summary);
|
||||
}
|
||||
return { continue: true };
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
TaskCreated: [{ hooks: [taskHook] }],
|
||||
TaskCompleted: [{ hooks: [taskHook] }],
|
||||
};
|
||||
}
|
||||
373
src/v2/restarts.ts
Normal file
373
src/v2/restarts.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* Restarts of a v2 session's query: a new
|
||||
* query resumes the same Claude session under the same id, and takes over
|
||||
* from the old one between turns.
|
||||
*
|
||||
* Claude Code reads the LLM route (the env of a provider or a gateway login)
|
||||
* and its account once, when its process starts. So a query must start again
|
||||
* when either changes:
|
||||
*
|
||||
* - The route: `providers/set`, `providers/disable`, and an `auth/logout`
|
||||
* that drops a provider or a gateway login. Each live session restarts at
|
||||
* its next turn boundary: no turn is open, no prompt of the session waits
|
||||
* to go to Claude Code or in its queue, and no subagent runs, background
|
||||
* ones included. A session at a turn boundary restarts right away, and the
|
||||
* request that changed the route answers once it did. A busy session's
|
||||
* restart is due at its next turn boundary, which the request does not
|
||||
* wait for: until then the session keeps its query, and its prompts go to
|
||||
* that query under the old route (the provider RFD lets an Agent leave
|
||||
* running sessions on it). That restart starts under the route in force
|
||||
* when it runs. A session whose query start read the route before the
|
||||
* change, and that became live after it, restarts the same way once its
|
||||
* setup response went out.
|
||||
* A session that cannot start again stops: its requests then fail with
|
||||
* what happened, until a resume starts it again.
|
||||
* - The additional directories: a resume of the active session with other
|
||||
* ones restarts it as for a new route, and does not wait for it. Claude
|
||||
* Code reads them once, when its process starts, and the SDK has no call
|
||||
* that changes them on a running query.
|
||||
* - The account, under `--hide-claude-auth` (see `subscription-guard.ts`):
|
||||
* the next prompt that finds the session at a turn boundary restarts it
|
||||
* first, so the guard judges the new account. If the restart fails, such
|
||||
* as when the guard refuses the account, the prompt fails with its error
|
||||
* and the session stays as it was, for a retry after a sign-in.
|
||||
*
|
||||
* The new query starts before the old one ends, so a failed start leaves the
|
||||
* session as it was. The old one ends at its next turn boundary after that,
|
||||
* so work that started on it during the start, such as a turn that Claude
|
||||
* Code started on its own, is not cut. Its background commands end with it
|
||||
* (their terminals exit), as when a query ends. The new query starts without
|
||||
* the effort of the setup's `_meta`, and the client's choices are set again
|
||||
* on it (`SessionConfig.restoreFrom`), which then reports its options in a
|
||||
* `config_option_update`. Ending the old query withdraws its MCP OAuth
|
||||
* runs, and the new one starts OAuth for the setup's MCP servers that still
|
||||
* need it, as a setup does. One restart of a session runs at
|
||||
* a time: a route restart waits for an account restart that runs, as for a
|
||||
* busy session.
|
||||
*
|
||||
* Prompts wait only for a restart that runs, from the new query's start to
|
||||
* the old one's end, and go to the new query. A `session/cancel` or
|
||||
* `session/close` answers them with -32800, and stops the work that runs on
|
||||
* the old query while the restart waits for it.
|
||||
*/
|
||||
import { RequestError } from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import { withoutEffort } from "../session-effort.js";
|
||||
import type { V2Session } from "./session.js";
|
||||
import { afterSetupResponse } from "./setup.js";
|
||||
|
||||
/** The request that set the session up, whose options a restart starts with again. */
|
||||
export type SetupParams = v2.NewSessionRequest | v2.ResumeSessionRequest | v2.ForkSessionRequest;
|
||||
|
||||
/** What the restarts need of the agent. */
|
||||
export interface RestartHost {
|
||||
readonly logger: Logger;
|
||||
/** The active session `sessionId`. */
|
||||
active(sessionId: string): V2Session | undefined;
|
||||
/**
|
||||
* Starts a new query that resumes `sessionId`, with the options of `params`
|
||||
* and the current route. The new session is active once it resolves.
|
||||
*/
|
||||
start(sessionId: string, params: SetupParams): Promise<V2Session>;
|
||||
/** `previous` could not start again: the session stops. */
|
||||
lost(sessionId: string, previous: V2Session): Promise<void>;
|
||||
send(notification: v2.UpdateSessionNotification): void;
|
||||
}
|
||||
|
||||
/** A route restart of a busy session: waiting for its turn boundary, then running. */
|
||||
type Reroute = { done: Promise<void>; started: boolean };
|
||||
|
||||
export class SessionRestarts {
|
||||
/** The setup request of each live session. */
|
||||
private readonly setups = new Map<string, SetupParams>();
|
||||
/** The sessions whose account may have changed: their next prompt at a turn boundary restarts them. */
|
||||
private readonly accountChanged = new Set<string>();
|
||||
/** The route restarts that wait for the session's turn boundary. Prompts do not wait for them. */
|
||||
private readonly reroutes = new Map<string, Reroute>();
|
||||
/** The restarts that run: from the new query's start to the old one's end. */
|
||||
private readonly swaps = new Map<string, Promise<void>>();
|
||||
/** The session that each running restart replaces. */
|
||||
private readonly retiring = new Map<string, V2Session>();
|
||||
/** The sessions that `session/close` closes: a restart that runs does not track them again. */
|
||||
private readonly closingSessions = new Set<string>();
|
||||
/**
|
||||
* The sessions whose start read the route before a change that
|
||||
* {@link reroute} did not find them live for: they restart once their
|
||||
* setup answered ({@link setupAnswered}).
|
||||
*/
|
||||
private readonly staleRoutes = new Set<string>();
|
||||
/** The prompts that wait for a restart, each with what rejects it. */
|
||||
private readonly held = new Map<string, Set<(error: unknown) => void>>();
|
||||
|
||||
constructor(private readonly host: RestartHost) {}
|
||||
|
||||
/**
|
||||
* The session started from `params`, or a resume of the active session
|
||||
* brought new ones. `staleRoute`: the start read the route before a route
|
||||
* change came.
|
||||
*/
|
||||
track(sessionId: string, params: SetupParams, { staleRoute = false } = {}): void {
|
||||
if (this.closingSessions.has(sessionId)) return;
|
||||
this.setups.set(sessionId, params);
|
||||
// A restart's start: the change found the session it replaces live, and
|
||||
// its reroute runs again after this restart.
|
||||
if (staleRoute && !this.swaps.has(sessionId)) this.staleRoutes.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The setup response of `sessionId` goes out: a route restart that its
|
||||
* start missed runs from then, as for a live session (see {@link reroute}).
|
||||
* Not before: the restart's updates must not precede the response.
|
||||
*/
|
||||
setupAnswered(sessionId: string): void {
|
||||
if (!this.staleRoutes.delete(sessionId)) return;
|
||||
// The handler returns the response right after this call, and the SDK
|
||||
// writes it before a timer runs. Nothing else orders the two.
|
||||
setTimeout(() => {
|
||||
if (this.setups.has(sessionId)) void this.rerouteSession(sessionId);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* A resume of the active session brought other additional directories:
|
||||
* the session restarts with `params`, as for a new route (see the module
|
||||
* doc). Does not wait for it.
|
||||
*/
|
||||
rootsChanged(sessionId: string, params: SetupParams): void {
|
||||
this.track(sessionId, params);
|
||||
void this.rerouteSession(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The session is no longer active, such as when its query ended. A route
|
||||
* restart that is due does not run.
|
||||
*/
|
||||
forget(sessionId: string): void {
|
||||
this.setups.delete(sessionId);
|
||||
this.accountChanged.delete(sessionId);
|
||||
this.staleRoutes.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* The route changed: restarts every live session at its next turn
|
||||
* boundary. Resolves once each session that is at one now restarted, or
|
||||
* was closed or lost. Their restarts start in this call, so a prompt that
|
||||
* comes after it waits for them (`beforePrompt`). The busy sessions'
|
||||
* restarts are only due: a failure of one stops its session later
|
||||
* (`RestartHost.lost`).
|
||||
*/
|
||||
async reroute(): Promise<void> {
|
||||
await Promise.all([...this.setups.keys()].map((sessionId) => this.rerouteSession(sessionId)));
|
||||
}
|
||||
|
||||
/** The session's account may have changed: its next prompt at a turn boundary restarts it. */
|
||||
accountMayHaveChanged(sessionId: string): void {
|
||||
if (this.setups.has(sessionId)) this.accountChanged.add(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* What a prompt of `sessionId` waits for before it goes to the session: a
|
||||
* restart that runs, or an account restart that is due. `undefined` when
|
||||
* none is: the prompt goes on in the same call, so prompts keep their
|
||||
* order. A route restart that waits for a turn boundary does not hold it:
|
||||
* the prompt goes to the current query.
|
||||
*/
|
||||
beforePrompt(sessionId: string): Promise<void> | undefined {
|
||||
const running = this.swaps.get(sessionId);
|
||||
if (running) return this.hold(sessionId, running);
|
||||
const session = this.host.active(sessionId);
|
||||
if (this.accountChanged.has(sessionId) && session?.atTurnBoundary(true)) {
|
||||
return this.hold(sessionId, this.swap(sessionId, session, { keepOnFailure: true }));
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a restart runs: its new query starts MCP OAuth once it took
|
||||
* over, so the setup response of the session it replaces need not.
|
||||
*/
|
||||
restarting(sessionId: string): boolean {
|
||||
return this.swaps.has(sessionId);
|
||||
}
|
||||
|
||||
/** What a request other than a prompt waits for: a restart that runs. */
|
||||
settled(sessionId: string): Promise<void> | undefined {
|
||||
return this.swaps.get(sessionId)?.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/cancel`: the prompts that wait for a restart fail with -32800,
|
||||
* and work that runs on the query that a restart replaces stops too (the
|
||||
* restart waits for it).
|
||||
*/
|
||||
cancelled(sessionId: string): void {
|
||||
this.release(sessionId, RequestError.requestCancelled());
|
||||
const retiring = this.retiring.get(sessionId);
|
||||
// Until the new query started, the old one is the active session, which the cancel stops.
|
||||
if (retiring && retiring !== this.host.active(sessionId)) void retiring.cancel();
|
||||
}
|
||||
|
||||
/**
|
||||
* `session/close`: the prompts that wait fail with -32800, and a route
|
||||
* restart that waits for a turn boundary is dropped (the closed session
|
||||
* reaches none). Resolves once a restart that runs is done, so the close
|
||||
* ends the session that it started.
|
||||
*/
|
||||
async closing(sessionId: string): Promise<void> {
|
||||
this.closingSessions.add(sessionId);
|
||||
this.forget(sessionId);
|
||||
this.release(sessionId, RequestError.requestCancelled());
|
||||
// The close stops the old query's work too, which the restart waits for.
|
||||
await this.retiring.get(sessionId)?.close();
|
||||
await this.swaps.get(sessionId)?.catch(() => {});
|
||||
}
|
||||
|
||||
/** `session/close` closed the session. */
|
||||
closed(sessionId: string): void {
|
||||
this.closingSessions.delete(sessionId);
|
||||
this.forget(sessionId);
|
||||
}
|
||||
|
||||
/** Waits for the restarts that run, so no new query starts after the agent's sessions closed. */
|
||||
async dispose(): Promise<void> {
|
||||
for (const sessionId of [...this.setups.keys(), ...this.retiring.keys()]) {
|
||||
this.closingSessions.add(sessionId);
|
||||
this.forget(sessionId);
|
||||
this.release(sessionId, RequestError.requestCancelled());
|
||||
}
|
||||
await Promise.all([...this.retiring.values()].map((session) => session.close()));
|
||||
await Promise.all([...this.swaps.values()].map((swap) => swap.catch(() => {})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Restarts the session for a new route. At a turn boundary, with no restart
|
||||
* that runs, the restart starts in this call, and the result resolves once
|
||||
* it is done. Otherwise it is due at the next turn boundary, and the result
|
||||
* resolves at once.
|
||||
*/
|
||||
private async rerouteSession(sessionId: string): Promise<void> {
|
||||
const failed = (error: unknown) => {
|
||||
this.host.logger.error(`Session ${sessionId}: could not restart for the new route`, error);
|
||||
};
|
||||
const session = this.host.active(sessionId);
|
||||
if (session && !this.swaps.has(sessionId) && session.atTurnBoundary(true)) {
|
||||
// A restart due at the boundary that came has not resumed yet: this
|
||||
// one runs in its place.
|
||||
this.reroutes.delete(sessionId);
|
||||
await this.swap(sessionId, session, { keepOnFailure: false }).catch(failed);
|
||||
return;
|
||||
}
|
||||
const waiting = this.reroutes.get(sessionId);
|
||||
// One that waits starts under the route in force when it runs. One that
|
||||
// started may have read the route before this change: it runs again.
|
||||
if (waiting && !waiting.started) return;
|
||||
const reroute: Reroute = { done: Promise.resolve(), started: false };
|
||||
const current = () => this.reroutes.get(sessionId) === reroute;
|
||||
reroute.done = (async () => {
|
||||
await waiting?.done;
|
||||
let session: V2Session | undefined;
|
||||
for (;;) {
|
||||
// After an account restart that runs, on the session it started.
|
||||
await this.swaps.get(sessionId)?.catch(() => {});
|
||||
session = this.host.active(sessionId);
|
||||
if (!current() || !session) return;
|
||||
// `false` once the session closed.
|
||||
if (!(await session.turnBoundary(true)) || !current()) return;
|
||||
// A restart can start at the boundary too, and a prompt that it held
|
||||
// can end the boundary again before this resumes: wait again then.
|
||||
if (
|
||||
!this.swaps.has(sessionId) &&
|
||||
this.host.active(sessionId) === session &&
|
||||
session.atTurnBoundary(true)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!this.setups.has(sessionId)) return;
|
||||
reroute.started = true;
|
||||
await this.swap(sessionId, session, { keepOnFailure: false });
|
||||
})()
|
||||
.catch(failed)
|
||||
.finally(() => {
|
||||
if (current()) this.reroutes.delete(sessionId);
|
||||
});
|
||||
this.reroutes.set(sessionId, reroute);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the new query, then ends the old one and sets the client's choices
|
||||
* on the new one. With `keepOnFailure`, a failed start rejects and leaves
|
||||
* `previous` active. Otherwise the session is lost.
|
||||
*/
|
||||
private swap(
|
||||
sessionId: string,
|
||||
previous: V2Session,
|
||||
{ keepOnFailure }: { keepOnFailure: boolean },
|
||||
): Promise<void> {
|
||||
const run = (async () => {
|
||||
const params = this.setups.get(sessionId);
|
||||
if (!params) return;
|
||||
this.host.logger.log(`Restarting the query of session ${sessionId}`);
|
||||
let session: V2Session;
|
||||
try {
|
||||
// The client's effort pin is restored below instead.
|
||||
session = await this.host.start(sessionId, withoutEffort(params));
|
||||
} catch (error) {
|
||||
if (keepOnFailure) throw error;
|
||||
this.host.logger.error(`Session ${sessionId}: could not start again`, error);
|
||||
await this.host.lost(sessionId, previous);
|
||||
return;
|
||||
}
|
||||
// The new query read the account in force.
|
||||
this.accountChanged.delete(sessionId);
|
||||
session.reports.restartedFrom(previous.reports);
|
||||
// Work that started on the old query while the new one started, such
|
||||
// as a turn that Claude Code started on its own, ends first. New
|
||||
// prompts wait for this restart meanwhile (`beforePrompt`).
|
||||
await previous.turnBoundary(true);
|
||||
await previous.retire();
|
||||
// A close during the restart closes the new session next.
|
||||
if (this.closingSessions.has(sessionId)) return;
|
||||
await session.config.restoreFrom(previous.config);
|
||||
this.host.send({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "config_option_update", configOptions: session.config.options() },
|
||||
});
|
||||
// Closing the old query withdrew its MCP OAuth runs. The servers of a
|
||||
// resume that came during the restart are tracked by now.
|
||||
afterSetupResponse(session, this.setups.get(sessionId) ?? params);
|
||||
})().finally(() => {
|
||||
if (this.swaps.get(sessionId) === run) this.swaps.delete(sessionId);
|
||||
if (this.retiring.get(sessionId) === previous) this.retiring.delete(sessionId);
|
||||
});
|
||||
this.retiring.set(sessionId, previous);
|
||||
this.swaps.set(sessionId, run);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** Waits for `work`, unless a cancel or close of the session comes first. */
|
||||
private hold(sessionId: string, work: Promise<void>): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let holds = this.held.get(sessionId);
|
||||
if (!holds) this.held.set(sessionId, (holds = new Set()));
|
||||
const settle = (error?: unknown) => {
|
||||
if (!holds.delete(settle)) return;
|
||||
if (holds.size === 0 && this.held.get(sessionId) === holds) this.held.delete(sessionId);
|
||||
if (error === undefined) resolve();
|
||||
else reject(error);
|
||||
};
|
||||
holds.add(settle);
|
||||
work.then(
|
||||
() => settle(),
|
||||
(error: unknown) => settle(error ?? new Error("The restart failed")),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private release(sessionId: string, error: unknown): void {
|
||||
const holds = this.held.get(sessionId);
|
||||
this.held.delete(sessionId);
|
||||
for (const settle of [...(holds ?? [])]) settle(error);
|
||||
}
|
||||
}
|
||||
133
src/v2/session-requests.ts
Normal file
133
src/v2/session-requests.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* The v1 form of the v2 session setup and config requests, which the shared
|
||||
* session modules take (`sdkMcpServers`, `forkSession`, `configOptionValue`),
|
||||
* and the v2 form of the config options and commands that they build.
|
||||
*/
|
||||
import {
|
||||
RequestError,
|
||||
type AvailableCommand,
|
||||
type ForkSessionRequest,
|
||||
type McpServer,
|
||||
type NewSessionRequest,
|
||||
type SessionConfigOption,
|
||||
type SessionConfigSelectGroup,
|
||||
type SessionConfigSelectOptions,
|
||||
type SetSessionConfigOptionRequest,
|
||||
type SystemPrompt,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { V2_SESSION_CAPABILITIES } from "./initialize.js";
|
||||
import { v1ContentBlock } from "./prompt.js";
|
||||
|
||||
export function v1NewSessionRequest(params: v2.NewSessionRequest): NewSessionRequest {
|
||||
const { mcpServers, systemPrompt, ...request } = params;
|
||||
// v1 requires the list; v2 treats an omitted list as an empty one.
|
||||
return {
|
||||
...request,
|
||||
mcpServers: v1McpServers(mcpServers) ?? [],
|
||||
...v1SystemPromptField(systemPrompt),
|
||||
};
|
||||
}
|
||||
|
||||
/** The v1 request of a v2 `session/fork`. */
|
||||
export function v1ForkSessionRequest(params: v2.ForkSessionRequest): ForkSessionRequest {
|
||||
const { mcpServers, systemPrompt, ...request } = params;
|
||||
return {
|
||||
...request,
|
||||
...(mcpServers ? { mcpServers: v1McpServers(mcpServers) } : {}),
|
||||
...v1SystemPromptField(systemPrompt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The v1 `systemPrompt`. Its content converts like prompt content: a v2
|
||||
* annotation audience can be any string, and v1 takes only `user` or `assistant`.
|
||||
*/
|
||||
function v1SystemPromptField(systemPrompt: v2.SystemPrompt | null | undefined): {
|
||||
systemPrompt?: SystemPrompt;
|
||||
} {
|
||||
if (systemPrompt == null) return {};
|
||||
return { systemPrompt: { ...systemPrompt, content: systemPrompt.content.map(v1ContentBlock) } };
|
||||
}
|
||||
|
||||
/** v2 tags a select value with `type: "id"`; v1 leaves it untagged. */
|
||||
export function v1SetSessionConfigOptionRequest(
|
||||
params: v2.SetSessionConfigOptionRequest,
|
||||
): SetSessionConfigOptionRequest {
|
||||
const request = {
|
||||
sessionId: params.sessionId,
|
||||
configId: params.configId,
|
||||
...(params._meta != null ? { _meta: params._meta } : {}),
|
||||
};
|
||||
if (v2.SetSessionConfigOptionRequest.isId(params)) {
|
||||
return { ...request, value: params.value };
|
||||
}
|
||||
if (v2.SetSessionConfigOptionRequest.isBoolean(params)) {
|
||||
return { ...request, type: "boolean", value: params.value };
|
||||
}
|
||||
throw RequestError.invalidParams(
|
||||
{ type: params.type },
|
||||
`Config option values of type ${params.type} are not supported`,
|
||||
);
|
||||
}
|
||||
|
||||
/** v2 names the id of an option `configId` and the id of a select group `groupId`. */
|
||||
export function v2ConfigOptions(options: SessionConfigOption[]): v2.SessionConfigOption[] {
|
||||
return options.map((option) => {
|
||||
const { id, ...rest } = option;
|
||||
if (rest.type === "select") {
|
||||
return { ...rest, configId: id, options: v2SelectOptions(rest.options) };
|
||||
}
|
||||
return { ...rest, configId: id };
|
||||
});
|
||||
}
|
||||
|
||||
function v2SelectOptions(options: SessionConfigSelectOptions): v2.SessionConfigSelectOptions {
|
||||
if (!isGrouped(options)) return options;
|
||||
return options.map(({ group, ...selectGroup }) => ({ ...selectGroup, groupId: group }));
|
||||
}
|
||||
|
||||
function isGrouped(options: SessionConfigSelectOptions): options is SessionConfigSelectGroup[] {
|
||||
return options.some((entry) => "group" in entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 tags each command with `type: "command"` and its free-text input with
|
||||
* `type: "text"`. A receiver drops a command without the `type` tag.
|
||||
*/
|
||||
export function v2AvailableCommands(commands: AvailableCommand[]): v2.AvailableCommand[] {
|
||||
return commands.map(({ input, ...command }) => ({
|
||||
type: "command",
|
||||
...command,
|
||||
...(input != null ? { input: { type: "text", ...input } } : {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* The v1 list of the MCP servers of a v2 setup request, or undefined for no
|
||||
* list. A request with two servers of the same `name`, or with a transport
|
||||
* that the agent does not advertise in `session.mcp` (such as `acp` or a
|
||||
* custom `_` type), fails with invalid params (-32602): `sdkMcpServers` would
|
||||
* otherwise drop the server without telling the client.
|
||||
*/
|
||||
function v1McpServers(servers: v2.McpServer[] | null | undefined): McpServer[] | undefined {
|
||||
if (servers == null) return undefined;
|
||||
v2.assertSupportedMcpServers(servers, V2_SESSION_CAPABILITIES.mcp);
|
||||
return servers.map(v1McpServer);
|
||||
}
|
||||
|
||||
/**
|
||||
* v2 requires a `type` on every transport and makes the empty lists optional.
|
||||
* `sdkMcpServers` recognizes a v1 stdio server by its missing `type`, so the
|
||||
* v2 tag is dropped. {@link v1McpServers} has already rejected every other transport.
|
||||
*/
|
||||
function v1McpServer(server: v2.McpServer): McpServer {
|
||||
if (v2.McpServer.isStdio(server)) {
|
||||
const { name, command, args, env, _meta } = server;
|
||||
return { name, command, args: args ?? [], env: env ?? [], ...(_meta != null ? { _meta } : {}) };
|
||||
}
|
||||
if (v2.McpServer.isHttp(server)) {
|
||||
return { ...server, headers: server.headers ?? [] };
|
||||
}
|
||||
throw new Error(`Unreachable: MCP server of type ${server.type} passed the transport check`);
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
/**
|
||||
* The v2 form of the session updates that `ClaudeAcpAgent` sends as v1
|
||||
* updates.
|
||||
* The v2 form of the v1 session updates that the shared SDK-to-ACP mapping
|
||||
* produces (`toAcpNotifications`).
|
||||
*/
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { v2AvailableCommands, v2ConfigOptions } from "./session.js";
|
||||
import { v2AvailableCommands, v2ConfigOptions } from "./session-requests.js";
|
||||
import { checklistPlanOperation } from "../plans.js";
|
||||
import { v2ToolCallUpdate } from "./tool-call.js";
|
||||
|
||||
@@ -53,9 +53,7 @@ export function v2SessionUpdate(
|
||||
// v1's draft plan operations have v2's shape.
|
||||
return update;
|
||||
case "subagent_update": {
|
||||
// The subagents RFD gives it the same shape in v1 and v2. One that
|
||||
// reports a state also has a copy on the child's stream (see
|
||||
// `v2ChildStateUpdate`).
|
||||
// The subagents RFD gives it the same shape in v1 and v2.
|
||||
const { state, ...association } = update;
|
||||
return { ...association, ...(state != null ? { state: v2SubagentState(state) } : {}) };
|
||||
}
|
||||
@@ -75,26 +73,6 @@ export function v2SessionUpdate(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The child's own `state_update` for a v1 `subagent_update` that reports the
|
||||
* child's work state, or `undefined` when it reports none.
|
||||
*
|
||||
* v1 reports a child's work state only on its parent, as the `state` of the
|
||||
* `subagent_update`. v2 reports it on the child's own stream, as for any
|
||||
* session, and mirrors it on the parent (the subagents RFD). The child's copy
|
||||
* goes right after the parent's: never before the announcement, and both
|
||||
* copies of a state before any later state.
|
||||
*/
|
||||
export function v2ChildStateUpdate(
|
||||
update: AcpSessionNotification["update"],
|
||||
): v2.UpdateSessionNotification | undefined {
|
||||
if (update.sessionUpdate !== "subagent_update" || update.state == null) return undefined;
|
||||
return {
|
||||
sessionId: update.sessionId,
|
||||
update: { sessionUpdate: "state_update", ...v2SubagentState(update.state) },
|
||||
};
|
||||
}
|
||||
|
||||
type V1SubagentState = NonNullable<
|
||||
Extract<AcpSessionNotification["update"], { sessionUpdate: "subagent_update" }>["state"]
|
||||
>;
|
||||
|
||||
1541
src/v2/session.ts
1541
src/v2/session.ts
File diff suppressed because it is too large
Load Diff
130
src/v2/setup.ts
Normal file
130
src/v2/setup.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Session setup of the v2 core, on the v1 core's rules: the query
|
||||
* options that `_meta.claudeCode.options` and the agent's environment add,
|
||||
* the models a session offers, and the updates that a `session/resume`
|
||||
* sends before its response.
|
||||
*/
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import {
|
||||
type HookCallbackMatcher,
|
||||
type HookEvent,
|
||||
type ModelInfo,
|
||||
type Options,
|
||||
type Query,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger, NewSessionMeta } from "../acp-agent.js";
|
||||
import {
|
||||
AIR_LOAD_REPLAY_END_CAPABILITY,
|
||||
AIR_LOAD_REPLAY_END_KEY,
|
||||
clientSupportsAirCapability,
|
||||
withAirMeta,
|
||||
} from "../air-extension.js";
|
||||
import {
|
||||
aliasResumedModelRow,
|
||||
applyAvailableModelsAllowlist,
|
||||
getAvailableModels,
|
||||
type SessionModelState,
|
||||
withCurrentModelInfo,
|
||||
} from "../session-model.js";
|
||||
import type { SettingsManager } from "../settings.js";
|
||||
import type { SetupParams } from "./restarts.js";
|
||||
import type { V2Session } from "./session.js";
|
||||
|
||||
/** The SDK options a client passes in `_meta.claudeCode.options`. */
|
||||
export function clientOptions(meta: unknown): Options | undefined {
|
||||
return (meta as NewSessionMeta | undefined)?.claudeCode?.options;
|
||||
}
|
||||
|
||||
/** The core's hooks, after the client's own hooks of the same event. */
|
||||
export function withClientHooks(
|
||||
client: Options["hooks"],
|
||||
own: Partial<Record<HookEvent, HookCallbackMatcher[]>>,
|
||||
): Partial<Record<HookEvent, HookCallbackMatcher[]>> {
|
||||
const hooks = { ...client };
|
||||
for (const [event, matchers] of Object.entries(own) as [HookEvent, HookCallbackMatcher[]][]) {
|
||||
hooks[event] = [...(client?.[event] ?? []), ...matchers];
|
||||
}
|
||||
return hooks;
|
||||
}
|
||||
|
||||
/**
|
||||
* The models of a session, as the v1 core picks them: the SDK's models, with
|
||||
* a resumed session's model row under its family alias (`aliasResumedModelRow`),
|
||||
* narrowed by the settings' `availableModels` allowlist and then by the
|
||||
* provider (`narrow`), with the current model from `ANTHROPIC_MODEL`, the
|
||||
* settings' `model`, or the transcript of a resumed session
|
||||
* (`getAvailableModels`, which sets it on the query when needed), and the
|
||||
* current model's capabilities, also for a resumed model outside the
|
||||
* allowlist (`withCurrentModelInfo`).
|
||||
*/
|
||||
export async function sessionModels(
|
||||
query: Query,
|
||||
sdkModels: ModelInfo[],
|
||||
settings: SettingsManager,
|
||||
narrow: (models: ModelInfo[]) => ModelInfo[],
|
||||
narrowedByProvider: boolean,
|
||||
resumed: { model?: string } | undefined,
|
||||
sessionId: string,
|
||||
logger: Logger,
|
||||
): Promise<{
|
||||
models: SessionModelState;
|
||||
modelInfos: ModelInfo[];
|
||||
currentModelInfo: ModelInfo | undefined;
|
||||
allowlistedModelInfo: ModelInfo | undefined;
|
||||
}> {
|
||||
const { availableModels, modelOverrides } = settings.getSettings();
|
||||
// A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new session does.
|
||||
const listed = aliasResumedModelRow(sdkModels, resumed?.model);
|
||||
const allowed = Array.isArray(availableModels)
|
||||
? applyAvailableModelsAllowlist(
|
||||
listed,
|
||||
availableModels,
|
||||
modelOverrides,
|
||||
settings.getManagedDeniedModels(),
|
||||
)
|
||||
: listed;
|
||||
const narrowed = narrow(allowed);
|
||||
const models = await getAvailableModels(
|
||||
query,
|
||||
narrowed,
|
||||
sdkModels,
|
||||
settings,
|
||||
logger,
|
||||
resumed !== undefined,
|
||||
sessionId,
|
||||
resumed?.model,
|
||||
narrowedByProvider,
|
||||
);
|
||||
return { models, ...withCurrentModelInfo(narrowed, listed, models.currentModelId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The update that ends a replay, for a client that declares AIR's
|
||||
* `loadReplayEnd` capability, as v1 sends it.
|
||||
*/
|
||||
export function replayEndUpdate(
|
||||
capabilities: v2.ClientCapabilities | null | undefined,
|
||||
): v2.SessionUpdate | undefined {
|
||||
if (!clientSupportsAirCapability(capabilities, AIR_LOAD_REPLAY_END_CAPABILITY)) return undefined;
|
||||
// No `title` and no `updatedAt`: `null` would clear them.
|
||||
return {
|
||||
sessionUpdate: "session_info_update",
|
||||
_meta: withAirMeta(undefined, AIR_LOAD_REPLAY_END_KEY, true),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts OAuth for the setup request's MCP servers that need it, once the
|
||||
* setup response is sent: v2 asks for no elicitation before it. The handler
|
||||
* returns the response right after this call, and the SDK writes it before a
|
||||
* timer runs. Nothing else orders the two. A restart calls it on the new
|
||||
* query too: closing the old one withdrew the old one's OAuth runs.
|
||||
*/
|
||||
export function afterSetupResponse(session: V2Session, params: SetupParams): void {
|
||||
// The setup already rejected servers of an unknown transport.
|
||||
const names = (params.mcpServers ?? []).flatMap((server) =>
|
||||
typeof server.name === "string" ? [server.name] : [],
|
||||
);
|
||||
if (names.length === 0) return;
|
||||
setTimeout(() => session.elicitations.authenticateMcpServers(names), 0);
|
||||
}
|
||||
246
src/v2/stopped-work.ts
Normal file
246
src/v2/stopped-work.ts
Normal file
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* The work of an ACP v2 session that a `session/cancel` stopped before
|
||||
* the client saw it, and the reach of the cancel's interrupt.
|
||||
*
|
||||
* A cancel answers every prompt that is not inserted yet with -32800, also
|
||||
* one that went to Claude Code already. Claude Code can still run such a
|
||||
* prompt, when it had taken it in before the interrupt arrived. The client
|
||||
* was told the prompt never ran, so that run is dropped: its output, its
|
||||
* states, and its permission requests, which are denied. So is a model cycle
|
||||
* that Claude Code had started on its own (a `status: requesting` frame came)
|
||||
* and that showed nothing yet when the cancel came while the session was
|
||||
* idle.
|
||||
*
|
||||
* The interrupt stops whatever Claude Code runs when it arrives, which can be
|
||||
* a cycle that the session reads only after the cancel. That cycle ends with
|
||||
* an aborted result, which reports `cancelled`. Only the next result can be
|
||||
* that cycle's after a cancel of an idle session, and the next two after a
|
||||
* cancel of open work (the open cycle can end before the interrupt arrives).
|
||||
* A prompt that a cancel did not abandon starting, or an idle that follows no
|
||||
* result, ends that reach sooner: a later aborted result, such as of an
|
||||
* interrupt from Claude Code's remote control, reports as it would without
|
||||
* the cancel.
|
||||
*/
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKResultMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { isAbortedResult } from "../acp-agent.js";
|
||||
|
||||
/**
|
||||
* How long `canUseTool` waits, after a cancel, for the session to read the
|
||||
* tool use that it asks about, as on v1 (`TOOL_USE_WAIT_MS`).
|
||||
*/
|
||||
const TOOL_USE_WAIT_MS = 2_000;
|
||||
|
||||
export class StoppedWork {
|
||||
/** The prompts that a cancel answered with -32800 after they went to Claude Code. */
|
||||
private readonly abandoned = new Set<string>();
|
||||
/** Whether Claude Code runs dropped work: its output goes nowhere. */
|
||||
private dropping = false;
|
||||
/** The abandoned prompt whose run is dropped, when a prompt's run is. */
|
||||
private droppingFor: string | undefined;
|
||||
/** The prompts that Claude Code took in since the last result. */
|
||||
private readonly startedSinceResult = new Set<string>();
|
||||
/**
|
||||
* Whether the late result of a dead turn may still come: Claude Code sent
|
||||
* `cancelled` for a prompt that it had started, before any result. The v1
|
||||
* core calls such a prompt a zombie.
|
||||
*/
|
||||
private deadTurnResult = false;
|
||||
/**
|
||||
* The tool uses of the main conversation that the session read since the
|
||||
* last result, each with whether it was dropped work's.
|
||||
*/
|
||||
private readonly toolUses = new Map<string, boolean>();
|
||||
/** The `canUseTool` checks that wait for the session to read their tool use. */
|
||||
private readonly toolUseWaiters = new Map<string, Set<() => void>>();
|
||||
/**
|
||||
* How many of the next results a cancel's interrupt can end, until Claude
|
||||
* Code shows what it stopped: 2 after a cancel of open work, whose result
|
||||
* can come before the interrupt arrives and stops the next cycle, and 1
|
||||
* after a cancel of an idle session (the cycle that Claude Code runs when
|
||||
* it arrives).
|
||||
*/
|
||||
private interruptReach = 0;
|
||||
/**
|
||||
* Whether a model request started since the last result: a `status:
|
||||
* requesting` frame, which starts each one.
|
||||
*/
|
||||
modelRequestSinceResult = false;
|
||||
|
||||
/**
|
||||
* A cancel: `pushed` are the prompts it answered that Claude Code has, and
|
||||
* `foreground` whether the client sees work running. A cycle that Claude
|
||||
* Code started while the client saw none is dropped.
|
||||
*/
|
||||
cancelled(pushed: readonly string[], foreground: boolean): void {
|
||||
for (const uuid of pushed) this.abandoned.add(uuid);
|
||||
if (!foreground && this.modelRequestSinceResult) this.dropping = true;
|
||||
this.interruptReach = foreground ? 2 : 1;
|
||||
}
|
||||
|
||||
/** The interrupt failed: Claude Code stops nothing. */
|
||||
interruptFailed(): void {
|
||||
this.interruptReach = 0;
|
||||
}
|
||||
|
||||
/** A `status: requesting` frame, with the prompts that the request serves. */
|
||||
requesting(prompts: readonly string[]): void {
|
||||
this.modelRequestSinceResult = true;
|
||||
const abandoned = prompts.find((uuid) => this.abandoned.has(uuid));
|
||||
if (abandoned !== undefined) this.drop(abandoned);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code takes in the prompt `uuid` (its `started` frame or echo).
|
||||
* Returns whether it is one that a cancel abandoned, which then adds
|
||||
* nothing: its run is dropped, or, when work is open (`foreground`), Claude
|
||||
* Code folded it into the work that the cancel stops. Another prompt's run
|
||||
* is not dropped.
|
||||
*/
|
||||
promptStarted(uuid: string, foreground: boolean): boolean {
|
||||
this.startedSinceResult.add(uuid);
|
||||
if (!this.abandoned.has(uuid)) {
|
||||
// Claude Code takes its prompts in order: the earlier ones ran or were
|
||||
// dropped, and a dead turn's late result came before this prompt started.
|
||||
// A prompt that a cancel did not abandon went to Claude Code after the
|
||||
// cancel's interrupt, on the same stdin: the interrupt reached no
|
||||
// further either.
|
||||
this.abandoned.clear();
|
||||
this.endDropping();
|
||||
this.deadTurnResult = false;
|
||||
this.interruptReach = 0;
|
||||
return false;
|
||||
}
|
||||
if (!foreground) this.drop(uuid);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code ended the prompt `uuid` (`completed`, or `cancelled` when
|
||||
* `cancelled`). Only the end of the prompt whose run is dropped ends the
|
||||
* drop: another abandoned prompt's `cancelled` frame, which the interrupt's
|
||||
* `cancelQueued` sends, does not.
|
||||
*/
|
||||
promptEnded(uuid: string, cancelled = false): void {
|
||||
// `cancelled` after `started` and before a result: the turn died, and its
|
||||
// result can still come after this frame. That result ends nothing.
|
||||
if (cancelled && this.startedSinceResult.delete(uuid)) this.deadTurnResult = true;
|
||||
if (this.abandoned.delete(uuid) && this.droppingFor === uuid) this.endDropping();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `message`, output of the main conversation, is dropped work's.
|
||||
* Records the tool uses it asks to run.
|
||||
*/
|
||||
drops(message: { type: string; message?: unknown; event?: unknown }): boolean {
|
||||
for (const id of toolUseIds(message)) {
|
||||
this.toolUses.set(id, this.dropping);
|
||||
const waiters = this.toolUseWaiters.get(id);
|
||||
this.toolUseWaiters.delete(id);
|
||||
for (const waiter of waiters ?? []) waiter();
|
||||
}
|
||||
return this.dropping;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `canUseTool` denies the tool use `toolUseId` of the agent
|
||||
* `agentId` (undefined for the main conversation): dropped work asks to
|
||||
* run it. Claude Code asks on its control channel after it streamed the
|
||||
* tool use, and the session can read the stream later. So after a cancel,
|
||||
* while dropped work may run, this waits until the session reads the tool
|
||||
* use, for at most {@link TOOL_USE_WAIT_MS} or until `signal` aborts: the
|
||||
* stream never shows some tool uses, such as SandboxNetworkAccess's.
|
||||
*/
|
||||
async dropsToolUse(
|
||||
toolUseId: string,
|
||||
agentId: string | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<boolean> {
|
||||
if (agentId !== undefined) return false;
|
||||
const read = this.toolUses.get(toolUseId);
|
||||
if (read !== undefined) return read;
|
||||
if ((this.abandoned.size === 0 && !this.dropping) || signal.aborted) return false;
|
||||
await new Promise<void>((resolve) => {
|
||||
const waiters = this.toolUseWaiters.get(toolUseId) ?? new Set<() => void>();
|
||||
this.toolUseWaiters.set(toolUseId, waiters);
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
signal.removeEventListener("abort", done);
|
||||
waiters.delete(done);
|
||||
if (waiters.size === 0 && this.toolUseWaiters.get(toolUseId) === waiters) {
|
||||
this.toolUseWaiters.delete(toolUseId);
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(done, TOOL_USE_WAIT_MS);
|
||||
signal.addEventListener("abort", done, { once: true });
|
||||
waiters.add(done);
|
||||
});
|
||||
return this.toolUses.get(toolUseId) ?? this.dropping;
|
||||
}
|
||||
|
||||
/**
|
||||
* A result that is not a placeholder. Returns whether it ends dropped work,
|
||||
* or is the late result of a dead turn (see {@link promptEnded}), and
|
||||
* whether it ends work that the interrupt stopped (an aborted result after
|
||||
* a cancel).
|
||||
*/
|
||||
result(result: SDKResultMessage): { dropped: boolean; interrupted: boolean } {
|
||||
this.modelRequestSinceResult = false;
|
||||
this.startedSinceResult.clear();
|
||||
// Claude Code asks for a tool use before the result that ends its turn.
|
||||
this.toolUses.clear();
|
||||
// An aborted result shows what the interrupt stopped. Another one uses up
|
||||
// one result of its reach (see `interruptReach`).
|
||||
const aborted = this.interruptReach > 0 && isAbortedResult(result);
|
||||
this.interruptReach = aborted ? 0 : Math.max(0, this.interruptReach - 1);
|
||||
const dropped = this.dropping || this.deadTurnResult;
|
||||
this.deadTurnResult = false;
|
||||
this.endDropping();
|
||||
return { dropped, interrupted: aborted && !dropped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code went idle, other than right after a result: what it ran has
|
||||
* ended, and so has the interrupt's reach.
|
||||
*/
|
||||
idle(): void {
|
||||
this.interruptReach = 0;
|
||||
this.modelRequestSinceResult = false;
|
||||
// A dead turn's result comes before Claude Code goes idle, or never.
|
||||
this.deadTurnResult = false;
|
||||
this.startedSinceResult.clear();
|
||||
this.endDropping();
|
||||
}
|
||||
|
||||
/** Drops the run of the abandoned prompt `uuid`. */
|
||||
private drop(uuid: string): void {
|
||||
this.dropping = true;
|
||||
this.droppingFor = uuid;
|
||||
}
|
||||
|
||||
private endDropping(): void {
|
||||
this.dropping = false;
|
||||
this.droppingFor = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** The ids of the tool uses in an assistant message, or in a streamed tool_use block's start. */
|
||||
function toolUseIds(message: { type: string; message?: unknown; event?: unknown }): string[] {
|
||||
if (message.type === "assistant") {
|
||||
const content = (message as SDKAssistantMessage).message.content;
|
||||
return Array.isArray(content)
|
||||
? content.flatMap((block) => (block.type === "tool_use" ? [block.id] : []))
|
||||
: [];
|
||||
}
|
||||
if (message.type === "stream_event") {
|
||||
const event = (message as SDKPartialAssistantMessage).event;
|
||||
if (event.type === "content_block_start" && event.content_block.type === "tool_use") {
|
||||
return [event.content_block.id];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
633
src/v2/stream.ts
Normal file
633
src/v2/stream.ts
Normal file
@@ -0,0 +1,633 @@
|
||||
/**
|
||||
* The output of one Claude Code session, as ACP session updates.
|
||||
*
|
||||
* {@link SessionOutput} maps the SDK's content messages (stream events,
|
||||
* consolidated assistant messages, tool results) through the shared mapping
|
||||
* of `acp-agent.ts` (`toAcpNotifications`), whose v1 updates the connection's
|
||||
* {@link V2UpdateTranslator} turns into v2 updates. It knows nothing about
|
||||
* prompts or turns: the session decides what the output belongs to.
|
||||
*/
|
||||
import type {
|
||||
ClientCapabilities as AcpClientCapabilities,
|
||||
ToolCallContent,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { ContentBlock } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKMemoryRecallMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKPermissionDeniedMessage,
|
||||
SDKToolProgressMessage,
|
||||
SDKUserMessage,
|
||||
SessionMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
messageIdForGrouping,
|
||||
stripLocalCommandMetadata,
|
||||
streamEventToAcpNotifications,
|
||||
toAcpNotifications,
|
||||
type AcpClient,
|
||||
type Logger,
|
||||
type StreamedToolInputCache,
|
||||
type ToolUseCache,
|
||||
} from "../acp-agent.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { isCompactSummaryMessage, replayCompactSummary } from "../context-compaction.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import { AcpToolCallRenderer, V2_CANCELLED } from "../tool-calls/renderer.js";
|
||||
import { changedTaskPlanEntries, unregisterHookCallback, type TaskState } from "../tools.js";
|
||||
import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js";
|
||||
import { replayClear, type V2UpdateTranslator } from "./update-translator.js";
|
||||
|
||||
/** Every v2 client gets the same tool call reports: v2 diffs and display terminals. */
|
||||
const V2_TOOL_CALL_CAPABILITIES = ClientCapabilities.from(undefined, { v2: true });
|
||||
|
||||
/**
|
||||
* The v1 capabilities the shared mapping reads. Every v2 client takes
|
||||
* `plan_update` with a markdown plan, so an approved ExitPlanMode's plan
|
||||
* goes out as one. Every v2 client also gets a session per subagent
|
||||
* (`tasks.ts`), so a subagent's TodoWrite checklist is that session's `plan`
|
||||
* rather than `plan:<parentToolUseId>` in its parent's session.
|
||||
*/
|
||||
const V2_CLIENT_CAPABILITIES = { plan: {}, subagents: {} } as AcpClientCapabilities;
|
||||
|
||||
type AssistantContent = SDKAssistantMessage["message"]["content"];
|
||||
type UserContent = SDKUserMessage["message"]["content"];
|
||||
|
||||
/**
|
||||
* Rewrites a v1 update of the output before it is translated: the updates it
|
||||
* returns go out instead, such as none for the Agent call of a child session
|
||||
* (see `tasks.ts`).
|
||||
*/
|
||||
export type OutputRoute = (notification: AcpSessionNotification) => AcpSessionNotification[];
|
||||
|
||||
/** What a replay asks of the session's subagents (see `subagent-replay.ts`). */
|
||||
export interface ReplayHooks {
|
||||
/** Rewrites each replayed update, as {@link OutputRoute} does live. */
|
||||
route: OutputRoute;
|
||||
/** A message of the history is about to replay, such as a child's response with its model. */
|
||||
replaying?(message: SessionMessage): void;
|
||||
/** A message of the history was replayed, such as one that launched subagents. */
|
||||
replayed(message: SessionMessage): void;
|
||||
/**
|
||||
* The part of a user message's content to replay, or `null` for none. A
|
||||
* child's prompt replays as a `session_message` instead.
|
||||
*/
|
||||
userContent?(message: SessionMessage, content: unknown): unknown;
|
||||
}
|
||||
|
||||
export class SessionOutput {
|
||||
private readonly toolUseCache: ToolUseCache = {};
|
||||
private readonly emittedToolCalls = new Set<string>();
|
||||
/** What the client holds of each open tool call: an update carries only what changed. */
|
||||
private readonly toolCallFields = new ToolCallFieldTracker();
|
||||
private readonly streamedToolInputs: StreamedToolInputCache = new Map();
|
||||
/** The session's Task* list (TaskCreate, TaskUpdate, TaskList), sent as plan updates. */
|
||||
readonly taskState: TaskState = new Map();
|
||||
/** The text and thinking that streamed as deltas since the last message started or ended. */
|
||||
private readonly streamedBlocks: StreamedBlock[] = [];
|
||||
/** The id of the assistant message that is streaming. */
|
||||
private streamingMessageId: string | undefined;
|
||||
/** Whether any agent text went out since {@link resetAgentText}. */
|
||||
private agentText = false;
|
||||
/** During {@link replay}, the messages that it has cleared so far. */
|
||||
private replayCleared: Set<string> | undefined;
|
||||
/** The open tool calls whose tool use reached a complete assistant message: Claude Code runs them. */
|
||||
private readonly dispatched = new Set<string>();
|
||||
/**
|
||||
* Whether the output comes from work that a `session/cancel` stopped, until
|
||||
* its idle. The session's streamed deltas still go out,
|
||||
* and of its consolidated messages only the tool results of the calls that
|
||||
* the client holds, where an error result ends a run that the cancel
|
||||
* aborted (`cancelled`). On the root, this covers the main conversation's
|
||||
* messages. A foreground subagent's session sets it at the root's cancel,
|
||||
* until its run ends (see `SessionTasks.cancelForeground`).
|
||||
*/
|
||||
cancelled = false;
|
||||
|
||||
/** Whether `message` is of the work that a cancel stopped (see {@link cancelled}). */
|
||||
private stoppedWork(message: { parent_tool_use_id: string | null }): boolean {
|
||||
return this.cancelled && (this.subagent || message.parent_tool_use_id === null);
|
||||
}
|
||||
/** During {@link replay}, the replayed tool uses, apart from the live ones. */
|
||||
private replayToolUses: ToolUseCache | undefined;
|
||||
/** During {@link replay}, what the session's subagents ask of it. */
|
||||
private replayHooks: ReplayHooks | undefined;
|
||||
/** During {@link replay}, the replayed tool calls without a final status. */
|
||||
private replayUnfinished: Set<string> | undefined;
|
||||
/** Set by the session's tasks: rewrites the live updates (see {@link OutputRoute}). */
|
||||
route: OutputRoute | undefined;
|
||||
/** Set by the session around one message: rewrites its updates before {@link route}. */
|
||||
rewrite: ((notification: AcpSessionNotification) => AcpSessionNotification) | undefined;
|
||||
|
||||
constructor(
|
||||
readonly sessionId: string,
|
||||
private readonly cwd: string,
|
||||
private readonly translator: V2UpdateTranslator,
|
||||
private readonly send: (update: v2.SessionUpdate) => void,
|
||||
private readonly logger: Logger,
|
||||
/**
|
||||
* Whether this is a subagent's session. Its messages keep the
|
||||
* `parent_tool_use_id` of the Agent call that launched it, and its text
|
||||
* streams (`forwardSubagentText`).
|
||||
*/
|
||||
private readonly subagent = false,
|
||||
) {}
|
||||
|
||||
/** Tool updates that a hook callback sends outside the stream (an Edit's diff). */
|
||||
private readonly hookClient = {
|
||||
sessionUpdate: async (notification: AcpSessionNotification) => this.emit([notification]),
|
||||
} as unknown as AcpClient;
|
||||
|
||||
/** A partial assistant message: one event of the streaming API response. */
|
||||
streamEvent(message: SDKPartialAssistantMessage): void {
|
||||
// A subagent's stream goes to its own session (see `tasks.ts`).
|
||||
if (message.parent_tool_use_id !== null && !this.subagent) return;
|
||||
if (message.event.type === "message_start") {
|
||||
this.streamingMessageId = message.event.message.id;
|
||||
// A message that never reached its consolidated message, such as one an
|
||||
// interrupt cut short, leaves its record behind.
|
||||
this.streamedBlocks.length = 0;
|
||||
}
|
||||
recordStreamedDelta(this.streamedBlocks, message.event);
|
||||
this.emit(
|
||||
streamEventToAcpNotifications(
|
||||
message,
|
||||
this.sessionId,
|
||||
this.toolUseCache,
|
||||
this.hookClient,
|
||||
this.logger,
|
||||
{
|
||||
toolCallCapabilities: V2_TOOL_CALL_CAPABILITIES,
|
||||
cwd: this.cwd,
|
||||
emittedToolCalls: this.emittedToolCalls,
|
||||
toolCallFields: this.toolCallFields,
|
||||
messageId: this.streamingMessageId,
|
||||
streamedToolInputs: this.streamedToolInputs,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A consolidated assistant message. Of its text and thinking, only what did
|
||||
* not stream as deltas goes out (`unstreamedRemainder`):
|
||||
* the tail of a block whose stream was cut short, or the whole message when
|
||||
* it never streamed (a gateway without streaming). Its tool uses then get
|
||||
* their complete input. A subagent's message on the root session goes
|
||||
* without its text, which belongs to the subagent's own session. After a
|
||||
* cancel, the stopped work's message is dropped (see {@link cancelled}).
|
||||
*/
|
||||
assistant(message: SDKAssistantMessage): void {
|
||||
const messageId = messageIdForGrouping(message);
|
||||
for (const block of message.message.content) {
|
||||
if (block.type === "tool_use") this.dispatched.add(block.id);
|
||||
}
|
||||
const ownText = message.parent_tool_use_id === null || this.subagent;
|
||||
const content = ownText
|
||||
? unstreamedRemainder(message.message.content, this.streamedBlocks)
|
||||
: message.message.content.filter(
|
||||
(block) => block.type !== "text" && block.type !== "thinking",
|
||||
);
|
||||
if (ownText) this.streamedBlocks.length = 0;
|
||||
if (this.stoppedWork(message)) return;
|
||||
this.content(content, "assistant", message.parent_tool_use_id, { messageId });
|
||||
}
|
||||
|
||||
/**
|
||||
* A user message that Claude Code adds: the results of tool uses. Its other
|
||||
* blocks (command output, reminders, a prompt's echo) are not output.
|
||||
* `transform` rewrites each update before it goes out, such as a background
|
||||
* command's result, whose terminal keeps running.
|
||||
*/
|
||||
toolResults(
|
||||
message: SDKUserMessage,
|
||||
transform: (notification: AcpSessionNotification) => AcpSessionNotification = (n) => n,
|
||||
): void {
|
||||
if (!Array.isArray(message.message.content)) return;
|
||||
const all = message.message.content.filter((block) => block.type === "tool_result");
|
||||
// After a cancel, only the results of the calls that the client holds (see {@link cancelled}).
|
||||
const results = this.stoppedWork(message)
|
||||
? all.filter((block) =>
|
||||
this.emittedToolCalls.has((block as { tool_use_id: string }).tool_use_id),
|
||||
)
|
||||
: all;
|
||||
if (results.length === 0) return;
|
||||
this.content(
|
||||
results,
|
||||
"user",
|
||||
message.parent_tool_use_id,
|
||||
{
|
||||
// `tool_use_result` describes the one result of its message.
|
||||
toolUseResult: results.length === all.length ? message.tool_use_result : undefined,
|
||||
toolResultMeta: (message as { tool_result_meta?: unknown }).tool_result_meta,
|
||||
cancelled: this.cancelled,
|
||||
},
|
||||
transform,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows `content`, the exact patch of an Edit or Write, on its tool call
|
||||
* before the tool runs (see `file-preview.ts`). The streamed input's diff
|
||||
* does not replace the patch. A hook can come before the streamed tool_use,
|
||||
* and a workflow's tools run hooks without ever streaming their use or
|
||||
* result: the patch then waits for the tool call's first publication (by the
|
||||
* stream or a permission request), instead of showing a tool call that would
|
||||
* never end.
|
||||
*/
|
||||
preview(toolCallId: string, content: ToolCallContent[]): void {
|
||||
if (!this.emittedToolCalls.has(toolCallId)) {
|
||||
this.toolCallFields.stagePreview(toolCallId, content);
|
||||
return;
|
||||
}
|
||||
const update = { sessionUpdate: "tool_call_update" as const, toolCallId, content };
|
||||
if (!this.toolCallFields.apply(update)) return;
|
||||
this.toolCallFields.pinContent(toolCallId, content);
|
||||
this.emit([{ sessionId: this.sessionId, update }]);
|
||||
}
|
||||
|
||||
/** Whether the stream mapped the tool call `toolCallId`, shown or routed away. */
|
||||
holds(toolCallId: string): boolean {
|
||||
return this.emittedToolCalls.has(toolCallId);
|
||||
}
|
||||
|
||||
/** The tool uses of the session's tool calls, by id. */
|
||||
toolUses(): Readonly<ToolUseCache> {
|
||||
return this.toolUseCache;
|
||||
}
|
||||
|
||||
/** Sends v1-shaped updates of the session, as the shared mapping makes them. */
|
||||
emitV1(notifications: AcpSessionNotification[]): void {
|
||||
this.emit(notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* A tool's progress beat, on its tool call: the beat's own `tool_use_id`
|
||||
* when the client holds that call, else the call it runs in
|
||||
* (`parent_tool_use_id`), such as the Agent call of a subagent's tool or
|
||||
* a heartbeat's derived id. A beat for a call that returned is dropped.
|
||||
*/
|
||||
toolProgress(message: SDKToolProgressMessage): void {
|
||||
const toolCallId = this.emittedToolCalls.has(message.tool_use_id)
|
||||
? message.tool_use_id
|
||||
: message.parent_tool_use_id;
|
||||
if (toolCallId === null || !this.emittedToolCalls.has(toolCallId)) return;
|
||||
// A subagent's beat, on its own session, names the Agent call that runs
|
||||
// it. A tool call is never its own parent.
|
||||
const parentToolUseId =
|
||||
this.subagent && message.parent_tool_use_id !== toolCallId
|
||||
? (message.parent_tool_use_id ?? undefined)
|
||||
: undefined;
|
||||
const beat = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).progress({
|
||||
toolCallId,
|
||||
toolName: message.tool_name,
|
||||
parentToolUseId,
|
||||
elapsedTimeSeconds: message.elapsed_time_seconds,
|
||||
subagentType: message.subagent_type,
|
||||
subagentRetry: message.subagent_retry,
|
||||
});
|
||||
if (this.toolCallFields.apply(beat)) this.emit([{ sessionId: this.sessionId, update: beat }]);
|
||||
}
|
||||
|
||||
/** Claude Code recalled memories: a completed read tool call. */
|
||||
memoryRecall(message: SDKMemoryRecallMessage): void {
|
||||
this.emit([
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
update: new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).memoryRecall(message),
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/** The Task* list changed outside a tool result (the TaskCreated and TaskCompleted hooks). */
|
||||
taskPlanChanged(): void {
|
||||
const entries = changedTaskPlanEntries(this.taskState, false);
|
||||
if (entries)
|
||||
this.emit([{ sessionId: this.sessionId, update: { sessionUpdate: "plan", entries } }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool call of a permission request that the stream never shows, such
|
||||
* as SandboxNetworkAccess's, before the request: the client holds it, and
|
||||
* {@link toolCallStatus} ends it once the request is answered, as on v1
|
||||
* (`ensureToolCallEmitted`).
|
||||
*/
|
||||
permissionToolCall(toolName: string, toolCallId: string, input: unknown): void {
|
||||
if (this.emittedToolCalls.has(toolCallId)) return;
|
||||
this.emittedToolCalls.add(toolCallId);
|
||||
const update = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).toolCall(
|
||||
{ id: toolCallId, name: toolName, input },
|
||||
{ cwd: this.cwd },
|
||||
);
|
||||
this.toolCallFields.apply(update);
|
||||
this.emit([{ sessionId: this.sessionId, update }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool call that the adapter starts for a background task that no tool
|
||||
* call stands for (see `SessionTasks.adoptTasks`). It ends with the task.
|
||||
*/
|
||||
taskToolCall(
|
||||
update: Extract<AcpSessionNotification["update"], { sessionUpdate: "tool_call" }>,
|
||||
): void {
|
||||
this.emittedToolCalls.add(update.toolCallId);
|
||||
this.toolCallFields.apply(update);
|
||||
this.emit([{ sessionId: this.sessionId, update }]);
|
||||
}
|
||||
|
||||
/** A workflow's latest progress on its tool call, as its title and body (`workflowCard`). */
|
||||
taskToolCallProgress(
|
||||
toolCallId: string,
|
||||
card: { title: string; content: ToolCallContent[] },
|
||||
): void {
|
||||
this.emit([
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
update: { sessionUpdate: "tool_call_update", toolCallId, ...card },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code denied a tool use without asking (a rule, the classifier or
|
||||
* the mode): its tool call fails with the reason. A tool
|
||||
* call that the client does not hold, such as dropped work's, is left out.
|
||||
*/
|
||||
permissionDenied(message: SDKPermissionDeniedMessage, parentToolUseId?: string): void {
|
||||
// Its PostToolUse hook never runs.
|
||||
unregisterHookCallback(message.tool_use_id);
|
||||
if (!this.emittedToolCalls.has(message.tool_use_id)) return;
|
||||
const denied = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES).permissionDenied({
|
||||
toolCallId: message.tool_use_id,
|
||||
toolName: message.tool_name,
|
||||
...(parentToolUseId !== undefined ? { parentToolUseId } : {}),
|
||||
decisionReasonType: message.decision_reason_type,
|
||||
decisionReason: message.decision_reason,
|
||||
message: message.message,
|
||||
});
|
||||
// A denial is final, so it replaces a pinned approval patch.
|
||||
if (this.toolCallFields.apply(denied, { replacePinnedContent: true })) {
|
||||
this.emit([{ sessionId: this.sessionId, update: denied }]);
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets the status of a tool call, such as a background task's when it ends. */
|
||||
toolCallStatus(toolCallId: string, status: "in_progress" | "paused" | ToolCallFinalStatus): void {
|
||||
if (FINAL_STATUSES.has(status)) this.unfinished.delete(toolCallId);
|
||||
this.send({ sessionUpdate: "tool_call_update", toolCallId, status } as v2.SessionUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replays the stored history of the session: its prompts, Claude's
|
||||
* messages and its tool calls, as the live session sent them. Subagent
|
||||
* transcripts are left out, as live. The client may hold the replayed
|
||||
* messages already, so each message is cleared before its first chunk.
|
||||
* `hooks` replays the subagents' own histories to their sessions.
|
||||
*
|
||||
* A replayed tool call that the history gives no result, such as one that
|
||||
* ran when Claude Code was killed, ends `failed` after the replay, as v2
|
||||
* asks of replayed work that no longer runs. The calls that the live
|
||||
* session holds open, and those in `live` (its background work), go on.
|
||||
* `running` rewrites the replayed results of that background work as live,
|
||||
* so that they do not end it (see `SessionTasks.replayedBackground`).
|
||||
*/
|
||||
replay(
|
||||
messages: SessionMessage[],
|
||||
hooks?: ReplayHooks,
|
||||
live: ReadonlySet<string> = new Set(),
|
||||
running?: (notification: AcpSessionNotification) => AcpSessionNotification,
|
||||
): void {
|
||||
this.replayCleared = new Set();
|
||||
this.replayHooks = hooks;
|
||||
const unfinished = (this.replayUnfinished = new Set());
|
||||
try {
|
||||
for (const message of messages) {
|
||||
if (message.parent_tool_use_id !== null || isReplayHiddenMetaMessage(message)) continue;
|
||||
// Claude Code's sign-in message, which the live session did not show either.
|
||||
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) continue;
|
||||
// A compaction's summary message replays as the completed compaction.
|
||||
// `emit` sends at once, before the call returns, so the update keeps
|
||||
// its place among the replayed messages.
|
||||
if (isCompactSummaryMessage(message) && message.uuid) {
|
||||
void replayCompactSummary(message, async (n) => this.emit([n]), {
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
hooks?.replaying?.(message);
|
||||
const inner = message.message as { id?: string; content?: unknown };
|
||||
if (message.type === "assistant" && Array.isArray(inner.content)) {
|
||||
this.content(inner.content as AssistantContent, "assistant", null, {
|
||||
messageId: inner.id ?? message.uuid,
|
||||
replay: true,
|
||||
});
|
||||
} else if (message.type === "user" && inner.content !== undefined) {
|
||||
// `null`: the message was command metadata alone.
|
||||
let content = stripLocalCommandMetadata(inner.content);
|
||||
if (content !== null && hooks?.userContent) content = hooks.userContent(message, content);
|
||||
if (content === null) continue;
|
||||
this.content(
|
||||
content as UserContent,
|
||||
"user",
|
||||
null,
|
||||
{ messageId: message.uuid, replay: true },
|
||||
running,
|
||||
);
|
||||
}
|
||||
hooks?.replayed(message);
|
||||
}
|
||||
} finally {
|
||||
this.replayCleared = undefined;
|
||||
this.replayToolUses = undefined;
|
||||
this.replayHooks = undefined;
|
||||
this.replayUnfinished = undefined;
|
||||
}
|
||||
for (const toolCallId of unfinished) {
|
||||
if (this.unfinished.has(toolCallId) || live.has(toolCallId)) continue;
|
||||
this.send({ sessionUpdate: "tool_call_update", toolCallId, status: "failed" });
|
||||
}
|
||||
}
|
||||
|
||||
/** Agent text that is not a model message, such as a local command's result. */
|
||||
agentMessage(text: string, messageId: string): void {
|
||||
this.content(text, "assistant", null, { messageId });
|
||||
}
|
||||
|
||||
/**
|
||||
* A user message that this session did not send, such as one typed in
|
||||
* another client over Remote Control: rendered as a replayed one is,
|
||||
* without its command metadata.
|
||||
*/
|
||||
userMessage(content: unknown, messageId: string): void {
|
||||
const shown = stripLocalCommandMetadata(content);
|
||||
if (shown !== null) this.content(shown as UserContent, "user", null, { messageId });
|
||||
}
|
||||
|
||||
/** Whether agent text went out since the last {@link resetAgentText}. */
|
||||
hasAgentText(): boolean {
|
||||
return this.agentText;
|
||||
}
|
||||
|
||||
resetAgentText(): void {
|
||||
this.agentText = false;
|
||||
}
|
||||
|
||||
private content(
|
||||
content: Parameters<typeof toAcpNotifications>[0],
|
||||
role: "assistant" | "user",
|
||||
parentToolUseId: string | null,
|
||||
options: {
|
||||
messageId?: string;
|
||||
toolUseResult?: unknown;
|
||||
toolResultMeta?: unknown;
|
||||
cancelled?: boolean;
|
||||
replay?: boolean;
|
||||
},
|
||||
transform: (notification: AcpSessionNotification) => AcpSessionNotification = (n) => n,
|
||||
): void {
|
||||
// A replay renders the history on its own: each
|
||||
// replayed tool call goes out whole, whatever the live session sent.
|
||||
const live = !options.replay;
|
||||
this.emit(
|
||||
toAcpNotifications(
|
||||
content,
|
||||
role,
|
||||
this.sessionId,
|
||||
live ? this.toolUseCache : (this.replayToolUses ??= {}),
|
||||
this.hookClient,
|
||||
this.logger,
|
||||
{
|
||||
clientCapabilities: V2_CLIENT_CAPABILITIES,
|
||||
toolCallCapabilities: V2_TOOL_CALL_CAPABILITIES,
|
||||
cwd: this.cwd,
|
||||
...(live
|
||||
? { emittedToolCalls: this.emittedToolCalls, toolCallFields: this.toolCallFields }
|
||||
: {}),
|
||||
taskState: this.taskState,
|
||||
parentToolUseId,
|
||||
// A replayed tool use runs no hook that could report its result.
|
||||
registerHooks: !options.replay,
|
||||
...options,
|
||||
},
|
||||
).map(transform),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gives each tool call that has no final status yet the final `status`,
|
||||
* except those in `keep` (background work, which goes on). For the end of
|
||||
* stopped work: v2 asks for a final status of its tool calls before the idle.
|
||||
* With `exitTerminals`, a command's terminal exits with its call, with no
|
||||
* exit code. Returns the ids of the calls it ended.
|
||||
*/
|
||||
finishUnfinished(
|
||||
status: "failed" | "cancelled",
|
||||
keep: ReadonlySet<string> = new Set(),
|
||||
{ exitTerminals = false }: { exitTerminals?: boolean } = {},
|
||||
): string[] {
|
||||
const finished: string[] = [];
|
||||
for (const toolCallId of [...this.unfinished]) {
|
||||
if (keep.has(toolCallId)) continue;
|
||||
this.unfinished.delete(toolCallId);
|
||||
finished.push(toolCallId);
|
||||
if (!exitTerminals) {
|
||||
this.send({ sessionUpdate: "tool_call_update", toolCallId, status });
|
||||
continue;
|
||||
}
|
||||
// The translator ignores `terminal_exit` for a call without a terminal.
|
||||
this.emit([
|
||||
{
|
||||
sessionId: this.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId,
|
||||
status: status === "cancelled" ? V2_CANCELLED : status,
|
||||
_meta: { terminal_exit: { terminal_id: toolCallId, exit_code: null, signal: null } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
return finished;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the open tool calls that will get no result, for the end of the work
|
||||
* (see `AcpToolCallRenderer.unfinished`), before its idle. After a cancel
|
||||
* (`stopped`), every open call ends `cancelled`. Otherwise only the calls
|
||||
* that Claude dropped before they ran end, `cancelled` too: a streamed
|
||||
* tool_use that no complete message holds. A call that runs, such as one in
|
||||
* the background, gets Claude Code's result. A command's report also ends
|
||||
* its terminal. The call is forgotten, so a late result changes nothing.
|
||||
* The calls in `keep` stay open, such as one whose task runs in the background.
|
||||
*/
|
||||
closeUnfinished(stopped: boolean, keep: ReadonlySet<string> = new Set()): void {
|
||||
const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES);
|
||||
for (const toolCallId of [...this.unfinished]) {
|
||||
if (keep.has(toolCallId)) continue;
|
||||
const ran = this.dispatched.has(toolCallId);
|
||||
if (ran && !stopped) continue;
|
||||
const report = renderer.unfinished({ toolCallId, ran, cancelled: stopped });
|
||||
const toolUse = this.toolUseCache[toolCallId];
|
||||
const terminalExit = toolUse && renderer.terminalExit(toolUse);
|
||||
if (terminalExit) report._meta = { ...report._meta, ...terminalExit };
|
||||
this.emittedToolCalls.delete(toolCallId);
|
||||
delete this.toolUseCache[toolCallId];
|
||||
this.toolCallFields.delete(toolCallId);
|
||||
this.emit([{ sessionId: this.sessionId, update: report }]);
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool calls that the client holds without a final status. */
|
||||
private readonly unfinished = new Set<string>();
|
||||
|
||||
/** Called with each live agent text chunk, for the session's title. */
|
||||
onAgentText: ((content: ContentBlock) => void) | undefined;
|
||||
|
||||
private emit(notifications: AcpSessionNotification[]): void {
|
||||
// A replay routes only what its hooks ask: the live routing tracks live calls.
|
||||
const route = this.replayCleared !== undefined ? this.replayHooks?.route : this.route;
|
||||
// Each update is routed once the ones before it went out: a route can
|
||||
// send updates of its own, such as a child's announcement or a
|
||||
// hand-back's two messages, which then keep their place.
|
||||
for (const original of notifications) {
|
||||
const notification = this.rewrite ? this.rewrite(original) : original;
|
||||
for (const { update } of route ? route(notification) : [notification]) this.sendOne(update);
|
||||
}
|
||||
}
|
||||
|
||||
private sendOne(update: AcpSessionNotification["update"]): void {
|
||||
const replaying = this.replayCleared !== undefined;
|
||||
if (update.sessionUpdate === "agent_message_chunk") {
|
||||
this.agentText = true;
|
||||
if (!replaying) this.onAgentText?.(update.content);
|
||||
}
|
||||
for (const v2Update of this.translator.updates(this.sessionId, update, replaying)) {
|
||||
const clear = this.replayCleared && replayClear(this.replayCleared, v2Update);
|
||||
if (clear) this.send(clear);
|
||||
if (v2Update.sessionUpdate === "tool_call_update") {
|
||||
const { toolCallId, status } = v2Update as { toolCallId: string; status?: string | null };
|
||||
if (status != null && replaying) {
|
||||
if (FINAL_STATUSES.has(status)) this.replayUnfinished?.delete(toolCallId);
|
||||
else this.replayUnfinished?.add(toolCallId);
|
||||
} else if (status != null) {
|
||||
if (FINAL_STATUSES.has(status)) {
|
||||
this.unfinished.delete(toolCallId);
|
||||
this.dispatched.delete(toolCallId);
|
||||
} else {
|
||||
this.unfinished.add(toolCallId);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.send(v2Update);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type ToolCallFinalStatus = "completed" | "failed" | "cancelled";
|
||||
|
||||
const FINAL_STATUSES = new Set<string>(["completed", "failed", "cancelled"]);
|
||||
615
src/v2/subagent-replay.ts
Normal file
615
src/v2/subagent-replay.ts
Normal file
@@ -0,0 +1,615 @@
|
||||
/**
|
||||
* The replay of a session's subagents, for `session/resume` with
|
||||
* `replayFrom`, as the v1 core replays them (`RfdSubagentReplay`).
|
||||
*
|
||||
* Claude Code keeps each subagent's history in its own transcript, and its
|
||||
* metadata file names the Agent or Task call that launched it (see
|
||||
* `subagentHistory`). The replay announces each child right after that call,
|
||||
* under its live session id (the agent id), with no capabilities: it cannot
|
||||
* run again in this process. Then it sends the child's `model` (of its first
|
||||
* response) and its history to the child session; a later response with
|
||||
* another model sends it again. As live, the parent is not shown the launch
|
||||
* call: its prompt is the parent's outgoing message, and a foreground child's
|
||||
* answer its reply. A child's successful SubagentHandback call is its message
|
||||
* to the parent instead. Unlike live, the replay does not tell runs apart, so
|
||||
* a child that handed back any report gets no reply from its launch's result.
|
||||
*
|
||||
* A SendMessage call whose message the replay delivered to a child before it
|
||||
* reached the call is that message too: the sender's outgoing view, at the
|
||||
* call, and the child's incoming one, in the child's history. A child's
|
||||
* history follows its launch, so a call to a child from its parent is always
|
||||
* delivered first. Any other SendMessage call stays a tool call.
|
||||
*
|
||||
* Every child's end goes out on its parent at the end of the replay, and once
|
||||
* on the child's own stream: v2 forbids replaying a child's past `running` as
|
||||
* current activity. These states are history to the client, so each child's
|
||||
* current state goes out again after the response ({@link afterResponse}).
|
||||
* A child that the live session still tracks, on a resume of the active
|
||||
* session, gets its current state from the resume snapshot instead
|
||||
* (`SessionTasks.currentState`). After the response, a child whose
|
||||
* configuration is not the `model` that the replay sent, such as a live child
|
||||
* whose hooks reported its mode, gets it again.
|
||||
*
|
||||
* A launch without metadata has no known id, so it is not announced: the RFD
|
||||
* forbids inventing one. Its Agent call stays an ordinary tool call of the
|
||||
* parent, and a notice at the end says that subagent history is missing, as
|
||||
* it does for a child whose transcript cannot be read.
|
||||
*/
|
||||
import * as fs from "node:fs";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SessionMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import type { AcpSessionNotification, SubagentState } from "../acp-subagents.js";
|
||||
import {
|
||||
childModelOption,
|
||||
HANDBACK_TOOL,
|
||||
handbackMessage,
|
||||
isNativeSubagentControlTool,
|
||||
launchReply,
|
||||
linkedSessionMessages,
|
||||
nativeSubagentState,
|
||||
responseModelEntry,
|
||||
rfdEndState,
|
||||
rfdLabels,
|
||||
type ModelEntry,
|
||||
type SubagentModels,
|
||||
type WorkState,
|
||||
} from "../native-subagents.js";
|
||||
import {
|
||||
readSubagentMessages,
|
||||
subagentHistory,
|
||||
subagentLaunchIds,
|
||||
type SubagentHistory,
|
||||
type SubagentRecord,
|
||||
} from "../subagent-history.js";
|
||||
import {
|
||||
deliveredCall,
|
||||
firstResponseModel,
|
||||
isDelivery,
|
||||
missingHistoryNotice,
|
||||
replaySubagentTerminalState,
|
||||
} from "../subagent-replay.js";
|
||||
import { taskNotificationsOf } from "../task-notifications.js";
|
||||
import type { V2UpdateTranslator } from "./update-translator.js";
|
||||
import { SessionOutput, type ReplayHooks } from "./stream.js";
|
||||
import { sameDirectory } from "./tasks.js";
|
||||
|
||||
/** What the replay needs of its root session. */
|
||||
export interface ReplayHost {
|
||||
readonly sessionId: string;
|
||||
readonly cwd: string;
|
||||
readonly logger: Logger;
|
||||
readonly translator: V2UpdateTranslator;
|
||||
/** Sends an update of the root session or one of its children. */
|
||||
send(notification: v2.UpdateSessionNotification): void;
|
||||
models(): SubagentModels;
|
||||
}
|
||||
|
||||
/** What the live session knows of the replayed children. */
|
||||
export interface LiveChildren {
|
||||
/** Whether the live session tracks the child session `sessionId`. */
|
||||
tracks(sessionId: string): boolean;
|
||||
/** The configuration that the client last got for the live child `sessionId`, if any. */
|
||||
configuration(sessionId: string): SessionConfigOption[] | undefined;
|
||||
}
|
||||
|
||||
const NO_LIVE_CHILDREN: LiveChildren = { tracks: () => false, configuration: () => undefined };
|
||||
|
||||
type ReplayChild = {
|
||||
launchId: string;
|
||||
sessionId: string;
|
||||
record: SubagentRecord;
|
||||
parentSessionId: string;
|
||||
input: Record<string, unknown>;
|
||||
/** How the launch's tool result ended a foreground child's run. */
|
||||
launchEnd?: "completed" | "failed" | "cancelled";
|
||||
/** The state of the child's last persisted task notification. */
|
||||
notified?: SubagentState;
|
||||
announced: boolean;
|
||||
promptSent: boolean;
|
||||
/** Whether it handed a report to its parent with a SubagentHandback call. */
|
||||
handedBack?: boolean;
|
||||
/** The `model` option entry that the replay sent. */
|
||||
model?: ModelEntry;
|
||||
/** The model id of its latest replayed response. */
|
||||
responseModel?: string;
|
||||
/** The `cwd` it was announced with, when not its parent's. */
|
||||
cwd?: string;
|
||||
output?: SessionOutput;
|
||||
};
|
||||
|
||||
type SendMessageCall = {
|
||||
toolUseId: string;
|
||||
to: string;
|
||||
message: string;
|
||||
senderSessionId: string;
|
||||
/** The child the replay delivered the message to. */
|
||||
recipientSessionId?: string;
|
||||
/** The replay reached the call before the delivery, so it stays a tool call. */
|
||||
shownAsToolCall: boolean;
|
||||
/** The sender's view of the message went out, at the call. */
|
||||
outgoingSent: boolean;
|
||||
};
|
||||
|
||||
export class SubagentReplay {
|
||||
/** The children, by the id of the call that launched each. */
|
||||
private readonly children = new Map<string, ReplayChild>();
|
||||
private readonly sendMessages: SendMessageCall[] = [];
|
||||
/** The SubagentHandback calls, with their report and whether their result failed. */
|
||||
private readonly handbacks = new Map<
|
||||
string,
|
||||
{ message: string; failed: boolean; sent: boolean }
|
||||
>();
|
||||
/** Launches without metadata, which the replay leaves out. */
|
||||
private readonly omitted = new Set<string>();
|
||||
/** Children whose transcript could not be read: only their association is replayed. */
|
||||
private withoutHistory = 0;
|
||||
private rootOutput: SessionOutput | undefined;
|
||||
|
||||
private constructor(
|
||||
private readonly host: ReplayHost,
|
||||
private readonly history: SubagentHistory,
|
||||
/** The transcript of each child, by agent id. */
|
||||
private readonly transcripts: Map<string, SessionMessage[]>,
|
||||
private readonly live: LiveChildren,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Reads the subagent history that `messages`, the session's transcript,
|
||||
* launched, with every child's transcript. Undefined when it launched none.
|
||||
*/
|
||||
static async load(
|
||||
host: ReplayHost,
|
||||
messages: SessionMessage[],
|
||||
live: LiveChildren = NO_LIVE_CHILDREN,
|
||||
): Promise<SubagentReplay | undefined> {
|
||||
if (!messages.some((message) => launchIdsOf(message).length > 0)) return undefined;
|
||||
const history = await subagentHistory(host.sessionId);
|
||||
const transcripts = new Map<string, SessionMessage[]>();
|
||||
const queue = messages.flatMap(launchIdsOf);
|
||||
while (queue.length > 0) {
|
||||
const record = history.agents.get(queue.shift()!);
|
||||
if (!record || transcripts.has(record.agentId)) continue;
|
||||
const transcript = (
|
||||
await readSubagentMessages(host.sessionId, record.agentId, history, host.logger)
|
||||
).map((message) => ({ ...message, parent_tool_use_id: null }));
|
||||
transcripts.set(record.agentId, transcript);
|
||||
queue.push(...transcript.flatMap(launchIdsOf));
|
||||
}
|
||||
return new SubagentReplay(host, history, transcripts, live);
|
||||
}
|
||||
|
||||
/** The hooks of the replay of the root session's history (`messages`). */
|
||||
hooks(output: SessionOutput, messages: SessionMessage[]): ReplayHooks {
|
||||
this.rootOutput = output;
|
||||
this.scan(messages, output.sessionId);
|
||||
return this.ownerHooks(output, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the replay: every child's end, on its parent, then once on its own
|
||||
* stream, and the notice when subagent history is missing.
|
||||
*/
|
||||
finish(): void {
|
||||
const announced = [...this.children.values()].filter((child) => child.announced);
|
||||
for (const child of [...announced].reverse()) {
|
||||
this.host.send({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
state: endState(child) as v2.StateUpdate,
|
||||
},
|
||||
});
|
||||
}
|
||||
for (const child of announced) {
|
||||
this.host.send({
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "state_update", ...endState(child) } as v2.SessionUpdate,
|
||||
});
|
||||
}
|
||||
const missing = this.omitted.size + this.withoutHistory;
|
||||
if (missing > 0) {
|
||||
this.rootOutput?.emitV1([
|
||||
{ sessionId: this.host.sessionId, update: missingHistoryNotice(missing) },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After the response, as the v1 core sends them (`currentStates`): the
|
||||
* current state of each replayed child that the live session does not
|
||||
* track, since its process is gone, so its replayed end. A `cwd` that is
|
||||
* gone, such as a worktree that Claude Code removed, is cleared. Then each
|
||||
* replayed child's configuration, when it is not the `model` that the
|
||||
* replay sent: the live child's, else the model of its latest response, by
|
||||
* the session's model list.
|
||||
*/
|
||||
afterResponse(): void {
|
||||
for (const child of this.children.values()) {
|
||||
if (!child.announced) continue;
|
||||
if (!this.live.tracks(child.sessionId)) this.sendEndState(child);
|
||||
this.sendConfiguration(child);
|
||||
}
|
||||
}
|
||||
|
||||
private sendConfiguration(child: ReplayChild): void {
|
||||
const entry = responseModelEntry(child.responseModel, this.host.models());
|
||||
const current =
|
||||
this.live.configuration(child.sessionId) ?? (entry ? [childModelOption(entry)] : undefined);
|
||||
const replayed = child.model ? JSON.stringify([childModelOption(child.model)]) : undefined;
|
||||
if (!current || JSON.stringify(current) === replayed) return;
|
||||
child.output!.emitV1([
|
||||
{
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "config_option_update", configOptions: current },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private sendEndState(child: ReplayChild): void {
|
||||
const state = endState(child);
|
||||
this.host.send({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(child.cwd && !isDirectory(child.cwd) ? { cwd: null } : {}),
|
||||
state: state as v2.StateUpdate,
|
||||
},
|
||||
});
|
||||
this.host.send({
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "state_update", ...state } as v2.SessionUpdate,
|
||||
});
|
||||
}
|
||||
|
||||
private ownerHooks(output: SessionOutput, owner: ReplayChild | undefined): ReplayHooks {
|
||||
return {
|
||||
route: (notification) => this.route(notification, output, owner),
|
||||
replaying: (message) => this.replaying(message, owner),
|
||||
replayed: (message) => this.replayed(message, output),
|
||||
...(owner
|
||||
? { userContent: (message, content) => this.childContent(owner, message, content) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the launches, the launch results, the SendMessage calls and the
|
||||
* SubagentHandback calls in `messages`, of the session `ownerSessionId`.
|
||||
*/
|
||||
private scan(messages: readonly SessionMessage[], ownerSessionId: string): void {
|
||||
for (const message of messages) {
|
||||
for (const block of contentBlocks(message)) {
|
||||
if (block.type === "tool_use" && typeof block.id === "string") {
|
||||
if (isNativeSubagentControlTool(block.name)) {
|
||||
this.registerLaunch(block.id, inputOf(block), ownerSessionId);
|
||||
} else if (block.name === HANDBACK_TOOL) {
|
||||
const report = handbackMessage(block.input);
|
||||
if (report)
|
||||
this.handbacks.set(block.id, { message: report, failed: false, sent: false });
|
||||
} else if (block.name === "SendMessage") {
|
||||
const input = inputOf(block);
|
||||
if (typeof input.to === "string" && typeof input.message === "string") {
|
||||
this.sendMessages.push({
|
||||
toolUseId: block.id,
|
||||
to: input.to,
|
||||
message: input.message,
|
||||
senderSessionId: ownerSessionId,
|
||||
shownAsToolCall: false,
|
||||
outgoingSent: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (block.type === "tool_result" && typeof block.tool_use_id === "string") {
|
||||
const handback = this.handbacks.get(block.tool_use_id);
|
||||
if (handback && block.is_error === true) handback.failed = true;
|
||||
const child = this.children.get(block.tool_use_id);
|
||||
// A background launch answers at once; its result is not the end.
|
||||
if (child && !child.record.background)
|
||||
child.launchEnd = replaySubagentTerminalState(block);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private registerLaunch(
|
||||
launchId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentSessionId: string,
|
||||
): void {
|
||||
if (this.children.has(launchId)) return;
|
||||
const record = this.history.agents.get(launchId);
|
||||
if (!record) {
|
||||
this.omitted.add(launchId);
|
||||
return;
|
||||
}
|
||||
this.children.set(launchId, {
|
||||
launchId,
|
||||
sessionId: record.agentId,
|
||||
record,
|
||||
parentSessionId,
|
||||
input,
|
||||
announced: false,
|
||||
promptSent: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A replayed update of a tool call of `output`, the session of `owner`
|
||||
* when it is a child's. The client is not shown the launch of a replayed
|
||||
* child, a SendMessage call whose message the replay delivered, or a
|
||||
* successful SubagentHandback call: a foreground launch's completed result
|
||||
* goes out as the child's reply, a delivered SendMessage as the sender's
|
||||
* outgoing message, and a hand-back as the child's message to its parent.
|
||||
*/
|
||||
private route(
|
||||
notification: AcpSessionNotification,
|
||||
output: SessionOutput,
|
||||
owner: ReplayChild | undefined,
|
||||
): AcpSessionNotification[] {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
return [notification];
|
||||
}
|
||||
const sendMessage = this.sendMessages.find((call) => call.toolUseId === update.toolCallId);
|
||||
if (sendMessage) {
|
||||
if (sendMessage.recipientSessionId === undefined) {
|
||||
sendMessage.shownAsToolCall = true;
|
||||
return [notification];
|
||||
}
|
||||
if (sendMessage.outgoingSent) return [];
|
||||
sendMessage.outgoingSent = true;
|
||||
const [outgoing] = linkedSessionMessages(
|
||||
sendMessage.toolUseId,
|
||||
sendMessage.senderSessionId,
|
||||
sendMessage.recipientSessionId,
|
||||
sendMessage.message,
|
||||
);
|
||||
return [outgoing!];
|
||||
}
|
||||
const handback = owner ? this.handbacks.get(update.toolCallId) : undefined;
|
||||
if (owner && handback && !handback.failed) {
|
||||
if (handback.sent) return [];
|
||||
handback.sent = true;
|
||||
owner.handedBack = true;
|
||||
const [outgoing, incoming] = linkedSessionMessages(
|
||||
update.toolCallId,
|
||||
owner.sessionId,
|
||||
owner.parentSessionId,
|
||||
handback.message,
|
||||
);
|
||||
// Sent here, in order: an output routes each update once the ones before it went out.
|
||||
output.emitV1([outgoing!]);
|
||||
this.outputOf(owner.parentSessionId)?.emitV1([incoming!]);
|
||||
return [];
|
||||
}
|
||||
const child = this.children.get(update.toolCallId);
|
||||
if (!child) return [notification];
|
||||
if (
|
||||
update.status === "completed" &&
|
||||
child.announced &&
|
||||
!child.record.background &&
|
||||
!child.handedBack
|
||||
) {
|
||||
const reply = launchReply(child.launchId, child.sessionId, child.parentSessionId, update);
|
||||
if (reply) return [reply];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Before a message of `owner`'s history (the root's when undefined): a
|
||||
* child's response with another model sends its `model` again, and a
|
||||
* persisted task notification of a child is the state its work ended in.
|
||||
*/
|
||||
private replaying(message: SessionMessage, owner: ReplayChild | undefined): void {
|
||||
const inner = message.message as { model?: unknown; content?: unknown } | undefined;
|
||||
if (message.type === "assistant") {
|
||||
if (owner) this.sendModel(owner, typeof inner?.model === "string" ? inner.model : undefined);
|
||||
return;
|
||||
}
|
||||
for (const notification of taskNotificationsOf(inner?.content)) {
|
||||
const child = [...this.children.values()].find((c) => c.sessionId === notification.task_id);
|
||||
if (child) child.notified = nativeSubagentState(notification.status) ?? child.notified;
|
||||
}
|
||||
}
|
||||
|
||||
/** After a message that launched children: each is announced, and its history replays. */
|
||||
private replayed(message: SessionMessage, parent: SessionOutput): void {
|
||||
if (message.type !== "assistant") return;
|
||||
for (const launchId of launchIdsOf(message)) {
|
||||
const child = this.children.get(launchId);
|
||||
if (!child || child.announced) continue;
|
||||
const messages = this.transcripts.get(child.sessionId) ?? [];
|
||||
if (messages.length === 0) this.withoutHistory++;
|
||||
this.announce(child);
|
||||
this.sendModel(child, firstResponseModel(messages));
|
||||
this.scan(messages, child.sessionId);
|
||||
child.output!.replay(messages, this.ownerHooks(child.output!, child));
|
||||
if (!child.promptSent && typeof child.input.prompt === "string") {
|
||||
this.sendPrompt(child, parent, child.input.prompt);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private announce(child: ReplayChild): void {
|
||||
const labels = rfdLabels(
|
||||
child.record.name ?? child.input.name,
|
||||
child.record.agentType ?? child.input.subagent_type,
|
||||
child.record.description ?? child.input.description,
|
||||
);
|
||||
const parentCwd = this.cwdOf(child.parentSessionId);
|
||||
child.cwd =
|
||||
child.record.cwd && !sameDirectory(child.record.cwd, parentCwd)
|
||||
? child.record.cwd
|
||||
: undefined;
|
||||
child.announced = true;
|
||||
this.host.send({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(labels.title ? { title: labels.title } : {}),
|
||||
...(labels.description ? { description: labels.description } : {}),
|
||||
...(child.cwd ? { cwd: child.cwd } : {}),
|
||||
state: { state: "running" },
|
||||
},
|
||||
});
|
||||
child.output = new SessionOutput(
|
||||
child.sessionId,
|
||||
child.cwd ?? parentCwd,
|
||||
this.host.translator,
|
||||
(update) => this.host.send({ sessionId: child.sessionId, update }),
|
||||
this.host.logger,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/** The `model` of a response of `child`, when it is not the one the client holds. */
|
||||
private sendModel(child: ReplayChild, model: string | undefined): void {
|
||||
const entry = responseModelEntry(model, this.host.models());
|
||||
if (!entry || !child.output) return;
|
||||
child.responseModel = model;
|
||||
if (child.model?.value === entry.value && child.model.name === entry.name) return;
|
||||
child.model = entry;
|
||||
child.output.emitV1([
|
||||
{
|
||||
sessionId: child.sessionId,
|
||||
update: { sessionUpdate: "config_option_update", configOptions: [childModelOption(entry)] },
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* The rest of a child's user message to replay, or `null` when nothing is
|
||||
* left, after its text went out as the child's incoming `session_message`.
|
||||
* The first text is the prompt of the launch. A message that a SendMessage
|
||||
* call delivered (`origin.kind: "coordinator"`) is another incoming
|
||||
* message. Any other text is Claude Code's, such as "[Request interrupted
|
||||
* by user]", which the live child session does not show. Tool results stay.
|
||||
*/
|
||||
private childContent(child: ReplayChild, message: SessionMessage, content: unknown): unknown {
|
||||
const text = textOf(content);
|
||||
if (text.trim()) {
|
||||
if (isDelivery(message)) this.sendDelivery(child, message, text);
|
||||
else if (!child.promptSent) {
|
||||
const parent = this.outputOf(child.parentSessionId);
|
||||
if (parent) this.sendPrompt(child, parent, text);
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(content)) return null;
|
||||
const rest = content.filter(
|
||||
(block) => !(typeof block === "object" && block !== null && block.type === "text"),
|
||||
);
|
||||
return rest.length > 0 ? rest : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A message that a SendMessage call delivered to `child` (see
|
||||
* `deliveredCall`). With the call, the message has the call's id and text
|
||||
* and its sender, as live, and, unless the replay already showed the call
|
||||
* as a tool call, it replaces the call: the sender's view goes out at the
|
||||
* call (see {@link route}). Without the call, it has its transcript id and
|
||||
* the delivered text.
|
||||
*/
|
||||
private sendDelivery(child: ReplayChild, message: SessionMessage, text: string): void {
|
||||
const call = deliveredCall(
|
||||
this.sendMessages,
|
||||
[child.sessionId, child.record.name, child.input.name],
|
||||
text,
|
||||
);
|
||||
if (call) call.recipientSessionId = child.sessionId;
|
||||
const messageId = call?.toolUseId ?? message.uuid;
|
||||
const linked = call !== undefined && !call.shownAsToolCall;
|
||||
child.output!.emitV1([
|
||||
{
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId,
|
||||
...(call ? { senderSessionId: call.senderSessionId } : {}),
|
||||
recipientSessionId: child.sessionId,
|
||||
...(linked ? { senderMessageId: messageId } : {}),
|
||||
content: [{ type: "text", text: call?.message ?? text }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private sendPrompt(child: ReplayChild, parent: SessionOutput, text: string): void {
|
||||
child.promptSent = true;
|
||||
const [outgoing, incoming] = linkedSessionMessages(
|
||||
child.launchId,
|
||||
child.parentSessionId,
|
||||
child.sessionId,
|
||||
text,
|
||||
);
|
||||
parent.emitV1([outgoing!]);
|
||||
child.output!.emitV1([incoming!]);
|
||||
}
|
||||
|
||||
private outputOf(sessionId: string): SessionOutput | undefined {
|
||||
if (sessionId === this.host.sessionId) return this.rootOutput;
|
||||
return [...this.children.values()].find((child) => child.sessionId === sessionId)?.output;
|
||||
}
|
||||
|
||||
private cwdOf(sessionId: string): string {
|
||||
const child = [...this.children.values()].find((c) => c.sessionId === sessionId);
|
||||
if (!child) return this.host.cwd;
|
||||
return child.cwd ?? this.cwdOf(child.parentSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How a replayed child's work ended: the state of its last task
|
||||
* notification, else a stop by the user, else its foreground launch's result.
|
||||
*/
|
||||
function endState(child: ReplayChild): WorkState {
|
||||
const end =
|
||||
child.notified ??
|
||||
nativeSubagentState(child.record.stoppedByUser ? "cancelled" : child.launchEnd);
|
||||
return end ? rfdEndState(end, undefined) : { state: "idle" };
|
||||
}
|
||||
|
||||
function launchIdsOf(message: SessionMessage): string[] {
|
||||
if (message.type !== "assistant") return [];
|
||||
return subagentLaunchIds((message.message as { content?: unknown } | undefined)?.content);
|
||||
}
|
||||
|
||||
function contentBlocks(message: SessionMessage): Record<string, unknown>[] {
|
||||
const content = (message as { message?: { content?: unknown } }).message?.content;
|
||||
return Array.isArray(content)
|
||||
? content.filter(
|
||||
(block): block is Record<string, unknown> => typeof block === "object" && block !== null,
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
function inputOf(block: Record<string, unknown>): Record<string, unknown> {
|
||||
return typeof block.input === "object" && block.input !== null
|
||||
? (block.input as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/** The text of user message content: a string, or its text blocks. */
|
||||
function textOf(content: unknown): string {
|
||||
if (typeof content === "string") return content;
|
||||
if (!Array.isArray(content)) return "";
|
||||
return content
|
||||
.flatMap((block) =>
|
||||
typeof block === "object" && block !== null && block.type === "text" ? [block.text] : [],
|
||||
)
|
||||
.filter((text): text is string => typeof text === "string")
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isDirectory(dir: string): boolean {
|
||||
try {
|
||||
return fs.statSync(dir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
127
src/v2/subscription-guard.ts
Normal file
127
src/v2/subscription-guard.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* The `--hide-claude-auth` subscription guard on the v2 core: this
|
||||
* integration never bills a claude.ai subscription. The policy is the v1
|
||||
* core's (`hide-claude-auth.ts`):
|
||||
*
|
||||
* - It holds while the flag is on and no provider or gateway login routes
|
||||
* the traffic away from the CLI's own login.
|
||||
* - A session does not start on an account that a subscription would pay
|
||||
* for (`authRequired` with the `claude_subscription_not_supported`
|
||||
* reason), or that holds no credential this integration accepts
|
||||
* (`authRequired`).
|
||||
* - Each prompt reads the account that the query cached at its start, and is
|
||||
* refused, before it is inserted, on the same terms.
|
||||
* - The cached account goes stale when the user signs out or logs in
|
||||
* elsewhere. A turn that fails for a missing login, or an auth status
|
||||
* probe that finds another kind of identity than the session started on,
|
||||
* restarts the query at the next prompt that finds the session idle (see
|
||||
* `restarts.ts`), so its start judges the current account.
|
||||
*
|
||||
* The `/remote-control` command that the adapter runs is not guarded.
|
||||
*/
|
||||
import type { AccountInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import { type AuthStatusKind, fromAccountInfo } from "../auth-status.js";
|
||||
import {
|
||||
billsClaudeSubscription,
|
||||
claudeLoginRequiredError,
|
||||
type ClaudeSubscriptionGuardState,
|
||||
claudeSubscriptionNotSupportedError,
|
||||
holdsNonSubscriptionCredential,
|
||||
refuseClaudeSubscriptionTurn,
|
||||
shouldHideClaudeAuth,
|
||||
warnClaudeSubscriptionGuardDegraded,
|
||||
} from "../hide-claude-auth.js";
|
||||
|
||||
/** What the guard needs of the connection's auth state (`V2Auth`). */
|
||||
export interface GuardAuth {
|
||||
/** Whether a provider or a gateway login routes the traffic. */
|
||||
overridesRoute(): boolean;
|
||||
/** Reports the account a session's query cached, as the identity. */
|
||||
publishAccount(sessionId: string, account: AccountInfo): void;
|
||||
}
|
||||
|
||||
type GuardedSession = {
|
||||
state: ClaudeSubscriptionGuardState;
|
||||
/** The kind of identity the session's query started on, if its account told. */
|
||||
kind: AuthStatusKind | undefined;
|
||||
};
|
||||
|
||||
export class SubscriptionGuard {
|
||||
private readonly sessions = new Map<string, GuardedSession>();
|
||||
|
||||
constructor(
|
||||
private readonly auth: GuardAuth,
|
||||
private readonly logger: Logger,
|
||||
/** The session's account may have changed: restart it at its next prompt. */
|
||||
private readonly accountMayHaveChanged: (sessionId: string) => void,
|
||||
) {}
|
||||
|
||||
/** Whether the guard holds now (see the module doc). */
|
||||
active(): boolean {
|
||||
return shouldHideClaudeAuth() && !this.auth.overridesRoute();
|
||||
}
|
||||
|
||||
/**
|
||||
* A session's query started on `account`. Throws the refusal when the
|
||||
* guard holds and the account is one it refuses.
|
||||
*/
|
||||
sessionStarted(sessionId: string, account: AccountInfo | undefined, apiKeyHelper?: string): void {
|
||||
const session: GuardedSession = {
|
||||
state: {},
|
||||
kind: fromAccountInfo(account, apiKeyHelper)?.kind,
|
||||
};
|
||||
if (this.active()) {
|
||||
if (!account) {
|
||||
warnClaudeSubscriptionGuardDegraded({
|
||||
sessionId,
|
||||
guardState: session.state,
|
||||
logger: this.logger,
|
||||
cause: "the CLI reported no account at initialize",
|
||||
});
|
||||
} else if (billsClaudeSubscription(account)) {
|
||||
throw claudeSubscriptionNotSupportedError();
|
||||
} else if (!holdsNonSubscriptionCredential(account)) {
|
||||
throw claudeLoginRequiredError();
|
||||
}
|
||||
}
|
||||
this.sessions.set(sessionId, session);
|
||||
}
|
||||
|
||||
/**
|
||||
* A prompt of `sessionId` is about to go to `query`: rejects with the
|
||||
* refusal when the guard holds and refuses the cached account. `undefined`
|
||||
* while the guard is off, so the prompt goes on in the same call.
|
||||
*/
|
||||
beforePrompt(sessionId: string, query: Pick<Query, "accountInfo">): Promise<void> | undefined {
|
||||
if (!this.active()) return undefined;
|
||||
let session = this.sessions.get(sessionId);
|
||||
if (!session) this.sessions.set(sessionId, (session = { state: {}, kind: undefined }));
|
||||
return refuseClaudeSubscriptionTurn({
|
||||
sessionId,
|
||||
query,
|
||||
guardState: session.state,
|
||||
logger: this.logger,
|
||||
// The guard reads the account anyway: it keeps the identity current.
|
||||
onAccount: (account) => this.auth.publishAccount(sessionId, account),
|
||||
});
|
||||
}
|
||||
|
||||
/** A turn of `sessionId` failed for a missing login: the user signed out. */
|
||||
signedOut(sessionId: string): void {
|
||||
if (shouldHideClaudeAuth()) this.accountMayHaveChanged(sessionId);
|
||||
}
|
||||
|
||||
/** An auth status probe read `kind`: a session started on another kind restarts. */
|
||||
probed(kind: AuthStatusKind): void {
|
||||
if (!this.active()) return;
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
// No kind: the account said nothing that this read could contradict.
|
||||
if (session.kind && session.kind !== kind) this.accountMayHaveChanged(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
sessionEnded(sessionId: string): void {
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
2079
src/v2/tasks.ts
Normal file
2079
src/v2/tasks.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -56,7 +56,15 @@ export class V2Terminals {
|
||||
): { terminal: v2.SessionUpdate[]; report: V1ToolCallReport | undefined } {
|
||||
const meta = (report._meta ?? {}) as TerminalMeta;
|
||||
const key = `${sessionId} ${report.toolCallId}`;
|
||||
if (meta.terminal_info) this.running.set(key, { terminalId: meta.terminal_info.terminal_id });
|
||||
// A replay of a command that still runs, such as one in the background,
|
||||
// names its terminal again: the running entry keeps its command and
|
||||
// `cancellable`.
|
||||
if (
|
||||
meta.terminal_info &&
|
||||
this.running.get(key)?.terminalId !== meta.terminal_info.terminal_id
|
||||
) {
|
||||
this.running.set(key, { terminalId: meta.terminal_info.terminal_id });
|
||||
}
|
||||
const terminal: v2.SessionUpdate[] = [];
|
||||
const running = this.running.get(key);
|
||||
if (running) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/**
|
||||
* The v2 form of the tool call reports that `ClaudeAcpAgent` sends as v1
|
||||
* `tool_call` and `tool_call_update` updates, and in permission requests.
|
||||
* The v2 form of the tool call reports that the shared SDK-to-ACP mapping
|
||||
* produces as v1 `tool_call` and `tool_call_update` updates, and of the tool
|
||||
* calls of permission requests.
|
||||
*/
|
||||
import type { ToolCall, ToolCallContent, ToolCallUpdate } from "@agentclientprotocol/sdk/unstable";
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
|
||||
106
src/v2/update-translator.ts
Normal file
106
src/v2/update-translator.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Turns the v1 session updates that the shared SDK-to-ACP mapping produces
|
||||
* (`toAcpNotifications`) into the v2 updates of one connection.
|
||||
*
|
||||
* The v2 core uses it for every update that it maps from
|
||||
* the SDK stream. It holds the per-connection state that the translation
|
||||
* needs: the display terminals of commands and the `_meta` the client stores
|
||||
* for each tool call.
|
||||
*/
|
||||
import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { v2SessionUpdate } from "./session-update.js";
|
||||
import { V2Terminals } from "./terminal.js";
|
||||
import { V2ToolCallMeta } from "./tool-call-meta.js";
|
||||
import { v2RestartedToolCall } from "./tool-call.js";
|
||||
|
||||
export class V2UpdateTranslator {
|
||||
/** The display terminals of the commands that the agent runs. */
|
||||
readonly terminals = new V2Terminals();
|
||||
|
||||
/** The `_meta` that the client stores for each tool call. */
|
||||
readonly toolCallMeta = new V2ToolCallMeta();
|
||||
|
||||
/**
|
||||
* The v2 updates of a v1 update: for a tool call report, the updates of its
|
||||
* terminal first (see {@link V2Terminals}), then the report itself, with the
|
||||
* `_meta` keys that it changes (see {@link V2ToolCallMeta}), and during a
|
||||
* replay the `_meta` of a tool call that the report cleared.
|
||||
*/
|
||||
updates(
|
||||
sessionId: string,
|
||||
update: AcpSessionNotification["update"],
|
||||
replaying = false,
|
||||
): v2.SessionUpdate[] {
|
||||
if (update.sessionUpdate !== "tool_call" && update.sessionUpdate !== "tool_call_update") {
|
||||
const v2Update = v2SessionUpdate(update);
|
||||
return v2Update ? [v2Update] : [];
|
||||
}
|
||||
const { terminal, report } = this.terminals.split(sessionId, update);
|
||||
if (!report) return terminal;
|
||||
const starts = report.sessionUpdate === "tool_call";
|
||||
// A v1 `tool_call` starts the tool call afresh, but v2 only has updates,
|
||||
// which keep each field they leave out. The client may already hold this
|
||||
// id: from an earlier report on this connection, or, during a replay, from
|
||||
// before it (a client that resumes a session it showed before).
|
||||
const restarts =
|
||||
starts && (replaying || this.toolCallMeta.reported(sessionId, report.toolCallId));
|
||||
const [reported, metaPatch] = this.toolCallMeta.apply(sessionId, report, starts, replaying);
|
||||
const reportUpdate = restarts ? v2RestartedToolCall(reported) : v2SessionUpdate(reported);
|
||||
const metaUpdate = metaPatch
|
||||
? v2SessionUpdate({ sessionUpdate: "tool_call_update", ...metaPatch })
|
||||
: undefined;
|
||||
return [
|
||||
...terminal,
|
||||
...(reportUpdate ? [reportUpdate] : []),
|
||||
...(metaUpdate ? [metaUpdate] : []),
|
||||
];
|
||||
}
|
||||
|
||||
/** Forgets the terminals and tool calls of a session, when it closes. */
|
||||
forget(sessionId: string): void {
|
||||
this.terminals.forget(sessionId);
|
||||
this.toolCallMeta.forget(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The update that clears the message of a replayed chunk, when it is the
|
||||
* first replayed chunk of that message: a v2 client may still hold a message
|
||||
* that a replay sends again, and chunks append to it. `cleared` holds the
|
||||
* messages that the replay has cleared so far, and gains this one.
|
||||
*/
|
||||
export function replayClear(
|
||||
cleared: Set<string>,
|
||||
update: v2.SessionUpdate,
|
||||
): v2.SessionUpdate | undefined {
|
||||
const kind = MESSAGE_OF_CHUNK.get(update.sessionUpdate);
|
||||
if (!kind) return undefined;
|
||||
const { messageId } = update as { messageId: string };
|
||||
const key = `${kind} ${messageId}`;
|
||||
if (cleared.has(key)) return undefined;
|
||||
cleared.add(key);
|
||||
return emptyMessage(kind, messageId);
|
||||
}
|
||||
|
||||
/** The message update of each message chunk. */
|
||||
const MESSAGE_OF_CHUNK = new Map<string, "user_message" | "agent_message" | "agent_thought">([
|
||||
["user_message_chunk", "user_message"],
|
||||
["agent_message_chunk", "agent_message"],
|
||||
["agent_thought_chunk", "agent_thought"],
|
||||
]);
|
||||
|
||||
function emptyMessage(
|
||||
kind: "user_message" | "agent_message" | "agent_thought",
|
||||
messageId: string,
|
||||
): v2.SessionUpdate {
|
||||
const message = { messageId, content: [] };
|
||||
switch (kind) {
|
||||
case "user_message":
|
||||
return { ...message, sessionUpdate: kind };
|
||||
case "agent_message":
|
||||
return { ...message, sessionUpdate: kind };
|
||||
default:
|
||||
return { ...message, sessionUpdate: kind };
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,9 @@
|
||||
* some progress reports and none on others. Entries that don't match it are
|
||||
* skipped, so a change in the CLI drops the list instead of the card.
|
||||
*/
|
||||
import type { ToolCallContent } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { TaskProgressFields } from "./async-tasks.js";
|
||||
import { textContent } from "./tool-calls/content.js";
|
||||
|
||||
export type WorkflowPhase = { index: number; title: string };
|
||||
|
||||
@@ -27,6 +30,44 @@ export type WorkflowAgent = {
|
||||
|
||||
export type WorkflowSnapshot = { phases: WorkflowPhase[]; agents: WorkflowAgent[] };
|
||||
|
||||
/** What a workflow card shows: the task's progress fields and its latest phase and agent list. */
|
||||
export type WorkflowCardProgress = TaskProgressFields & { workflow?: WorkflowSnapshot };
|
||||
|
||||
/**
|
||||
* The latest workflow activity, as the title and the one replaceable body of
|
||||
* the workflow's tool call. Undefined when no progress came. For example, the
|
||||
* title `Workflow: Probe: probe` and the body:
|
||||
*
|
||||
* ```
|
||||
* Run a probe
|
||||
*
|
||||
* Last activity: probe
|
||||
*
|
||||
* 4,003 tokens · 2 tool calls · 1s
|
||||
* ```
|
||||
*/
|
||||
export function workflowCard(
|
||||
progress: WorkflowCardProgress | undefined,
|
||||
): { title: string; content: ToolCallContent[] } | undefined {
|
||||
if (!progress || Object.keys(progress).length === 0) return undefined;
|
||||
const activity = progress.description ?? progress.summary;
|
||||
const usage = progress.usage;
|
||||
const text = [
|
||||
progress.summary ?? progress.description,
|
||||
progress.lastToolName ? `Last activity: ${progress.lastToolName}` : undefined,
|
||||
usage
|
||||
? `${usage.totalTokens.toLocaleString("en-US")} tokens · ${usage.toolUses.toLocaleString("en-US")} tool calls · ${Math.floor(usage.durationMs / 1000)}s`
|
||||
: undefined,
|
||||
progress.workflow ? workflowSnapshotText(progress.workflow) : undefined,
|
||||
]
|
||||
.filter((part) => part !== undefined)
|
||||
.join("\n\n");
|
||||
return {
|
||||
title: activity ? `Workflow: ${activity}` : "Workflow",
|
||||
content: [textContent(text)],
|
||||
};
|
||||
}
|
||||
|
||||
/** Reads a `workflow_progress` value. Undefined when it holds no phase or agent. */
|
||||
export function workflowSnapshot(value: unknown): WorkflowSnapshot | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
|
||||
@@ -4,19 +4,26 @@ import { defineConfig } from "vitest/config";
|
||||
const usesRealCli =
|
||||
Boolean(process.env.RUN_INTEGRATION_TESTS) || process.env.RUN_LIVE_CAPABILITY_AUDIT === "true";
|
||||
|
||||
// The agent runs `claude auth status` in the background on every
|
||||
// initialize and prompt. Pointing it at a CLI that exits at once keeps
|
||||
// tests off the machine's real CLI and Claude config, and stops a run
|
||||
// from outliving its test (it wrote into temp config dirs being deleted).
|
||||
const env: Record<string, string> = usesRealCli ? {} : { CLAUDE_CODE_EXECUTABLE: "/usr/bin/false" };
|
||||
|
||||
export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
test: {
|
||||
watch: false,
|
||||
globals: true,
|
||||
environment: "node",
|
||||
// The agent runs `claude auth status` in the background on every
|
||||
// initialize and prompt. Pointing it at a CLI that exits at once keeps
|
||||
// tests off the machine's real CLI and Claude config, and stops a run
|
||||
// from outliving its test (it wrote into temp config dirs being deleted).
|
||||
env: usesRealCli ? {} : { CLAUDE_CODE_EXECUTABLE: "/usr/bin/false" },
|
||||
// Applies to real-CLI runs too: the gateway listing runs in the test process,
|
||||
// and tests point `providers/set` at fake URLs like gateway.example.com.
|
||||
setupFiles: ["src/tests/setup-no-network.ts"],
|
||||
env,
|
||||
include: [
|
||||
"src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}",
|
||||
// Release tooling lives in scripts/ rather than src/ so it stays outside tsc's
|
||||
@@ -28,9 +35,4 @@ export default defineConfig({
|
||||
reporter: ["text", "json", "html"],
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": "/src",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user