mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-10 03:12:09 +00:00
Compare commits
89 Commits
main
...
refactor/l
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ccf9432be | ||
|
|
6950f3f3fb | ||
|
|
fcf2a636cb | ||
|
|
231178127a | ||
|
|
071b2e8098 | ||
|
|
e42cc82304 | ||
|
|
d8d8d3b6dd | ||
|
|
885e765662 | ||
|
|
4be19db524 | ||
|
|
8c53cfa2be | ||
|
|
9a47567e59 | ||
|
|
f541c267f2 | ||
|
|
6c709a98d7 | ||
|
|
89cdd12e29 | ||
|
|
dc24f8a7bc | ||
|
|
05ea8b2a5b | ||
|
|
cf902892d3 | ||
|
|
51a9cbc480 | ||
|
|
21f8666709 | ||
|
|
600fb6f15d | ||
|
|
da70e3031a | ||
|
|
bc476f6a2f | ||
|
|
a031eabe10 | ||
|
|
1607023bdf | ||
|
|
854a347bf0 | ||
|
|
32d530a3dc | ||
|
|
c66464eace | ||
|
|
6db2e6ae20 | ||
|
|
1bae40d6cc | ||
|
|
7af0804b8a | ||
|
|
97b77d7cf7 | ||
|
|
dbe8a3fce8 | ||
|
|
3dc4582b95 | ||
|
|
d31f4a9306 | ||
|
|
f0e99ea5a0 | ||
|
|
c9c3f092d2 | ||
|
|
400e531404 | ||
|
|
8af9df957c | ||
|
|
1cf208b68b | ||
|
|
3f0beb033b | ||
|
|
4a79f0487a | ||
|
|
aa13302252 | ||
|
|
71792d29a9 | ||
|
|
c99d3a1e59 | ||
|
|
92e695d1cc | ||
|
|
e3c5a8af96 | ||
|
|
afe4434be7 | ||
|
|
05a827c69f | ||
|
|
acf21d2468 | ||
|
|
0dc4b370e0 | ||
|
|
a14c72b080 | ||
|
|
4f0780584d | ||
|
|
01fe4b00d7 | ||
|
|
f90e379f6c | ||
|
|
51ab74538c | ||
|
|
4dc6e656a6 | ||
|
|
39244f7952 | ||
|
|
911ba98f14 | ||
|
|
9a15f4889a | ||
|
|
1a56268aae | ||
|
|
ce892a59ae | ||
|
|
19f42bfb96 | ||
|
|
b658390078 | ||
|
|
d649d2c423 | ||
|
|
529531f726 | ||
|
|
ab2ff616a8 | ||
|
|
e83770c609 | ||
|
|
6494eaa95a | ||
|
|
9bf4fedee6 | ||
|
|
6db28b2cba | ||
|
|
35dee0a20f | ||
|
|
c700127ad2 | ||
|
|
a32fe7a6cc | ||
|
|
d81307c108 | ||
|
|
a9bc3b81ae | ||
|
|
fd41fb0055 | ||
|
|
b5ada6848a | ||
|
|
cad5166361 | ||
|
|
94a7731257 | ||
|
|
4cea4c8e22 | ||
|
|
63a69d09fb | ||
|
|
7b3cd48edd | ||
|
|
c4a4899cdb | ||
|
|
50dd028877 | ||
|
|
64fdb9364f | ||
|
|
4a86415a37 | ||
|
|
ecbc310304 | ||
|
|
4a19a9dfb8 | ||
|
|
1801c037b8 |
26
AGENTS.md
26
AGENTS.md
@@ -1,7 +1,12 @@
|
||||
# Agent guide
|
||||
|
||||
An ACP-compatible coding agent powered by the Claude Agent SDK. Source in `src/`,
|
||||
TypeScript, ESM, Node >= 22.
|
||||
TypeScript, ESM, Node >= 22. `src/v1/` holds the v1 agent (`ClaudeAcpAgent` in
|
||||
`src/v1/acp-agent.ts`), `src/v2/` the v2 agent, and the rest of `src/` the modules
|
||||
both share. `src/acp-agent.ts` only re-exports `src/v1/acp-agent.ts` for the
|
||||
published `dist/acp-agent.js` path. ESLint rejects imports between `src/v1/` and
|
||||
`src/v2/`, and from shared modules into either agent. The entry points (`index.ts`, `lib.ts`,
|
||||
`serve.ts`, `binary-entry.ts`, `acp-agent.ts`) are exempt.
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -40,9 +45,9 @@ opening a PR.
|
||||
|
||||
- Before coding, read the code the change plugs into and find the module that
|
||||
owns the state or concept. Build on that module, not beside it.
|
||||
- Don't add new features or components to `acp-agent.ts`. If no existing module fits the new
|
||||
code cleanly, create a dedicated one that owns the feature's state and logic,
|
||||
and keep the change in `acp-agent.ts` to the calls that wire it in.
|
||||
- Don't add new features or components to `src/v1/acp-agent.ts`. If no existing module fits
|
||||
the new code cleanly, create a dedicated one that owns the feature's state and logic,
|
||||
and keep the change in `src/v1/acp-agent.ts` to the calls that wire it in.
|
||||
- Prefer extending an existing mechanism over adding a new one, even if it takes
|
||||
a small refactor (moving a helper, generalizing a function). Add a parallel
|
||||
path, special case or flag only when the existing structure can't support the
|
||||
@@ -58,11 +63,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.
|
||||
|
||||
@@ -4,6 +4,23 @@ import tsparser from "@typescript-eslint/parser";
|
||||
import prettierConfig from "eslint-config-prettier";
|
||||
import globals from "globals";
|
||||
|
||||
/**
|
||||
* Rules that reject a module specifier matching `regex` in static imports and
|
||||
* re-exports (type-only ones too), `import()` calls and `import("...")` types.
|
||||
*/
|
||||
function protocolBoundary(regex, message) {
|
||||
// esquery regex literals end at the first `/`, so spell it `\x2F`.
|
||||
const literal = `/${regex.replaceAll("/", "\\x2F")}/`;
|
||||
return {
|
||||
"no-restricted-imports": ["error", { patterns: [{ regex, message }] }],
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{ selector: `ImportExpression[source.value=${literal}]`, message },
|
||||
{ selector: `TSImportType[argument.literal.value=${literal}]`, message },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["node_modules/", "dist/", "coverage/", "*.min.js", "*.config.js", ".github/"],
|
||||
@@ -44,5 +61,39 @@ export default [
|
||||
curly: ["error", "all"],
|
||||
},
|
||||
},
|
||||
// `src/v2/` must not import the v1 agent (`src/v1/`, or `src/acp-agent.ts`, which re-exports it).
|
||||
{
|
||||
files: ["src/v2/**/*.ts"],
|
||||
rules: protocolBoundary(
|
||||
"^(\\.\\./)+(v1/|acp-agent\\.js$)",
|
||||
"src/v2/ must not import the v1 agent. Move what both versions need into a shared module in src/.",
|
||||
),
|
||||
},
|
||||
// `src/v1/` must not import the v2 agent.
|
||||
{
|
||||
files: ["src/v1/**/*.ts"],
|
||||
rules: protocolBoundary(
|
||||
"^(\\.\\./)+v2/",
|
||||
"src/v1/ must not import the v2 agent. Move what both versions need into a shared module in src/.",
|
||||
),
|
||||
},
|
||||
// Shared modules must not import either agent. The entry points wire the agents up.
|
||||
{
|
||||
files: ["src/**/*.ts"],
|
||||
ignores: [
|
||||
"src/v1/**",
|
||||
"src/v2/**",
|
||||
"src/tests/**",
|
||||
"src/index.ts",
|
||||
"src/lib.ts",
|
||||
"src/serve.ts",
|
||||
"src/binary-entry.ts",
|
||||
"src/acp-agent.ts",
|
||||
],
|
||||
rules: protocolBoundary(
|
||||
"^(\\./|(\\.\\./)+)(v1/|v2/|acp-agent\\.js$)",
|
||||
"Shared modules must not import src/v1/, src/v2/ or src/acp-agent.ts. Move the code into the shared module, or pass it in from the agent.",
|
||||
),
|
||||
},
|
||||
prettierConfig,
|
||||
];
|
||||
|
||||
15209
src/acp-agent.ts
15209
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
43
src/acp-client.ts
Normal file
43
src/acp-client.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type {
|
||||
CompleteElicitationNotification,
|
||||
CreateElicitationRequest,
|
||||
CreateElicitationResponse,
|
||||
ReadTextFileRequest,
|
||||
ReadTextFileResponse,
|
||||
RequestPermissionResponse,
|
||||
WriteTextFileRequest,
|
||||
WriteTextFileResponse,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { AcpSessionNotification } from "./acp-subagents.js";
|
||||
import type { AcpPermissionRequest } from "./permissions/presentation.js";
|
||||
|
||||
/**
|
||||
* Client-facing surface the agent calls back into. This is the subset of ACP
|
||||
* client methods the agent actually uses, expressed as a narrow interface so
|
||||
* tests can supply lightweight mocks. In the v1 agent it is backed by
|
||||
* `ClientConnection` (`v1/acp-agent.ts`) over the SDK's typed `AgentContext`.
|
||||
* The v2 session output passes one that only takes `sessionUpdate`
|
||||
* (`v2/stream.ts`).
|
||||
*/
|
||||
export interface AcpClient {
|
||||
sessionUpdate(params: AcpSessionNotification): Promise<void>;
|
||||
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
|
||||
* permission request so the client can dismiss its prompt, and rejects the
|
||||
* call at once: a client that ignores the cancel never answers. */
|
||||
requestPermission(
|
||||
params: AcpPermissionRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RequestPermissionResponse>;
|
||||
readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse>;
|
||||
writeTextFile(params: WriteTextFileRequest): Promise<WriteTextFileResponse>;
|
||||
/** `signal`, when aborted, sends `$/cancel_request` for the in-flight
|
||||
* elicitation so the client can dismiss its prompt, and rejects the call
|
||||
* at once, as {@link requestPermission} does. */
|
||||
createElicitation(
|
||||
params: CreateElicitationRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CreateElicitationResponse>;
|
||||
completeElicitation(params: CompleteElicitationNotification): Promise<void>;
|
||||
/** Send a custom (extension) notification, e.g. `_claude/sdkMessage`. */
|
||||
extNotification(method: string, params: Record<string, unknown>): Promise<void>;
|
||||
}
|
||||
@@ -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,755 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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`). */
|
||||
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`.
|
||||
*/
|
||||
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,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Keeps the display terminal of the command `taskId` running after its Bash
|
||||
* result, until its task ends (see `withRunningTerminal`).
|
||||
*/
|
||||
terminalStarted(taskId: string, terminal: BackgroundTerminal): void {
|
||||
this.terminals.set(taskId, terminal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`).
|
||||
*/
|
||||
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 {
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: terminal.toolCallId,
|
||||
_meta: {
|
||||
...(terminal.parentToolUseId
|
||||
? { claudeCode: { parentToolUseId: terminal.parentToolUseId } }
|
||||
: {}),
|
||||
terminal_exit: { terminal_id: terminal.toolCallId, exit_code: null, signal: null },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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>,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* `--hide-claude-auth`: this integration must never bill a claude.ai
|
||||
* subscription. Under the flag the agent hides the claude.ai login method from
|
||||
* `initialize`, and refuses with `authRequired` every session and every turn
|
||||
* that a subscription would pay for. `acp-agent.ts` decides when the guard
|
||||
* that a subscription would pay for. `v1/acp-agent.ts` decides when the guard
|
||||
* applies (flag on, no provider override); this module decides what it refuses.
|
||||
*
|
||||
* The guard judges the `AccountInfo` that the SDK caches at `initialize`. That
|
||||
@@ -208,14 +208,14 @@ type GuardOptions = {
|
||||
* covers a session created under a provider override that `providers/disable`
|
||||
* later removed, so the create-time check never ran for the routing in force
|
||||
* now. A sign-out ends the query (see `markSessionForSignOutRespawn` in
|
||||
* `acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* `v1/acp-agent.ts`), so the next turn recreates it and `initialize` reports the
|
||||
* real account. This guard does not see the residual case the module header
|
||||
* names either.
|
||||
*
|
||||
* A turn is refused when the account bills a subscription, and also when it
|
||||
* holds nothing this integration accepts, which is the user signing out in
|
||||
* mid-session. The refusal mirrors the signed-out path exactly (see
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `acp-agent.ts`): every
|
||||
* `failActiveWithSessionFailure` for `auth_required` in `v1/acp-agent.ts`): every
|
||||
* client gets the `authRequired` JSON-RPC rejection that starts its own auth
|
||||
* flow. The guard sends no separate access message.
|
||||
*
|
||||
|
||||
@@ -83,7 +83,7 @@ export class IdleCompaction {
|
||||
this.setTimer = options.setTimeout ?? ((cb, ms) => setTimeout(cb, ms));
|
||||
}
|
||||
|
||||
/** Whether an idle compaction it started is still running. `acp-agent.ts` reports
|
||||
/** Whether an idle compaction it started is still running. `v1/acp-agent.ts` reports
|
||||
* `trigger: "idle"` while it is true. A client's `/compact` queued behind is read
|
||||
* only after the adapter's turn's `result`, which clears `compacting`; this relies
|
||||
* on the CLI emitting that compaction's `compact_boundary` after the previous
|
||||
|
||||
16
src/lib.ts
16
src/lib.ts
@@ -1,15 +1,9 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
type ToolUpdateMeta,
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export { ClaudeAcpAgent, runAcp } from "./v1/acp-agent.js";
|
||||
export { isLocalCommandMetadata, stripLocalCommandMetadata } from "./message-filters.js";
|
||||
export { toAcpNotifications, streamEventToAcpNotifications } from "./sdk-to-acp.js";
|
||||
export type { NewSessionMeta, SDKMessageFilter } from "./new-session-meta.js";
|
||||
export type { ToolUpdateMeta } from "./tool-calls/renderer.js";
|
||||
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
|
||||
9
src/logger.ts
Normal file
9
src/logger.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Logger interface for customizing logging output
|
||||
*/
|
||||
export interface Logger {
|
||||
log: (...args: any[]) => void;
|
||||
error: (...args: any[]) => void;
|
||||
/** Optional: a caller that supplies no `warn` gets its warnings on `error`. */
|
||||
warn?: (...args: any[]) => void;
|
||||
}
|
||||
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;
|
||||
}
|
||||
217
src/message-filters.ts
Normal file
217
src/message-filters.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* The Claude Code messages and content that a client never sees as the user's
|
||||
* or the agent's text: local slash command markers, context the CLI injects,
|
||||
* the synthetic `/login` message and meta messages from other agents. The live
|
||||
* stream and the `session/load` replay both filter with these.
|
||||
*/
|
||||
import { isLoginRequiredText } from "./result-end.js";
|
||||
import { isSyntheticUsageLimitMessage } from "./session-failure-extension.js";
|
||||
import { markerTagText, TASK_NOTIFICATION_TAG } from "./task-notifications.js";
|
||||
|
||||
// Slash commands that the SDK handles locally without replaying the user
|
||||
// message and without invoking the model.
|
||||
export const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
// Commands whose marker-only transcript records are ACP/client-local UI noise,
|
||||
// not model-visible user prompts. Marker-only custom slash skills are not in
|
||||
// this list, so replay can reconstruct them from `<command-name>`/`<command-args>`.
|
||||
const REPLAY_HIDDEN_COMMANDS = new Set([
|
||||
...LOCAL_ONLY_COMMANDS,
|
||||
"/compact",
|
||||
"/model",
|
||||
"/mcp",
|
||||
"/status",
|
||||
"/usage",
|
||||
]);
|
||||
|
||||
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
|
||||
// their output as user messages in the session transcript, wrapping the
|
||||
// payload in these XML-like markers that the CLI uses for its own display.
|
||||
// The live prompt loop drops them; replay must strip them too or they leak
|
||||
// into the UI on session/load.
|
||||
const LOCAL_COMMAND_MARKERS = [
|
||||
"command-name",
|
||||
"command-message",
|
||||
"command-args",
|
||||
"local-command-stdout",
|
||||
"local-command-stderr",
|
||||
].map((tag) => ({ open: `<${tag}>`, close: `</${tag}>` }));
|
||||
|
||||
// Context the CLI injects into a user turn to steer the model, appended to
|
||||
// whatever the user typed. Nobody wrote it and no client sees it live — the
|
||||
// prompt loop's user-message skip covers the whole message — but it is
|
||||
// persisted alongside that prose, so replay would hand the client a prompt
|
||||
// with instructions in it the user never gave. A `task-notification` tells the
|
||||
// model that a background task stopped; live, the SDK `task_notification`
|
||||
// frame reports the same stop, so replay restores the task state from it (see
|
||||
// taskNotificationsOf) instead of showing it.
|
||||
const INJECTED_CONTEXT_MARKERS = ["system-reminder", TASK_NOTIFICATION_TAG].map((tag) => ({
|
||||
open: `<${tag}>`,
|
||||
close: `</${tag}>`,
|
||||
}));
|
||||
|
||||
// Everything a replayed user message may be wrapped in that is not speech.
|
||||
const TRANSCRIPT_MARKERS = [...LOCAL_COMMAND_MARKERS, ...INJECTED_CONTEXT_MARKERS];
|
||||
|
||||
// Single-pass scanner that removes each `<tag>…</tag>` marker (matching the
|
||||
// nearest closing tag of the same name, like a lazy regex would).
|
||||
function stripMarkerTags(text: string): string {
|
||||
const dead = new Set<string>();
|
||||
let result = "";
|
||||
let copiedUpTo = 0;
|
||||
let i = 0;
|
||||
while (i < text.length) {
|
||||
if (text[i] === "<") {
|
||||
const marker = TRANSCRIPT_MARKERS.find(
|
||||
(m) => !dead.has(m.open) && text.startsWith(m.open, i),
|
||||
);
|
||||
if (marker) {
|
||||
const end = text.indexOf(marker.close, i + marker.open.length);
|
||||
if (end !== -1) {
|
||||
result += text.slice(copiedUpTo, i);
|
||||
i = copiedUpTo = end + marker.close.length;
|
||||
continue;
|
||||
}
|
||||
// No closing marker remains anywhere ahead, and `indexOf` only ever
|
||||
// searches forward from here on, so stop treating this tag as an
|
||||
// opener — that avoids rescanning the tail for it on every match.
|
||||
dead.add(marker.open);
|
||||
}
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return result + text.slice(copiedUpTo);
|
||||
}
|
||||
|
||||
function hasMarkerTag(text: string, tag: string): boolean {
|
||||
return markerTagText(text, tag) !== undefined;
|
||||
}
|
||||
|
||||
function commandInvocationFromMarkerOnlyText(text: string): string | null {
|
||||
if (hasMarkerTag(text, "local-command-stdout") || hasMarkerTag(text, "local-command-stderr")) {
|
||||
return null;
|
||||
}
|
||||
const commandName = markerTagText(text, "command-name")?.trim();
|
||||
if (!commandName?.startsWith("/")) return null;
|
||||
if (REPLAY_HIDDEN_COMMANDS.has(commandName.split(" ", 1)[0])) return null;
|
||||
|
||||
const commandArgs = markerTagText(text, "command-args")?.trim();
|
||||
return commandArgs ? `${commandName} ${commandArgs}` : commandName;
|
||||
}
|
||||
|
||||
function stripLocalCommandMetadataText(text: string): string | null {
|
||||
const stripped = stripMarkerTags(text);
|
||||
if (stripped.trim() !== "") return stripped;
|
||||
return commandInvocationFromMarkerOnlyText(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return user-message content with local-command and injected-context marker
|
||||
* tags removed, or `null` if nothing meaningful remains (caller should skip
|
||||
* the message). Preserves real prose that's mixed in alongside the markers —
|
||||
* e.g. a message like `<command-name>…</command-name>hi` becomes `hi`, and
|
||||
* `hi<system-reminder>…</system-reminder>` becomes `hi`.
|
||||
*/
|
||||
export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
if (typeof content === "string") {
|
||||
return stripLocalCommandMetadataText(content);
|
||||
}
|
||||
if (!Array.isArray(content)) return content;
|
||||
|
||||
const kept: unknown[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
(block as { type: unknown }).type === "text" &&
|
||||
"text" in block &&
|
||||
typeof (block as { text: unknown }).text === "string"
|
||||
) {
|
||||
const stripped = stripLocalCommandMetadataText((block as { text: string }).text);
|
||||
if (stripped === null) continue;
|
||||
kept.push({ ...(block as object), text: stripped });
|
||||
} else {
|
||||
kept.push(block);
|
||||
}
|
||||
}
|
||||
if (kept.length === 0) return null;
|
||||
return kept;
|
||||
}
|
||||
|
||||
export function isLocalCommandMetadata(content: unknown): boolean {
|
||||
return stripLocalCommandMetadata(content) === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* True for the synthetic assistant message the CLI injects into the transcript
|
||||
* when a turn fails authentication (e.g. "Not logged in · Please run /login",
|
||||
* "Session expired. Please run /login to sign in again."). The `/login`
|
||||
* instruction is Claude Code TUI-specific and meaningless to ACP clients
|
||||
* (issue #863). The live prompt loop suppresses the text and fails the turn
|
||||
* with `authRequired` so the client can run its own auth flow; replay must
|
||||
* skip it too — both for parity with what the client saw live and because the
|
||||
* message stays in the transcript forever, so it would resurface on every
|
||||
* session/load even after the user has logged back in.
|
||||
*
|
||||
* Takes the API message (`message.message`), which replay only knows as
|
||||
* `unknown`. The persisted record's structured `error` marker is stripped by
|
||||
* `getSessionMessages`, and the CLI does not always set it to
|
||||
* `authentication_failed` anyway: an expired login with no refresh token
|
||||
* ("Login expired · Run /login to sign in again, …") arrives as
|
||||
* `invalid_request`. So the synthetic model + text is what both paths match on.
|
||||
* Usage-limit banners are excluded because some of them also mention `/login`
|
||||
* (to switch accounts) and have their own handling.
|
||||
*/
|
||||
export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
|
||||
if (!apiMessage || typeof apiMessage !== "object") {
|
||||
return false;
|
||||
}
|
||||
const { model, content } = apiMessage as { model?: unknown; content?: unknown };
|
||||
if (model !== "<synthetic>" || !Array.isArray(content) || content.length !== 1) {
|
||||
return false;
|
||||
}
|
||||
const block = content[0] as { type?: unknown; text?: unknown } | null;
|
||||
return (
|
||||
!!block &&
|
||||
block.type === "text" &&
|
||||
typeof block.text === "string" &&
|
||||
isLoginRequiredText(block.text) &&
|
||||
!isSyntheticUsageLimitMessage(apiMessage)
|
||||
);
|
||||
}
|
||||
|
||||
/** Origin kinds of the meta user messages `getSessionMessages` returns since
|
||||
* SDK 0.3.284 (messages from other agents, sessions and channels). */
|
||||
const REPLAY_HIDDEN_META_ORIGIN_KINDS = new Set([
|
||||
"peer",
|
||||
"channel",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
"slack-ping",
|
||||
]);
|
||||
|
||||
/**
|
||||
* True for a transcript message delivered to the model from another agent,
|
||||
* session or channel. Since SDK 0.3.284 `getSessionMessages` returns these as
|
||||
* `is_meta` user messages whose content is the full harness framing (envelope
|
||||
* XML plus the "not typed by your user" preamble). The live prompt loop never
|
||||
* renders them, so replay skips them too rather than presenting them as text
|
||||
* the user typed. Other `is_meta` messages (compact summaries) are kept.
|
||||
*
|
||||
* `is_meta` and `origin` are runtime fields not declared on `SessionMessage`.
|
||||
*/
|
||||
export function isReplayHiddenMetaMessage(message: unknown): boolean {
|
||||
if (!message || typeof message !== "object") return false;
|
||||
const { type, is_meta, origin } = message as {
|
||||
type?: unknown;
|
||||
is_meta?: unknown;
|
||||
origin?: { kind?: unknown } | null;
|
||||
};
|
||||
return (
|
||||
type === "user" &&
|
||||
is_meta === true &&
|
||||
typeof origin?.kind === "string" &&
|
||||
REPLAY_HIDDEN_META_ORIGIN_KINDS.has(origin.kind)
|
||||
);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
41
src/new-session-meta.ts
Normal file
41
src/new-session-meta.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { Options, SDKMessageOrigin } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
export type SDKMessageFilter = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
origin?: SDKMessageOrigin["kind"];
|
||||
};
|
||||
|
||||
/**
|
||||
* Extra metadata that can be given when creating a new session.
|
||||
*/
|
||||
export type NewSessionMeta = {
|
||||
claudeCode?: {
|
||||
/**
|
||||
* Options forwarded to Claude Code when starting a new session.
|
||||
* Those parameters will be ignored and managed by ACP:
|
||||
* - cwd
|
||||
* - includePartialMessages
|
||||
* - permissionMode
|
||||
* - canUseTool
|
||||
* - executable
|
||||
* Those parameters will be used and updated to work with ACP:
|
||||
* - hooks (merged with ACP's hooks)
|
||||
* - mcpServers (merged with ACP's mcpServers)
|
||||
* - disallowedTools (merged with ACP's disallowedTools)
|
||||
* - tools (passed through; defaults to claude_code preset if not provided)
|
||||
* - allowDangerouslySkipPermissions (set to `false` to remove bypassPermissions
|
||||
* from this session; repeat it on session/load. `true` cannot override root)
|
||||
*/
|
||||
options?: Options;
|
||||
/**
|
||||
* When set, raw SDK messages are emitted as extNotification("_claude/sdkMessage", message)
|
||||
* in addition to normal processing.
|
||||
* - true: emit all messages
|
||||
* - false/undefined: emit nothing (default)
|
||||
* - SDKMessageFilter[]: emit only messages matching at least one filter
|
||||
*/
|
||||
emitRawSDKMessages?: boolean | SDKMessageFilter[];
|
||||
};
|
||||
additionalRoots?: string[];
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
129
src/prompt-to-claude.ts
Normal file
129
src/prompt-to-claude.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The conversion of an ACP prompt into the Claude Code user message.
|
||||
*/
|
||||
import type { PromptRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { blobResourceToClaude } from "./blob-resources.js";
|
||||
|
||||
function formatUriAsLink(uri: string): string {
|
||||
try {
|
||||
if (uri.startsWith("file://")) {
|
||||
const path = uri.slice(7); // Remove "file://"
|
||||
const name = path.split("/").pop() || path;
|
||||
return `[@${name}](${uri})`;
|
||||
} else if (uri.startsWith("zed://")) {
|
||||
const parts = uri.split("/");
|
||||
const name = parts[parts.length - 1] || uri;
|
||||
return `[@${name}](${uri})`;
|
||||
}
|
||||
return uri;
|
||||
} catch {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
/** The prompt content block types that `promptToClaude` converts. The agent
|
||||
* advertises no `audio` prompt capability. */
|
||||
export const SUPPORTED_PROMPT_CONTENT_TYPES: readonly string[] = [
|
||||
"text",
|
||||
"image",
|
||||
"resource_link",
|
||||
"resource",
|
||||
];
|
||||
|
||||
export function promptToClaude(prompt: PromptRequest): SDKUserMessage {
|
||||
const content: any[] = [];
|
||||
const context: any[] = [];
|
||||
|
||||
const firstText = prompt.prompt.find((chunk) => chunk.type === "text");
|
||||
for (const chunk of prompt.prompt) {
|
||||
switch (chunk.type) {
|
||||
case "text": {
|
||||
let text = chunk.text;
|
||||
// A slash command is the first text block. Change
|
||||
// `/mcp:server:command args` to `/server:command (MCP) args`; the
|
||||
// arguments can span lines.
|
||||
const mcpMatch =
|
||||
chunk === firstText ? text.match(/^\/mcp:([^:\s]+):(\S+)(?:\s+([\s\S]*))?$/) : null;
|
||||
if (mcpMatch) {
|
||||
const [, server, command, args] = mcpMatch;
|
||||
text = `/${server}:${command} (MCP)${args ? ` ${args}` : ""}`;
|
||||
}
|
||||
content.push({ type: "text", text });
|
||||
break;
|
||||
}
|
||||
case "resource_link": {
|
||||
const formattedUri = formatUriAsLink(chunk.uri);
|
||||
content.push({
|
||||
type: "text",
|
||||
text: formattedUri,
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "resource": {
|
||||
const { uri } = chunk.resource;
|
||||
// A blob resource becomes an image or document block, or its decoded
|
||||
// text. A v1 prompt skips a blob of any other type, e.g.
|
||||
// `application/zip`; a v2 prompt rejects it (see `v1PromptRequest`).
|
||||
const blob = "blob" in chunk.resource ? blobResourceToClaude(chunk.resource) : undefined;
|
||||
const text =
|
||||
"text" in chunk.resource
|
||||
? chunk.resource.text
|
||||
: blob?.kind === "text"
|
||||
? blob.text
|
||||
: undefined;
|
||||
if (blob?.kind === "image" || blob?.kind === "document") {
|
||||
content.push({ type: "text", text: formatUriAsLink(uri) }, blob.block);
|
||||
} else if (text !== undefined) {
|
||||
content.push({ type: "text", text: formatUriAsLink(uri) });
|
||||
context.push({
|
||||
type: "text",
|
||||
text: `\n<context ref="${uri}">\n${text}\n</context>`,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
if (chunk.data) {
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
data: chunk.data,
|
||||
media_type: chunk.mimeType,
|
||||
},
|
||||
});
|
||||
} else if (chunk.uri && chunk.uri.startsWith("http")) {
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "url",
|
||||
url: chunk.uri,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
// Ignore audio and other unsupported types
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
content.push(...context);
|
||||
|
||||
return {
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: content,
|
||||
},
|
||||
session_id: prompt.sessionId,
|
||||
parent_tool_use_id: null,
|
||||
// ACP prompts are the user's own input relayed by the client. Stamp the
|
||||
// provenance explicitly: per the SDK, a host wrapping keyboard input must
|
||||
// send `{kind: "human"}` — an absent `origin` is treated as unattributed
|
||||
// and fails closed at the CLI's strict isHuman() trust gates (e.g. the
|
||||
// ultracode keyword opt-in honors only human-originated turns).
|
||||
origin: { kind: "human" },
|
||||
};
|
||||
}
|
||||
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
|
||||
);
|
||||
}
|
||||
105
src/result-end.ts
Normal file
105
src/result-end.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* How Claude Code's `result` ends the work that it reports, and whether that
|
||||
* work was the user's turn or a cycle Claude Code started on its own: the
|
||||
* rules that both cores read a turn's result with (the v1 prompt loop in
|
||||
* `v1/acp-agent.ts` and `v2/`). Each core adds its own side effects, such as v1's
|
||||
* session failure category.
|
||||
*/
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
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 };
|
||||
}
|
||||
|
||||
/** Result-message origin kinds that mark an AUTONOMOUS cycle — work the
|
||||
* model did on its own (a task-notification followup, a peer/coordinator/
|
||||
* observer message it handled) rather than the user's prompt. Absent,
|
||||
* `human`, and `channel` origins are the user's own turn (this adapter's
|
||||
* prompts arrive as the ACP channel on some CLI configurations — ALL
|
||||
* channel servers are treated as user, so a foreign channel integration's
|
||||
* autonomously-handled result is misclassified as the user's; accepted,
|
||||
* see below), and `auto-continuation` continues the user's turn, so its
|
||||
* result is the turn's real terminal.
|
||||
*
|
||||
* Deliberately fail-OPEN: an unknown future kind defaults to the user
|
||||
* lane — including `unclassified` (SDK 0.3.232+), the CLI's own "couldn't
|
||||
* attribute this" marker, which gets the same safe default. Misrouting a
|
||||
* USER result into the autonomous lane hangs the prompt un-detectably
|
||||
* (the result is skipped, its trailing idle absorbed as owed, so the
|
||||
* #825 detector can't fire); misrouting an autonomous result into the
|
||||
* user lane is the bounded misattribution class this set exists to
|
||||
* reduce.
|
||||
*
|
||||
* Exception: a result from one of these origins that names a pending prompt
|
||||
* in `user_message_uuids` consumed that folded prompt and takes the user
|
||||
* lane (see the result handler of each core). */
|
||||
export const AUTONOMOUS_RESULT_ORIGINS: ReadonlySet<SDKMessageOrigin["kind"]> = new Set([
|
||||
"task-notification",
|
||||
"peer",
|
||||
"coordinator",
|
||||
"observer",
|
||||
"observer-activity",
|
||||
]);
|
||||
|
||||
/** Whether Claude Code ended the work of `result` because an interrupt
|
||||
* stopped it, while the model streamed or while a tool ran. */
|
||||
export function isAbortedResult(result: Extract<SDKMessage, { type: "result" }>): boolean {
|
||||
return (
|
||||
result.terminal_reason === "aborted_streaming" || result.terminal_reason === "aborted_tools"
|
||||
);
|
||||
}
|
||||
853
src/sdk-to-acp.ts
Normal file
853
src/sdk-to-acp.ts
Normal file
@@ -0,0 +1,853 @@
|
||||
/**
|
||||
* The mapping of Claude Code output to ACP session updates: content blocks
|
||||
* (`toAcpNotifications`) and partial stream events
|
||||
* (`streamEventToAcpNotifications`) become message chunks, thoughts, tool
|
||||
* calls and plans. The live stream and the `session/load` replay of both
|
||||
* protocol versions use it; v2 converts the v1 updates it returns.
|
||||
*/
|
||||
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { SDKPartialAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ContentBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import type {
|
||||
BetaContentBlock,
|
||||
BetaRawContentBlockDelta,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import type { AcpClient } from "./acp-client.js";
|
||||
import { clientSupportsSubagents } from "./acp-subagents.js";
|
||||
import { approvedPlan, planModeUpdate } from "./exit-plan.js";
|
||||
import type { Logger } from "./logger.js";
|
||||
import { checklistPlanUpdate, todoWritePlanUpdate, type SubagentChecklists } from "./plans.js";
|
||||
import { ClientCapabilities as ToolCallClientCapabilities } from "./tool-calls/client-capabilities.js";
|
||||
import type { ToolCallFieldTracker } from "./tool-calls/field-tracker.js";
|
||||
import { AcpToolCallRenderer, type ToolUpdateMeta } from "./tool-calls/renderer.js";
|
||||
import { parseToolResultMeta } from "./tool-result-meta.js";
|
||||
import {
|
||||
applyTaskCreate,
|
||||
applyTaskList,
|
||||
applyTaskUpdate,
|
||||
changedTaskPlanEntries,
|
||||
type ClaudePlanEntry,
|
||||
completeHookCallback,
|
||||
hasHookCallback,
|
||||
parseTaskCreateOutput,
|
||||
parseTaskListOutput,
|
||||
parseTaskUpdateOutput,
|
||||
planEntries,
|
||||
registerHookCallback,
|
||||
type TaskState,
|
||||
unregisterHookCallback,
|
||||
} from "./tools.js";
|
||||
import { unreachable } from "./utils.js";
|
||||
|
||||
/** Attributes a report to the Agent/Task tool call of the subagent that made it. */
|
||||
export function stampParentToolUseId(
|
||||
update: SessionNotification["update"],
|
||||
parentToolUseId: string,
|
||||
) {
|
||||
update._meta = {
|
||||
...update._meta,
|
||||
claudeCode: {
|
||||
...((update._meta?.claudeCode as Record<string, unknown> | undefined) ?? {}),
|
||||
parentToolUseId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ToolUseCache = {
|
||||
[key: string]: {
|
||||
type: "tool_use" | "server_tool_use" | "mcp_tool_use";
|
||||
id: string;
|
||||
name: string;
|
||||
input: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
type StreamedToolInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
partialJson: string;
|
||||
inString: boolean;
|
||||
escaped: boolean;
|
||||
objectDepth: number;
|
||||
arrayDepth: number;
|
||||
/** Offset of the most recent comma at the top level of the input object
|
||||
* (-1 before the first). Everything before it is a complete field. */
|
||||
lastTopLevelComma: number;
|
||||
/** The comma offset the last emitted refinement was sliced at (-1 before the
|
||||
* first), so a field boundary only triggers one recovery attempt. */
|
||||
emittedThroughComma: number;
|
||||
};
|
||||
|
||||
export type StreamedToolInputCache = Map<string, Map<number, StreamedToolInput>>;
|
||||
|
||||
/**
|
||||
* Advance the lexer state across the fragment appended since the last delta:
|
||||
* just enough JSON awareness (string/escape, nesting depth) to spot commas
|
||||
* that sit at the top level of the input object — everything before such a
|
||||
* comma is a set of complete fields. Returns true once the input object's
|
||||
* closing brace arrives.
|
||||
*
|
||||
* The lexer reads the fragment itself, never `partialJson` by index. V8 keeps a
|
||||
* string built with `+=` as a chain of parts, and the first index access
|
||||
* copies the whole chain into one flat string. An index scan of `partialJson`
|
||||
* would copy the whole input on each delta, so a large Write would cost
|
||||
* quadratic time. A test in `acp-agent.test.ts` checks this.
|
||||
*/
|
||||
function scanStreamedToolInput(state: StreamedToolInput, fragment: string): boolean {
|
||||
const offset = state.partialJson.length;
|
||||
state.partialJson += fragment;
|
||||
let complete = false;
|
||||
for (let index = 0; index < fragment.length; index++) {
|
||||
const character = fragment[index];
|
||||
if (state.inString) {
|
||||
if (state.escaped) {
|
||||
state.escaped = false;
|
||||
} else if (character === "\\") {
|
||||
state.escaped = true;
|
||||
} else if (character === '"') {
|
||||
state.inString = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
state.inString = true;
|
||||
} else if (character === "{") {
|
||||
state.objectDepth++;
|
||||
} else if (character === "}") {
|
||||
state.objectDepth--;
|
||||
if (state.objectDepth === 0) {
|
||||
complete = true;
|
||||
}
|
||||
} else if (character === "[") {
|
||||
state.arrayDepth++;
|
||||
} else if (character === "]") {
|
||||
state.arrayDepth--;
|
||||
} else if (character === "," && state.objectDepth === 1 && state.arrayDepth === 0) {
|
||||
state.lastTopLevelComma = offset + index;
|
||||
}
|
||||
}
|
||||
return complete;
|
||||
}
|
||||
|
||||
/** Parse the complete top-level fields before a top-level comma by closing the
|
||||
* object at that boundary. */
|
||||
function recoveredToolInput(prefix: string): Record<string, unknown> | undefined {
|
||||
try {
|
||||
const value: unknown = JSON.parse(prefix + "}");
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the ACP `messageId` for a Claude SDK message (live) or a persisted
|
||||
* transcript message (replay) so chunk grouping is identical in both views.
|
||||
*
|
||||
* Assistant turns are keyed by the Anthropic API message id (`message.id`),
|
||||
* which is identical at `message_start`, on the consolidated assistant message,
|
||||
* and in the persisted transcript — unlike the per-`stream_event` uuid, which is
|
||||
* unique per event and never persisted. User messages have no API id, but they
|
||||
* are never streamed, so their (stable) SDK uuid is used instead. ACP message
|
||||
* ids are opaque strings, so no particular format is required.
|
||||
*/
|
||||
export function messageIdForGrouping(message: {
|
||||
type?: string;
|
||||
uuid?: string | null;
|
||||
message?: unknown;
|
||||
}): string | undefined {
|
||||
if (message.type === "assistant") {
|
||||
const inner = message.message;
|
||||
const apiId =
|
||||
inner && typeof inner === "object" && "id" in inner
|
||||
? (inner as { id?: unknown }).id
|
||||
: undefined;
|
||||
if (typeof apiId === "string" && apiId.length > 0) {
|
||||
return apiId;
|
||||
}
|
||||
}
|
||||
return typeof message.uuid === "string" && message.uuid.length > 0 ? message.uuid : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stamps an ACP `messageId` onto a session update, but only on the message/
|
||||
* thought chunk variants that carry one — tool_call/plan/etc. updates never do.
|
||||
* No-op when `messageId` is falsy, so callers can pass it through unconditionally.
|
||||
*/
|
||||
function applyMessageId(
|
||||
update: SessionNotification["update"],
|
||||
messageId: string | undefined,
|
||||
): void {
|
||||
if (
|
||||
messageId &&
|
||||
(update.sessionUpdate === "agent_message_chunk" ||
|
||||
update.sessionUpdate === "user_message_chunk" ||
|
||||
update.sessionUpdate === "agent_thought_chunk")
|
||||
) {
|
||||
update.messageId = messageId;
|
||||
}
|
||||
}
|
||||
|
||||
/** Built-in tools that drive the task list (headless/SDK sessions use these
|
||||
* instead of TodoWrite). Their tool_use/tool_result are surfaced as `plan`
|
||||
* snapshots rather than as tool_calls. */
|
||||
function isTaskTool(toolName: string): boolean {
|
||||
return (
|
||||
toolName === "TaskCreate" ||
|
||||
toolName === "TaskUpdate" ||
|
||||
toolName === "TaskList" ||
|
||||
toolName === "TaskGet"
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the streamed tool_use path surfaces this tool as a standalone
|
||||
* `tool_call`. TodoWrite is rendered as a `plan` and Task* tools are
|
||||
* suppressed (their plan snapshot is emitted at tool_result time), so neither
|
||||
* produces a streamed tool_call/tool_call_update — which means a
|
||||
* permission-surfaced tool_call for them (see `ensureToolCallEmitted`) must be
|
||||
* resolved explicitly at tool_result time. */
|
||||
function shouldEmitToolCall(toolName: string): boolean {
|
||||
return toolName !== "TodoWrite" && !isTaskTool(toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* The renderer of the notification functions: the agent's tool call
|
||||
* capabilities when they are passed, else those of the ACP capabilities.
|
||||
*/
|
||||
function toolCallRenderer(options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
replay?: boolean;
|
||||
}): AcpToolCallRenderer {
|
||||
return options?.toolCallCapabilities
|
||||
? new AcpToolCallRenderer(options.toolCallCapabilities, options.replay)
|
||||
: AcpToolCallRenderer.for(options?.clientCapabilities, options?.replay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an SDKAssistantMessage (Claude) to a SessionNotification (ACP).
|
||||
* Only handles text, image, and thinking chunks for now.
|
||||
*/
|
||||
export function toAcpNotifications(
|
||||
content: string | ContentBlockParam[] | BetaContentBlock[] | BetaRawContentBlockDelta[],
|
||||
role: "assistant" | "user",
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AcpClient,
|
||||
logger: Logger,
|
||||
options?: {
|
||||
registerHooks?: boolean;
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
// The agent's tool call capabilities. They win over clientCapabilities,
|
||||
// and carry what ACP capabilities cannot (ToolCallClientCapabilities.v2).
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
parentToolUseId?: string | null;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
// The subagent TodoWrite checklists that the client holds in the session.
|
||||
// Mutated in place.
|
||||
subagentChecklists?: SubagentChecklists;
|
||||
// Tracks tool_use ids already emitted as a `tool_call` so a permission
|
||||
// request (which emits the tool_call eagerly) and the streamed tool_use
|
||||
// chunk don't both emit one — whichever arrives second emits a
|
||||
// `tool_call_update` instead. Mutated in place. When omitted, the
|
||||
// tool_call/update decision falls back to `toolUseCache` presence (the
|
||||
// historical single-source behavior).
|
||||
emittedToolCalls?: Set<string>;
|
||||
// False while the input of a streamed tool_use still streams: the first
|
||||
// tool_call then leaves `rawInput` out, and the consolidated message
|
||||
// sends it once it is complete.
|
||||
inputComplete?: boolean;
|
||||
// Remembers the fields sent for each open tool call. When present, a
|
||||
// tool_call_update carries only the fields that changed, and an update
|
||||
// with nothing new is dropped. Mutated in place.
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
// Opaque id identifying the message these chunks belong to (ACP message ids
|
||||
// are opaque strings — no particular format is required). Attached to
|
||||
// user/agent message and thought chunks so clients can group streamed chunks
|
||||
// into a single message. Omit it (leave undefined) when unknown — never send
|
||||
// an explicit `null`.
|
||||
messageId?: string;
|
||||
// The SDK user message's `tool_use_result`: the structured Output object of
|
||||
// the tool_result this message carries (shape is per-tool). Used to render
|
||||
// Agent/Task results from the structured subagent report instead of the raw
|
||||
// text (which ends in a model-directed agentId/usage trailer).
|
||||
toolUseResult?: unknown;
|
||||
// The SDK user message's `tool_result_meta` sidecar, passed raw (it's
|
||||
// untyped in sdk.d.ts) and validated by `parseToolResultMeta`. Stamps
|
||||
// denied/interrupted tool_call_updates with why the tool never ran.
|
||||
toolResultMeta?: unknown;
|
||||
// True when the content is a result of the work that a `session/cancel`
|
||||
// stopped. An error result then ends a run that the cancel aborted,
|
||||
// whichever kind the SDK stamped on it (see `resultStatus`).
|
||||
cancelled?: boolean;
|
||||
// True when the content comes from the history of a loaded session.
|
||||
replay?: boolean;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const taskState = options?.taskState ?? new Map();
|
||||
const registerHooks = options?.registerHooks !== false;
|
||||
const renderer = toolCallRenderer(options);
|
||||
if (typeof content === "string") {
|
||||
if (content.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const update: SessionNotification["update"] = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: content,
|
||||
},
|
||||
};
|
||||
applyMessageId(update, options?.messageId);
|
||||
|
||||
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
|
||||
|
||||
return [{ sessionId, update }];
|
||||
}
|
||||
|
||||
// `tool_use_result` is message-level and carries no tool_use_id of its own:
|
||||
// it describes "the" tool_result block of the message it rode in on. If
|
||||
// several tool_result blocks were ever batched into one message it couldn't
|
||||
// be attributed, so it is only honored when the message carries exactly one.
|
||||
const toolUseResult =
|
||||
options?.toolUseResult !== undefined &&
|
||||
content.filter((c) => typeof c === "object" && c !== null && c.type === "tool_result")
|
||||
.length === 1
|
||||
? options.toolUseResult
|
||||
: undefined;
|
||||
|
||||
// Unlike `tool_use_result`, entries carry their own tool_use_id, so batched
|
||||
// messages need no single-block guard.
|
||||
const toolResultMeta = parseToolResultMeta(options?.toolResultMeta);
|
||||
const output = [];
|
||||
// Only handle the first chunk for streaming; extend as needed for batching
|
||||
for (const chunk of content) {
|
||||
let update: SessionNotification["update"] | null = null;
|
||||
// The tool call that this chunk finishes, if it is a tool result.
|
||||
let finishedToolCallId: string | undefined;
|
||||
// The plan that this chunk approves, if it is an ExitPlanMode result.
|
||||
let approvedPlanUpdate: SessionNotification["update"] | undefined;
|
||||
switch (chunk.type) {
|
||||
case "text":
|
||||
case "text_delta": {
|
||||
if (chunk.text) {
|
||||
update = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.text,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "image":
|
||||
update = {
|
||||
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
|
||||
content: {
|
||||
type: "image",
|
||||
data: chunk.source.type === "base64" ? chunk.source.data : "",
|
||||
mimeType: chunk.source.type === "base64" ? chunk.source.media_type : "",
|
||||
uri: chunk.source.type === "url" ? chunk.source.url : undefined,
|
||||
},
|
||||
};
|
||||
break;
|
||||
case "thinking":
|
||||
case "thinking_delta": {
|
||||
// Recent models default `thinking.display` to "omitted", which streams
|
||||
// signature-only thinking blocks whose text is empty.
|
||||
if (chunk.thinking) {
|
||||
update = {
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: {
|
||||
type: "text",
|
||||
text: chunk.thinking,
|
||||
},
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tool_use":
|
||||
case "server_tool_use":
|
||||
case "mcp_tool_use": {
|
||||
const alreadyCached = chunk.id in toolUseCache;
|
||||
toolUseCache[chunk.id] = chunk;
|
||||
if (chunk.name === "TodoWrite") {
|
||||
// @ts-expect-error - sometimes input is empty object or undefined
|
||||
if (Array.isArray(chunk.input?.todos)) {
|
||||
update = todoWritePlanUpdate(planEntries(chunk.input as { todos: ClaudePlanEntry[] }), {
|
||||
planOperations: options?.clientCapabilities?.plan != null,
|
||||
parentToolUseId: options?.parentToolUseId,
|
||||
// With native subagent sessions, an update of a subagent goes
|
||||
// to the subagent's session (see NativeSubagentRuntime).
|
||||
childSessions: clientSupportsSubagents(options?.clientCapabilities),
|
||||
shown: options?.subagentChecklists,
|
||||
});
|
||||
}
|
||||
} else if (isTaskTool(chunk.name)) {
|
||||
// Task* tool_use is suppressed; the plan update is emitted at
|
||||
// tool_result time once we have the task ID (for TaskCreate) and
|
||||
// confirmation that the change took effect.
|
||||
} else {
|
||||
// Only register hooks on first encounter to avoid double-firing
|
||||
if (registerHooks && !alreadyCached) {
|
||||
// Capture the tool name in the closure rather than re-reading the
|
||||
// cache when the hook fires. The cache entry is pruned at
|
||||
// tool_result time, and a PostToolUse hook can fire after that, so
|
||||
// closing over the name keeps the diff working without depending on
|
||||
// (or pinning) the cache entry's lifetime.
|
||||
const toolName = chunk.name;
|
||||
const hookToolCallId = chunk.id;
|
||||
registerHookCallback(
|
||||
chunk.id,
|
||||
{
|
||||
onPostToolUseHook: async (toolUseId, _toolInput, toolResponse) => {
|
||||
// The final diff of an Edit or a Write replaces the
|
||||
// optimistic content built from the input. Only the marker
|
||||
// fields of the tool_response travel: the rest repeats
|
||||
// output that the content already carries.
|
||||
const update = await renderer.hookResult(
|
||||
{ id: toolUseId, name: toolName },
|
||||
toolResponse,
|
||||
options?.cwd,
|
||||
);
|
||||
if (!update) return;
|
||||
if (options?.parentToolUseId) {
|
||||
stampParentToolUseId(update, options.parentToolUseId);
|
||||
}
|
||||
// The final result may replace a pinned approval patch.
|
||||
if (
|
||||
options?.toolCallFields &&
|
||||
!options.toolCallFields.apply(update, { replacePinnedContent: true })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
await client.sessionUpdate({ sessionId, update });
|
||||
},
|
||||
onRelease: () => options?.toolCallFields?.finishHook(hookToolCallId),
|
||||
},
|
||||
sessionId,
|
||||
);
|
||||
}
|
||||
|
||||
let rawInput;
|
||||
try {
|
||||
rawInput = JSON.parse(JSON.stringify(chunk.input));
|
||||
} catch {
|
||||
// ignore if we can't turn it to JSON
|
||||
}
|
||||
const toolUse = { id: chunk.id, name: chunk.name, input: rawInput };
|
||||
|
||||
// Emit a `tool_call` only the first time this id surfaces to the
|
||||
// client; afterwards refine it with a `tool_call_update`. The first
|
||||
// surface may be this stream chunk OR an earlier permission request
|
||||
// (see `ensureToolCallEmitted`), so emission is tracked separately
|
||||
// from `toolUseCache`. Without an `emittedToolCalls` set we fall back
|
||||
// to cache presence — the historical streaming-only behavior.
|
||||
const emittedToolCalls = options?.emittedToolCalls;
|
||||
const alreadyEmitted = emittedToolCalls ? emittedToolCalls.has(chunk.id) : alreadyCached;
|
||||
emittedToolCalls?.add(chunk.id);
|
||||
|
||||
// A permission request surfaced the tool call with its complete
|
||||
// input. The empty input at the stream start has nothing to add.
|
||||
if (alreadyEmitted && options?.inputComplete === false) break;
|
||||
update = alreadyEmitted
|
||||
? // Already surfaced (full assistant message after streaming, or a
|
||||
// permission request emitted it first): refine it with the
|
||||
// complete input.
|
||||
renderer.refinement(toolUse, options?.cwd)
|
||||
: // First surface (streaming content_block_start or replay).
|
||||
renderer.toolCall(toolUse, {
|
||||
cwd: options?.cwd,
|
||||
inputComplete: options?.inputComplete,
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "advisor_tool_result": {
|
||||
// Advisor results arrive in assistant content, including encrypted advice.
|
||||
// Resolve the server tool through the normal result path without rendering
|
||||
// opaque ciphertext. Server tools have no later PostToolUse hook to await.
|
||||
const result = chunk.content;
|
||||
unregisterHookCallback(chunk.tool_use_id);
|
||||
output.push(
|
||||
...toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: chunk.tool_use_id,
|
||||
is_error: result.type === "advisor_tool_result_error",
|
||||
content:
|
||||
result.type === "advisor_result"
|
||||
? result.text
|
||||
: result.type === "advisor_redacted_result"
|
||||
? "Advisor guidance applied server-side."
|
||||
: `Advisor error: ${result.error_code}`,
|
||||
},
|
||||
],
|
||||
role,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
options,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
case "tool_search_tool_result":
|
||||
case "web_fetch_tool_result":
|
||||
case "web_search_tool_result":
|
||||
case "code_execution_tool_result":
|
||||
case "bash_code_execution_tool_result":
|
||||
case "text_editor_code_execution_tool_result":
|
||||
case "mcp_tool_result": {
|
||||
const wasEmitted = options?.emittedToolCalls?.has(chunk.tool_use_id) === true;
|
||||
options?.emittedToolCalls?.delete(chunk.tool_use_id);
|
||||
completeHookCallback(chunk.tool_use_id);
|
||||
finishedToolCallId = chunk.tool_use_id;
|
||||
// Why this is_error result carries harness prose instead of tool
|
||||
// output (user-rejected / interrupted / …), when the SDK said so.
|
||||
// Spread into the claudeCode meta of every update emitted below; the
|
||||
// untracked-tool fallback can't carry it (claudeCode metas always
|
||||
// carry `toolName`, which is unknown there).
|
||||
const nonExecution = toolResultMeta?.get(chunk.tool_use_id);
|
||||
const toolUse = toolUseCache[chunk.tool_use_id];
|
||||
if (!toolUse) {
|
||||
// The permission flow may have surfaced this tool_call even though
|
||||
// its tool_use never reached the cache (e.g. the assistant message
|
||||
// carrying it was dropped by the cancelled-turn guard and a straggler
|
||||
// result landed later). Resolve the surfaced call anyway so it can't
|
||||
// stay pending in the client forever; without the cache entry the
|
||||
// tool name is unknown, so no claudeCode meta is attached.
|
||||
if (wasEmitted) {
|
||||
output.push({
|
||||
sessionId,
|
||||
update: {
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update" as const,
|
||||
status: renderer.resultStatus(
|
||||
"is_error" in chunk && chunk.is_error === true,
|
||||
nonExecution,
|
||||
{ cancelled: options?.cancelled },
|
||||
),
|
||||
rawOutput: chunk.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
logger.error(
|
||||
`[claude-agent-acp] Got a tool result for tool use that wasn't tracked: ${chunk.tool_use_id}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
// A permission request may have surfaced a plan-rendered (TodoWrite) or
|
||||
// suppressed (Task*) tool as a real tool_call so the request referenced
|
||||
// a tool call the client knows about (see `ensureToolCallEmitted`,
|
||||
// issue #851). The branches below never emit a tool_call_update for
|
||||
// those tools, which would leave the surfaced call pending in the
|
||||
// client forever — resolve it here. `wasEmitted` is only ever true for
|
||||
// these tools via the permission flow: the streamed plan/suppressed
|
||||
// branches don't record emissions.
|
||||
if (wasEmitted && !shouldEmitToolCall(toolUse.name)) {
|
||||
output.push({
|
||||
sessionId,
|
||||
update: {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: toolUse.name,
|
||||
...(nonExecution ?? {}),
|
||||
...(options?.parentToolUseId ? { parentToolUseId: options.parentToolUseId } : {}),
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: chunk.tool_use_id,
|
||||
sessionUpdate: "tool_call_update" as const,
|
||||
status: renderer.resultStatus(
|
||||
"is_error" in chunk && chunk.is_error === true,
|
||||
nonExecution,
|
||||
{ cancelled: options?.cancelled },
|
||||
),
|
||||
rawOutput: chunk.content,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (isTaskTool(toolUse.name)) {
|
||||
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
||||
// TaskCreate / TaskUpdate mutate the accumulated task list. TaskList
|
||||
// reconciles it from the SDK's authoritative snapshot, which repairs
|
||||
// resumed or compacted sessions whose creating calls are no longer in
|
||||
// replay history. TaskGet is read-only and remains suppressed. Plan
|
||||
// updates always carry the full accumulated snapshot, mirroring the
|
||||
// legacy TodoWrite behavior.
|
||||
const isError = "is_error" in chunk && chunk.is_error;
|
||||
let shouldEmitTaskPlan = false;
|
||||
if (!isError) {
|
||||
if (toolUse.name === "TaskCreate") {
|
||||
applyTaskCreate(
|
||||
taskState,
|
||||
toolUse.input as Parameters<typeof applyTaskCreate>[1],
|
||||
parseTaskCreateOutput(toolUseResult) ?? parseTaskCreateOutput(chunk.content),
|
||||
);
|
||||
shouldEmitTaskPlan = true;
|
||||
} else if (toolUse.name === "TaskUpdate") {
|
||||
const input = toolUse.input as Parameters<typeof applyTaskUpdate>[1];
|
||||
const output =
|
||||
parseTaskUpdateOutput(toolUseResult, input?.taskId) ??
|
||||
parseTaskUpdateOutput(chunk.content, input?.taskId);
|
||||
// Older CLI transcripts have no structured output, so retain the
|
||||
// input-based fallback. When an output is available, only apply a
|
||||
// confirmed update for the same task.
|
||||
if (!output || (output.success && output.taskId === input?.taskId)) {
|
||||
applyTaskUpdate(taskState, input);
|
||||
shouldEmitTaskPlan = true;
|
||||
}
|
||||
} else if (toolUse.name === "TaskList") {
|
||||
const output =
|
||||
parseTaskListOutput(toolUseResult) ?? parseTaskListOutput(chunk.content);
|
||||
if (output) {
|
||||
applyTaskList(taskState, output);
|
||||
shouldEmitTaskPlan = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const entries = shouldEmitTaskPlan
|
||||
? changedTaskPlanEntries(taskState, renderer.capabilities.air.client)
|
||||
: undefined;
|
||||
if (entries) {
|
||||
update = checklistPlanUpdate(entries, options?.clientCapabilities?.plan != null);
|
||||
}
|
||||
} else if (toolUse.name !== "TodoWrite") {
|
||||
// A command sends its output first, then the exit and the status.
|
||||
const [finalUpdate, ...rest] = renderer
|
||||
.result(toolUse, chunk as Parameters<AcpToolCallRenderer["result"]>[1], {
|
||||
structured: toolUseResult,
|
||||
nonExecution,
|
||||
cancelled: options?.cancelled,
|
||||
})
|
||||
.reverse();
|
||||
for (const outputUpdate of rest.reverse()) {
|
||||
if (options?.parentToolUseId) {
|
||||
stampParentToolUseId(outputUpdate, options.parentToolUseId);
|
||||
}
|
||||
output.push({ sessionId, update: outputUpdate });
|
||||
}
|
||||
// An approved plan of the main conversation becomes the session's
|
||||
// markdown plan, after the tool call's result. An approval that
|
||||
// clears the context ends the tool with an error result instead, and
|
||||
// the restart into the fresh context publishes that plan (see
|
||||
// ExitPlanCoordinator.restart).
|
||||
if (
|
||||
toolUse.name === "ExitPlanMode" &&
|
||||
options?.clientCapabilities?.plan &&
|
||||
!options.parentToolUseId &&
|
||||
!("is_error" in chunk && chunk.is_error)
|
||||
) {
|
||||
const plan = approvedPlan(toolUse.input, toolUseResult);
|
||||
if (plan) {
|
||||
approvedPlanUpdate = planModeUpdate(
|
||||
plan,
|
||||
renderer.capabilities.air.client ? toolUse.id : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
update = finalUpdate;
|
||||
}
|
||||
// The tool_use is fully resolved now — drop it so a long session doesn't
|
||||
// retain every tool call. The PostToolUse hook (Edit/Write diffs) closes
|
||||
// over the tool name and no longer reads the cache, so pruning here is
|
||||
// safe regardless of hook/result ordering.
|
||||
delete toolUseCache[chunk.tool_use_id];
|
||||
break;
|
||||
}
|
||||
|
||||
case "document":
|
||||
case "search_result":
|
||||
case "redacted_thinking":
|
||||
case "input_json_delta":
|
||||
case "citations_delta":
|
||||
case "signature_delta":
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "fallback":
|
||||
case "mcp_tool_listing":
|
||||
break;
|
||||
|
||||
default:
|
||||
unreachable(chunk, logger);
|
||||
break;
|
||||
}
|
||||
if (update) {
|
||||
if (options?.parentToolUseId) stampParentToolUseId(update, options.parentToolUseId);
|
||||
applyMessageId(update, options?.messageId);
|
||||
// A tool result is final, so it may replace a pinned approval patch,
|
||||
// for example with the error text of a rejected Edit.
|
||||
if (
|
||||
!options?.toolCallFields ||
|
||||
options.toolCallFields.apply(update, {
|
||||
replacePinnedContent: finishedToolCallId !== undefined,
|
||||
})
|
||||
) {
|
||||
output.push({ sessionId, update });
|
||||
}
|
||||
}
|
||||
if (approvedPlanUpdate) output.push({ sessionId, update: approvedPlanUpdate });
|
||||
if (finishedToolCallId !== undefined) {
|
||||
// The PostToolUse hook can still send the final diff, so the fields
|
||||
// stay tracked until its callback leaves the registry.
|
||||
options?.toolCallFields?.finishResult(
|
||||
finishedToolCallId,
|
||||
hasHookCallback(finishedToolCallId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
export function streamEventToAcpNotifications(
|
||||
message: SDKPartialAssistantMessage,
|
||||
sessionId: string,
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AcpClient,
|
||||
logger: Logger,
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
// See toAcpNotifications.
|
||||
toolCallCapabilities?: ToolCallClientCapabilities;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
subagentChecklists?: SubagentChecklists;
|
||||
emittedToolCalls?: Set<string>;
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
messageId?: string;
|
||||
streamedToolInputs?: StreamedToolInputCache;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
const streamKey = message.parent_tool_use_id ?? "";
|
||||
const streamedToolInputs = options?.streamedToolInputs;
|
||||
const forwardedOptions = {
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
toolCallCapabilities: options?.toolCallCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
subagentChecklists: options?.subagentChecklists,
|
||||
emittedToolCalls: options?.emittedToolCalls,
|
||||
toolCallFields: options?.toolCallFields,
|
||||
messageId: options?.messageId,
|
||||
};
|
||||
switch (event.type) {
|
||||
case "content_block_start": {
|
||||
const block = event.content_block;
|
||||
if (
|
||||
streamedToolInputs &&
|
||||
(block.type === "tool_use" ||
|
||||
block.type === "server_tool_use" ||
|
||||
block.type === "mcp_tool_use")
|
||||
) {
|
||||
let inputsForMessage = streamedToolInputs.get(streamKey);
|
||||
if (!inputsForMessage) {
|
||||
inputsForMessage = new Map();
|
||||
streamedToolInputs.set(streamKey, inputsForMessage);
|
||||
}
|
||||
inputsForMessage.set(event.index, {
|
||||
id: block.id,
|
||||
name: block.name,
|
||||
partialJson: "",
|
||||
inString: false,
|
||||
escaped: false,
|
||||
objectDepth: 0,
|
||||
arrayDepth: 0,
|
||||
lastTopLevelComma: -1,
|
||||
emittedThroughComma: -1,
|
||||
});
|
||||
}
|
||||
// The input of a streamed tool_use starts empty and streams after this
|
||||
// event, so the tool_call waits for the complete input.
|
||||
return toAcpNotifications([block], "assistant", sessionId, toolUseCache, client, logger, {
|
||||
...forwardedOptions,
|
||||
inputComplete: false,
|
||||
});
|
||||
}
|
||||
case "content_block_delta": {
|
||||
if (event.delta.type === "input_json_delta") {
|
||||
const streamedInput = streamedToolInputs?.get(streamKey)?.get(event.index);
|
||||
if (!streamedInput) return [];
|
||||
|
||||
if (scanStreamedToolInput(streamedInput, event.delta.partial_json)) {
|
||||
// Input complete: the consolidated assistant message replays the
|
||||
// block with its full input and refines the call there; emitting
|
||||
// here too would send a duplicate identical update.
|
||||
const inputsForMessage = streamedToolInputs?.get(streamKey);
|
||||
inputsForMessage?.delete(event.index);
|
||||
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
|
||||
return [];
|
||||
}
|
||||
if (streamedInput.lastTopLevelComma <= streamedInput.emittedThroughComma) {
|
||||
return [];
|
||||
}
|
||||
streamedInput.emittedThroughComma = streamedInput.lastTopLevelComma;
|
||||
const input = recoveredToolInput(
|
||||
streamedInput.partialJson.slice(0, streamedInput.lastTopLevelComma),
|
||||
);
|
||||
if (!input) return [];
|
||||
// TodoWrite and the Task* tools never surfaced a tool_call to refine.
|
||||
if (!shouldEmitToolCall(streamedInput.name)) return [];
|
||||
const update: SessionNotification["update"] = toolCallRenderer(options).partialRefinement(
|
||||
streamedInput,
|
||||
input,
|
||||
options?.cwd,
|
||||
);
|
||||
if (message.parent_tool_use_id) stampParentToolUseId(update, message.parent_tool_use_id);
|
||||
applyMessageId(update, options?.messageId);
|
||||
// A refinement resends only what changed: rawInput grows with every
|
||||
// field, and title, kind, and locations usually stay the same.
|
||||
if (options?.toolCallFields && !options.toolCallFields.apply(update)) return [];
|
||||
return [{ sessionId, update }];
|
||||
}
|
||||
return toAcpNotifications(
|
||||
[event.delta],
|
||||
"assistant",
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
client,
|
||||
logger,
|
||||
forwardedOptions,
|
||||
);
|
||||
}
|
||||
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
||||
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
||||
// wire format emits it; the `as never` cast lets us no-op it here
|
||||
// instead of letting it fall through to `unreachable`.
|
||||
case "ping" as never:
|
||||
case "message_delta":
|
||||
return [];
|
||||
// A message boundary ends every input stream on this lane: message_stop is
|
||||
// the normal end, and a message_start clears anything a prior message on
|
||||
// the lane left behind (e.g. a stream cut short mid-block).
|
||||
case "message_start":
|
||||
case "message_stop":
|
||||
streamedToolInputs?.delete(streamKey);
|
||||
return [];
|
||||
case "content_block_stop": {
|
||||
const inputsForMessage = streamedToolInputs?.get(streamKey);
|
||||
inputsForMessage?.delete(event.index);
|
||||
if (inputsForMessage?.size === 0) streamedToolInputs?.delete(streamKey);
|
||||
return [];
|
||||
}
|
||||
|
||||
default:
|
||||
unreachable(event, logger);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
24
src/serve.ts
24
src/serve.ts
@@ -1,7 +1,8 @@
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2";
|
||||
import { v1AgentApp, type ClaudeAcpAgent, type Logger } from "./acp-agent.js";
|
||||
import { v1AgentApp, type ClaudeAcpAgent } from "./v1/acp-agent.js";
|
||||
import type { Logger } from "./logger.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 +33,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 +54,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;
|
||||
};
|
||||
@@ -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 (`v1/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 `v1/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] };
|
||||
}
|
||||
}
|
||||
124
src/slash-commands.ts
Normal file
124
src/slash-commands.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* The `available_commands_update` list: the slash commands Claude Code reports,
|
||||
* minus the ones ACP clients can't run, plus the commands the adapter adds.
|
||||
*/
|
||||
import type { AvailableCommand } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Query, SlashCommand } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_KIND_KEY, AIR_SKILL_PATH_KEY, withAirMeta } from "./air-extension.js";
|
||||
import { MCP_AVAILABLE_COMMAND } from "./mcp-command.js";
|
||||
import {
|
||||
claudeCodeOffersCommand,
|
||||
REMOTE_CONTROL_COMMAND_NAME,
|
||||
supportsRemoteControl,
|
||||
} from "./remote-control.js";
|
||||
import { resolveSkillPath, type SkillScanCache } from "./tool-calls/reporters/interaction.js";
|
||||
|
||||
export function getAvailableSlashCommands(
|
||||
commands: SlashCommand[],
|
||||
// Names the CLI tagged terminal-bound on `system`/init (their UX lives in
|
||||
// the CLI's own terminal, which ACP clients aren't) — filtered alongside
|
||||
// the static list. Raw CLI names, matched before the MCP rename.
|
||||
terminalCommands?: readonly string[],
|
||||
// The session cwd of an AIR client, or undefined for every other client.
|
||||
// AIR gets the kind and the SKILL.md path of each skill command.
|
||||
airSkillCwd?: string,
|
||||
// The resolved SKILL.md paths of the session, keyed by the cwd and the name.
|
||||
skillPaths?: Map<string, string | undefined>,
|
||||
query?: Query,
|
||||
// The skill files Claude Code reports (`getContextUsage().skills.skillFrontmatter`).
|
||||
// A listed skill gets its path from here; one that is not listed, or has no
|
||||
// path (remote connections), is looked up on disk.
|
||||
skillFiles?: ReadonlyMap<string, { path?: string }>,
|
||||
): AvailableCommand[] {
|
||||
// The container listings of this one pass, so a list of a hundred commands lists each container once.
|
||||
const skillScans: SkillScanCache = new Map();
|
||||
const skillPath = (name: string, cwd: string): string | undefined => {
|
||||
const key = `${cwd}\0${name}`;
|
||||
if (skillPaths?.has(key)) return skillPaths.get(key);
|
||||
const resolved = resolveSkillPath(name, cwd, skillScans);
|
||||
skillPaths?.set(key, resolved);
|
||||
return resolved;
|
||||
};
|
||||
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"clear",
|
||||
"cost",
|
||||
"keybindings-help",
|
||||
"login",
|
||||
"logout",
|
||||
"output-style:new",
|
||||
"release-notes",
|
||||
"todos",
|
||||
];
|
||||
|
||||
const remoteControlCommands: AvailableCommand[] =
|
||||
query && supportsRemoteControl(query)
|
||||
? [
|
||||
{
|
||||
name: REMOTE_CONTROL_COMMAND_NAME,
|
||||
description:
|
||||
"Continue this session from claude.ai/code or the Claude mobile app; `off` disconnects",
|
||||
input: { hint: "[session name | off]" },
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
// The adapter replaces the terminal text of `/mcp` and runs its actions,
|
||||
// even when Claude Code tags the command terminal-bound.
|
||||
const advertised = commands
|
||||
.filter((command) => command.name !== "mcp" && !terminalCommands?.includes(command.name))
|
||||
.map((command) => {
|
||||
const input = command.argumentHint
|
||||
? {
|
||||
hint: Array.isArray(command.argumentHint)
|
||||
? command.argumentHint.join(" ")
|
||||
: command.argumentHint,
|
||||
}
|
||||
: null;
|
||||
let name = command.name;
|
||||
const mcpPrompt = command.name.endsWith(" (MCP)");
|
||||
if (mcpPrompt) {
|
||||
name = `mcp:${name.replace(" (MCP)", "")}`;
|
||||
}
|
||||
const skillFile = airSkillCwd && !mcpPrompt ? skillFiles?.get(command.name) : undefined;
|
||||
const skillMdPath =
|
||||
skillFile?.path ??
|
||||
(airSkillCwd && !command.builtin && !mcpPrompt
|
||||
? skillPath(command.name, airSkillCwd)
|
||||
: undefined);
|
||||
return {
|
||||
name,
|
||||
description: command.description || "",
|
||||
input,
|
||||
...(skillMdPath
|
||||
? {
|
||||
_meta: withAirMeta(
|
||||
withAirMeta(undefined, AIR_KIND_KEY, "skill"),
|
||||
AIR_SKILL_PATH_KEY,
|
||||
skillMdPath,
|
||||
),
|
||||
}
|
||||
: skillFile
|
||||
? // A skill without a file (bundled with the CLI, like `simplify`, or a remote connection): kind only.
|
||||
{ _meta: withAirMeta(undefined, AIR_KIND_KEY, "skill") }
|
||||
: {}),
|
||||
};
|
||||
})
|
||||
.filter((command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name));
|
||||
|
||||
// Command names must not contain whitespace and must be unique. A name
|
||||
// like "my command" is dropped, and of two commands named "review" only the
|
||||
// first is kept.
|
||||
const names = new Set<string>();
|
||||
return [
|
||||
...advertised,
|
||||
MCP_AVAILABLE_COMMAND,
|
||||
...remoteControlCommands.filter(
|
||||
(command) => !claudeCodeOffersCommand(commands, terminalCommands, command.name),
|
||||
),
|
||||
].filter((command) => {
|
||||
if (/\s/.test(command.name) || names.has(command.name)) return false;
|
||||
names.add(command.name);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
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" }>;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* The `<task-notification>` inputs that Claude Code writes to a transcript
|
||||
* when a background task stops. The replay of a session (`acp-agent.ts`) and
|
||||
* the session index (`session-index/background-tasks.ts`) read them here, so
|
||||
* when a background task stops. The replay of a session (`v1/acp-agent.ts`) and
|
||||
* the session index (`v1/session-index/background-tasks.ts`) read them here, so
|
||||
* both read the same fields.
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { querySpy } = vi.hoisted(() => ({
|
||||
@@ -84,7 +85,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -128,7 +129,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
if (recommended) {
|
||||
(agent as any).clientCapabilities = {
|
||||
@@ -179,7 +180,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
@@ -226,7 +227,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
applyFlagSettings,
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).clientCapabilities = {
|
||||
_meta: { jetbrains: { air: { version: 1, capabilities: ["recommendedValue"] } } },
|
||||
@@ -270,7 +271,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -295,7 +296,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -318,7 +319,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
@@ -362,7 +363,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
@@ -387,7 +388,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -413,7 +414,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
|
||||
@@ -447,7 +448,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
);
|
||||
|
||||
const { getCapturedOptions, setModelSpy } = mockQuery(models);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
(agent as any).logger = { log: () => {}, error: () => {} };
|
||||
return { agent, projectDir, getCapturedOptions, setModelSpy };
|
||||
@@ -646,7 +647,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -673,7 +674,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
|
||||
const { setModelSpy } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -728,7 +729,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -755,7 +756,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -807,7 +808,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "claude-haiku-4-5", displayName: "Claude Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -844,7 +845,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -891,7 +892,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -956,7 +957,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -996,7 +997,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1028,7 +1029,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1050,7 +1051,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1084,7 +1085,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1135,7 +1136,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1196,7 +1197,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1248,7 +1249,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1303,7 +1304,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1354,7 +1355,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1409,7 +1410,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1450,7 +1451,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
@@ -1501,7 +1502,7 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
}) as any;
|
||||
});
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
|
||||
@@ -35,27 +35,31 @@ import {
|
||||
import { toolUpdateFromDiffToolResponse } from "../diff.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import {
|
||||
toAcpNotifications,
|
||||
promptToClaude,
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
stripLocalCommandMetadata,
|
||||
ClaudeAcpAgent,
|
||||
computeSessionFingerprint,
|
||||
streamEventToAcpNotifications,
|
||||
messageIdForGrouping,
|
||||
buildConfigOptions,
|
||||
createFastModeConfigOption,
|
||||
discoverCustomAgents,
|
||||
runPromptWithCancellation,
|
||||
type AcpClient,
|
||||
type SDKMessageFilter,
|
||||
type SteerRequest,
|
||||
} from "../v1/acp-agent.js";
|
||||
import {
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
messageIdForGrouping,
|
||||
type StreamedToolInputCache,
|
||||
} from "../acp-agent.js";
|
||||
} from "../sdk-to-acp.js";
|
||||
import { promptToClaude } from "../prompt-to-claude.js";
|
||||
import {
|
||||
isLocalCommandMetadata,
|
||||
isReplayHiddenMetaMessage,
|
||||
isSyntheticLoginMessage,
|
||||
stripLocalCommandMetadata,
|
||||
} from "../message-filters.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import type { SDKMessageFilter } from "../new-session-meta.js";
|
||||
import { SessionTitles } from "../session-titles.js";
|
||||
import { SessionBackgroundWork } from "../background-work.js";
|
||||
import { SessionBackgroundWork } from "../v1/background-work.js";
|
||||
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { heldRequest, initializeClient, makeMockQuery } from "./helpers.js";
|
||||
@@ -67,7 +71,6 @@ import {
|
||||
getSessionInfo,
|
||||
getSessionMessages,
|
||||
getSubagentMessages,
|
||||
type HookCallback,
|
||||
importSessionToStore,
|
||||
listSessions,
|
||||
type ModelInfo,
|
||||
@@ -92,7 +95,7 @@ import {
|
||||
goalUpdateFromPrompt,
|
||||
parseGoalRequest,
|
||||
toGoalSnapshot,
|
||||
} from "../goal-extension.js";
|
||||
} from "../v1/goal-extension.js";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
@@ -5297,7 +5300,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 +5370,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 +5399,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 +7590,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 +15006,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 +15130,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 +15295,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 +19833,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 +21829,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 +21977,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 +21989,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 +22003,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 +23074,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 +23274,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 +23298,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 +28139,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 +30049,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));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -97,7 +97,7 @@ beforeAll(async () => {
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
Agent = ClaudeAcpAgent;
|
||||
for (const profile of profiles) {
|
||||
for (const scenario of SCENARIOS) {
|
||||
|
||||
@@ -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
@@ -3,7 +3,8 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
@@ -46,7 +47,7 @@ describe("additionalRoots", () => {
|
||||
capturedOptions = undefined;
|
||||
tempDirs.length = 0;
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { randomUUID } from "crypto";
|
||||
import { query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../acp-agent.js";
|
||||
import { discoverCustomAgents, BUILTIN_AGENT_NAMES } from "../v1/acp-agent.js";
|
||||
|
||||
// `discoverCustomAgents` distinguishes user/plugin-configured agents from
|
||||
// Claude Code's built-in subagents by name (BUILTIN_AGENT_NAMES), since the
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { Mock } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType, Logger } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AuthStatus } from "../auth-status.js";
|
||||
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
|
||||
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
|
||||
|
||||
@@ -4,7 +4,8 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import { AcpClient } from "../acp-client.js";
|
||||
import {
|
||||
billsClaudeSubscription,
|
||||
CLAUDE_LOGIN_REQUIRED_MESSAGE,
|
||||
|
||||
@@ -11,11 +11,11 @@ import * as fsSync from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { SessionInfo } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ActivityState } from "../session-index/activity.js";
|
||||
import { endedTaskId, searchBackgroundTasks } from "../session-index/background-tasks.js";
|
||||
import { encodeProjectPath } from "../session-index/project-dirs.js";
|
||||
import { SessionIndex } from "../session-index/session-index.js";
|
||||
import { SessionIndexService } from "../session-index/service.js";
|
||||
import type { ActivityState } from "../v1/session-index/activity.js";
|
||||
import { endedTaskId, searchBackgroundTasks } from "../v1/session-index/background-tasks.js";
|
||||
import { encodeProjectPath } from "../v1/session-index/project-dirs.js";
|
||||
import { SessionIndex } from "../v1/session-index/session-index.js";
|
||||
import { SessionIndexService } from "../v1/session-index/service.js";
|
||||
|
||||
let configDir: string;
|
||||
let workspace: string;
|
||||
|
||||
@@ -6,7 +6,8 @@ import {
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import { getSessionMessages, type Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { ALLOW_BYPASS } from "../permissions/modes.js";
|
||||
import { NativeSubagentRuntime } from "../native-subagents.js";
|
||||
@@ -117,7 +118,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
const acpAgent = await import("../v1/acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
@@ -176,25 +177,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]!;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { Writable } from "node:stream";
|
||||
import { EventLoopYielder, nodeToWebWritable, Pushable } from "../utils.js";
|
||||
import {
|
||||
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
FAST_MODE_ON,
|
||||
FAST_MODE_OFF,
|
||||
ClaudeAcpAgent,
|
||||
type AcpClient,
|
||||
} from "../acp-agent.js";
|
||||
} from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
|
||||
const MODES = {
|
||||
currentModeId: "default",
|
||||
|
||||
@@ -3,13 +3,13 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { AcpClient } from "../acp-client.js";
|
||||
import {
|
||||
AcpClient,
|
||||
StreamedToolInputCache,
|
||||
streamEventToAcpNotifications,
|
||||
toAcpNotifications,
|
||||
ToolUseCache,
|
||||
} from "../acp-agent.js";
|
||||
} from "../sdk-to-acp.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import { createPostToolUseHook } from "../tools.js";
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import {
|
||||
AGENT_FILE_CHANGE_REPORT_CAPABILITY,
|
||||
type AgentFileChangeReportResult,
|
||||
@@ -257,7 +258,7 @@ describe("native file-change report integration", () => {
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AcpClient;
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(client, {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
|
||||
@@ -170,7 +170,7 @@ beforeAll(async () => {
|
||||
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/usr/bin/false");
|
||||
configDir = fs.mkdtempSync(path.join(os.tmpdir(), "acp-scenario-config-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
for (const profile of runProfiles) {
|
||||
resetIds();
|
||||
const run = await unlessHung(runScenario(ClaudeAcpAgent, profile, scenario));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
mockSessionState,
|
||||
|
||||
@@ -4,9 +4,9 @@ import { createHash, randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
import { messageIdForGrouping } from "../acp-agent.js";
|
||||
import { messageIdForGrouping } from "../sdk-to-acp.js";
|
||||
import { forkSession } from "../fork-session.js";
|
||||
import { encodeProjectPath } from "../session-index/project-dirs.js";
|
||||
import { encodeProjectPath } from "../v1/session-index/project-dirs.js";
|
||||
|
||||
// Use the real SDK against temporary transcripts, without starting Claude Code.
|
||||
let root: string;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -9,7 +9,8 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { Options, SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { initializeClient } from "./helpers.js";
|
||||
import { successfulResultMessage } from "./session-doubles.js";
|
||||
|
||||
@@ -227,7 +228,7 @@ function scriptClaudeCode({
|
||||
|
||||
async function connect(clientCapabilities: Record<string, unknown> = {}) {
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const updates: Array<{ update: Record<string, any> }> = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: { update: Record<string, any> }) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { clearHookCallbacks, createPostToolUseHook, hasHookCallback } from "../tools.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@ import type {
|
||||
SessionNotification,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { McpServerStatus } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent, stripLocalCommandMetadata, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import { stripLocalCommandMetadata } from "../message-filters.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import {
|
||||
cleanMcpError,
|
||||
formatMcpStatus,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { promptSuggestionsMeta, promptSuggestionText } from "../prompt-suggestions.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import {
|
||||
|
||||
@@ -3,10 +3,9 @@ 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 { ClaudeAcpAgent, FAST_MODE_CONFIG_ID } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.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 +746,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,7 @@
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import { AcpClient } from "../acp-client.js";
|
||||
import { loggableBaseUrl } from "../providers.js";
|
||||
|
||||
const mockQuery = vi.hoisted(() =>
|
||||
vi.fn(() => ({
|
||||
@@ -922,3 +924,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../acp-agent.js";
|
||||
import { buildConfigOptions, clientSupportsRecommendedConfigValue } from "../v1/acp-agent.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
|
||||
|
||||
const MODES = {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { mockSessionState } from "./session-doubles.js";
|
||||
|
||||
/** A stream that stays open, like the real one: a consumer that reaches its end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { mockSessionState, successfulResultMessage, wrapQuery } from "./session-doubles.js";
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import type { Logger } from "../acp-agent.js";
|
||||
import type { Logger } from "../logger.js";
|
||||
import { resolvePermissionMode } from "../permissions/modes.js";
|
||||
|
||||
function mockLogger() {
|
||||
|
||||
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"));
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
@@ -132,7 +133,7 @@ describe("session config options", () => {
|
||||
registerHookCallbackSpy.mockClear();
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
const acpAgent = await import("../v1/acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
@@ -330,18 +331,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(
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
|
||||
import { vi } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { SessionBackgroundWork } from "../background-work.js";
|
||||
import { SessionBackgroundWork } from "../v1/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;
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { NewSessionRequest } from "@agentclientprotocol/sdk/unstable";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
|
||||
function sse(type: string, data: object): string {
|
||||
return `event: ${type}\ndata: ${JSON.stringify({ type, ...data })}\n\n`;
|
||||
|
||||
@@ -2,28 +2,28 @@ import { describe, expect, it } from "vitest";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { deriveActivity, selectCost, type ActivityState } from "../session-index/activity.js";
|
||||
import { LiveSessionRegistry, type LiveRecord } from "../session-index/live-registry.js";
|
||||
import { deriveActivity, selectCost, type ActivityState } from "../v1/session-index/activity.js";
|
||||
import { LiveSessionRegistry, type LiveRecord } from "../v1/session-index/live-registry.js";
|
||||
import {
|
||||
encodeProjectPath,
|
||||
isSessionId,
|
||||
pathAndAncestors,
|
||||
projectDirMatches,
|
||||
} from "../session-index/project-dirs.js";
|
||||
} from "../v1/session-index/project-dirs.js";
|
||||
import {
|
||||
decodeListCursor,
|
||||
encodeListCursor,
|
||||
parseListOptions,
|
||||
parseRenameSessionRequest,
|
||||
type ListScope,
|
||||
} from "../session-index/service.js";
|
||||
} from "../v1/session-index/service.js";
|
||||
import {
|
||||
effectiveTitle,
|
||||
isArchivedTitle,
|
||||
storedTitle,
|
||||
titleRecords,
|
||||
visibleTitle,
|
||||
} from "../session-index/archive-title.js";
|
||||
} from "../v1/session-index/archive-title.js";
|
||||
import {
|
||||
agentWorktreeOf,
|
||||
continuedInSessionId,
|
||||
@@ -33,11 +33,11 @@ import {
|
||||
sdkTitles,
|
||||
transcriptAgentName,
|
||||
type TranscriptFacts,
|
||||
} from "../session-index/transcript-scan.js";
|
||||
import { firstPrompt } from "../session-index/first-prompt.js";
|
||||
import { DirListings, statFiles } from "../session-index/dir-listing.js";
|
||||
import { SessionIndex, type IndexRow } from "../session-index/session-index.js";
|
||||
import { changeSignature, sessionInfoOf } from "../session-index/session-info.js";
|
||||
} from "../v1/session-index/transcript-scan.js";
|
||||
import { firstPrompt } from "../v1/session-index/first-prompt.js";
|
||||
import { DirListings, statFiles } from "../v1/session-index/dir-listing.js";
|
||||
import { SessionIndex, type IndexRow } from "../v1/session-index/session-index.js";
|
||||
import { changeSignature, sessionInfoOf } from "../v1/session-index/session-info.js";
|
||||
|
||||
const SESSION = "11111111-1111-4111-8111-111111111111";
|
||||
const lines = (...entries: object[]) => entries.map((entry) => JSON.stringify(entry)).join("\n");
|
||||
|
||||
@@ -16,13 +16,14 @@ import {
|
||||
listSessions,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { spawn } from "node:child_process";
|
||||
import { ClaudeAcpAgent, v1AgentApp, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent, v1AgentApp } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import {
|
||||
client as acpClient,
|
||||
type InitializeResponse,
|
||||
type SessionInfo,
|
||||
} from "@agentclientprotocol/sdk/unstable";
|
||||
import { encodeProjectPath } from "../session-index/project-dirs.js";
|
||||
import { encodeProjectPath } from "../v1/session-index/project-dirs.js";
|
||||
import {
|
||||
archiveInsteadOfDelete,
|
||||
archiveTo,
|
||||
@@ -30,16 +31,16 @@ import {
|
||||
parseBatchGetSessionsRequest,
|
||||
SessionIndexService,
|
||||
writeCustomTitleSidecar,
|
||||
} from "../session-index/service.js";
|
||||
import { scanTranscriptFile } from "../session-index/transcript-scan.js";
|
||||
import { repositoryWorktrees } from "../session-index/worktrees.js";
|
||||
} from "../v1/session-index/service.js";
|
||||
import { scanTranscriptFile } from "../v1/session-index/transcript-scan.js";
|
||||
import { repositoryWorktrees } from "../v1/session-index/worktrees.js";
|
||||
import {
|
||||
ListSubscriptions,
|
||||
MAX_SUBSCRIPTIONS,
|
||||
parseListSubscribeRequest,
|
||||
parseListUnsubscribeRequest,
|
||||
type ListChanges,
|
||||
} from "../session-index/list-subscriptions.js";
|
||||
} from "../v1/session-index/list-subscriptions.js";
|
||||
import { SessionTitles } from "../session-titles.js";
|
||||
import { initializeClient } from "./helpers.js";
|
||||
import {
|
||||
@@ -164,7 +165,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 +176,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 +318,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 +736,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", () => {
|
||||
@@ -1872,7 +1854,7 @@ describe("_session/list/subscribe", () => {
|
||||
});
|
||||
|
||||
it("clears the failed turn of a session that runs here when a new turn starts", async () => {
|
||||
const { noteSessionState } = await import("../session-index/connection.js");
|
||||
const { noteSessionState } = await import("../v1/session-index/connection.js");
|
||||
const session = { lastTurnFailed: true } as any;
|
||||
noteSessionState(session, "idle", "running");
|
||||
expect(session.lastTurnFailed).toBe(false);
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, it, expect } from "vitest";
|
||||
import { randomUUID } from "crypto";
|
||||
import { RequestError, SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { query, getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import { AcpClient } from "../acp-client.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { makeMockQuery } from "./helpers.js";
|
||||
|
||||
const SESSION_ID = "test-session-id";
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { ClaudeAcpAgent, type AcpClient } from "../acp-agent.js";
|
||||
import { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import { appendTitleContext, sanitizeTitle } from "../session-titles.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { getSessionInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
@@ -115,6 +116,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 +205,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 +228,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 +252,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"]);
|
||||
});
|
||||
|
||||
@@ -3,10 +3,11 @@ import * as fs from "node:fs";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import { AcpClient, toAcpNotifications, ToolUseCache } from "../acp-agent.js";
|
||||
import { AcpClient } from "../acp-client.js";
|
||||
import { toAcpNotifications, ToolUseCache } from "../sdk-to-acp.js";
|
||||
import { ClientCapabilities as ToolCallCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
|
||||
import { ChangedMetaFilter } from "../tool-calls/changed-meta-filter.js";
|
||||
import { ChangedMetaFilter } from "../v1/tool-calls/changed-meta-filter.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import { AcpToolCallRenderer, mcpToolName } from "../tool-calls/renderer.js";
|
||||
import { changedTaskPlanEntries, createTaskHook, type TaskState } from "../tools.js";
|
||||
|
||||
@@ -22,7 +22,9 @@ import {
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { AcpClient, toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import { AcpClient } from "../acp-client.js";
|
||||
import { toAcpNotifications, ToolUseCache } from "../sdk-to-acp.js";
|
||||
import { Logger } from "../logger.js";
|
||||
import {
|
||||
toolUpdateFromToolResult,
|
||||
createPostToolUseHook,
|
||||
|
||||
@@ -4,12 +4,10 @@
|
||||
* 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 { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
import type { ForegroundEvents, TurnEvents } from "../turn-events.js";
|
||||
import type { SessionNotification } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ClaudeAcpAgent } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import type { TurnEvents } from "../v1/turn-events.js";
|
||||
import { DEFAULT_CONTEXT_USAGE } from "./helpers.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
@@ -195,41 +193,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"),
|
||||
};
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
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 +309,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([
|
||||
|
||||
@@ -4,7 +4,8 @@ import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import type { ClientCapabilities } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../v1/acp-agent.js";
|
||||
import type { AcpClient } from "../acp-client.js";
|
||||
import {
|
||||
createUltracodeConfigOption,
|
||||
modelSupportsUltracode,
|
||||
@@ -168,7 +169,7 @@ describe("ultracode session config option", () => {
|
||||
...(opts.getSettings ? { getSettings: opts.getSettings } : {}),
|
||||
}),
|
||||
);
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const { ClaudeAcpAgent } = await import("../v1/acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent({
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
|
||||
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,100 +0,0 @@
|
||||
/**
|
||||
* 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}.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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}.
|
||||
*
|
||||
* When Claude Code reports command lifecycles (`msg_lifecycle_v1`), that is
|
||||
* when the prompt starts a turn, before the turn's output. Otherwise, and
|
||||
* while another turn is still active (held for its background work,
|
||||
* steered, or cancelled but not yet ended), it is the prompt's echo, after
|
||||
* that turn ended; for a command Claude Code does not echo (such as
|
||||
* `/compact`), it is the command's result, after any output it streamed.
|
||||
*/
|
||||
inserted(messageId: string): void;
|
||||
/**
|
||||
* The turn ended. A queued turn that is cancelled before it is inserted
|
||||
* ends too, without {@link inserted}; Claude Code may still run its prompt.
|
||||
*/
|
||||
ended(outcome: TurnOutcome): void;
|
||||
/**
|
||||
* The turn failed with `error`, the JSON-RPC error that v1 answers the
|
||||
* prompt with. Without {@link inserted} before, Claude Code was not seen
|
||||
* taking the prompt in.
|
||||
*/
|
||||
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";
|
||||
|
||||
/** How a turn ended. */
|
||||
export type TurnOutcome = {
|
||||
stopReason: StopReason;
|
||||
/** The tokens that the main agent loop of the turn used. */
|
||||
usage?: TurnUsage;
|
||||
/**
|
||||
* Extension data for the client, such as the quota breakdown of the turn
|
||||
* and a terminal session failure.
|
||||
*/
|
||||
_meta?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type TurnUsage = {
|
||||
totalTokens: number;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
thoughtTokens?: number;
|
||||
cachedReadTokens?: number;
|
||||
cachedWriteTokens?: number;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user