Compare commits

...

10 Commits

Author SHA1 Message Date
Codex
c8749dd76f fix: preserve mappings under a managed model allowlist 2026-10-30 04:24:37 +01:00
Codex
9f543dd1bf test: cover managed model list and override precedence 2026-10-30 04:22:25 +01:00
Codex
fb97d576fb fix: include inline model settings in the model picker 2026-10-30 04:20:14 +01:00
Ben Brandt
2fd46c866d fix: report the fresh context's usage when ExitPlanMode clears context (#1484)
Choosing one of `ExitPlanMode`'s "clear context" options restarts
Claude's conversation with only the plan. The ACP session ID and the
turn stay the same, and the adapter resets its context count to 0, but
it didn't tell the client. Clients kept showing the old context's usage
until the fresh context first reported its own, so nothing on the client
side showed that the context had been cleared.

`publishSessionState` now sends a `usage_update` for the empty fresh
context (`used: 0`, the fresh session's context window as `size`) right
after the `current_mode_update` and `config_option_update` it already
sends. `used: 0` slightly understates the fresh context, which already
holds the system prompt and the plan, until Claude's next usage report.

Testing:
- `npm run build`, `npm run test:run`, and `npm run check` pass.
- `clear-context-coordinator.test.ts` checks the update's order and
values.
2026-10-29 22:09:59 +01:00
acp-release-bot[bot]
0f78dbbdd0 chore(main): release 0.99.1 (#1472)
🤖 I have created a release *beep* *boop*
---


##
[0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1)
(2026-10-29)


### Bug Fixes

* add approval id to plan
([#1469](https://github.com/agentclientprotocol/claude-agent-acp/issues/1469))
([4b4b2c4](4b4b2c4de7))
* allow forking sessions into a different directory
([#1483](https://github.com/agentclientprotocol/claude-agent-acp/issues/1483))
([8bd0bc9](8bd0bc9b03))
* detect "Run /login" login-expired messages as authRequired
([#1481](https://github.com/agentclientprotocol/claude-agent-acp/issues/1481))
([fccaebc](fccaebcf87))
* mark plugin, bundled and nested Claude skills as skills for AIR
([#1473](https://github.com/agentclientprotocol/claude-agent-acp/issues/1473))
([52c241e](52c241e715))
* title subagent sessions by their task
([#1479](https://github.com/agentclientprotocol/claude-agent-acp/issues/1479))
([7dcb629](7dcb62923d))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: acp-release-bot[bot] <246668977+acp-release-bot[bot]@users.noreply.github.com>
2026-10-29 20:34:22 +01:00
Ben Brandt
8bd0bc9b03 fix: allow forking sessions into a different directory (#1483)
Fix `session/fork` when the requested working directory differs from the
directory containing the source session's transcript, such as a
subthread in another worktree.

The Claude Agent SDK's `dir` option restricts **source transcript
lookup**; it does not select the fork's runtime directory. Passing ACP's
destination `cwd` therefore prevents the SDK from finding a source
session stored elsewhere.

- Omit `dir` from both SDK fork calls, active-history lookup, and
full-history import so every fork-resolution path searches all projects.
- Continue starting the forked query with the requested `cwd`, MCP
servers, and other session settings.
- Add 12 real-SDK tests using isolated temporary transcripts, covering
same- and cross-directory forks through latest history, active messages,
inactive branches, fingerprints, and live message maps, plus
missing-message errors. Check copied history, fresh UUIDs, and an
unchanged source transcript.
- Extend ACP v2 coverage to verify same- and cross-directory forks
remain immediately promptable and use the requested runtime directory.

## Validation

- Before the fix, all six cross-directory regression cases failed while
the six same-directory cases passed; after the fix, all 12 pass.
- `npm run check` — passed.
- `npm run build` — passed.
- `npm run test:run -- --reporter=dot --silent` — passed: 60 files,
3,437 tests; 4 files and 30 tests skipped.
- `git diff --check` — passed.

Final validation ran on macOS with Node 24. Earlier Windows validation
passed all fork-related tests but encountered unrelated path-separator
assertions and checkout line-ending formatting failures; those are
outside this change.

No authenticated Claude prompt or end-to-end Delta subthread test was
run. The new transcript tests use the real SDK without starting Claude
Code; ACP query execution is mocked in the protocol test.
2026-10-29 19:20:46 +00:00
Mark Tkachenko
52c241e715 fix: mark plugin, bundled and nested Claude skills as skills for AIR (#1473)
AIR lists a command as a skill only when the adapter finds its
`SKILL.md`, and the resolver only probed `<cwd|~>/.claude/skills`.
Plugin skills, CLI-bundled skills and nested `.claude/skills` therefore
reached AIR as plain commands.

The adapter now takes the paths from Claude Code's own skill list,
`getContextUsage().skills.skillFrontmatter`:
- a skill with a `path` gets `kind: "skill"` and `skillPath`; one
without (bundled `simplify`, remote connections) gets `kind` only;
- a skill not in the list falls back to the existing `SKILL.md` lookup;
builtin commands like `compact` stay unmarked;
- the read runs in the background (the first call takes about 1 s) on
the first `system/init` and on every `commands_changed`, and the command
list is re-sent when the result changes.
2026-10-29 14:42:08 +01:00
Ben Brandt
fccaebcf87 fix: detect "Run /login" login-expired messages as authRequired (#1481)
The CLI now reports an expired login (no refresh token) as:

Login expired · Run /login to sign in again, or re-authenticate your
Anthropic profile

and tags the synthetic assistant message `error: "invalid_request"`, not
`authentication_failed`. The adapter only recognised the exact phrase
"Please run /login", so this message streamed to the client as agent
text and the turn failed with a generic internal error instead of
`RequestError.authRequired()`. Clients never learned auth was required,
so they couldn't offer their terminal auth methods. "Not logged in · Run
/login" had the same gap.

Changes:
- Add `isLoginRequiredText()`, which matches "run /login"
case-insensitively, and use it in `isSyntheticLoginMessage()` (live loop
and replay) and the two result-text checks.
- Exclude synthetic usage-limit banners from the login match, since some
mention `/login` to switch accounts and have their own handling.

Tests cover both new wordings, the usage-limit exclusion, and an
end-to-end turn where the `invalid_request`-tagged "Login expired"
message must reject with `authRequired` and not leak "/login" text to
the client.
2026-10-29 13:17:53 +00:00
Ben Brandt
7dcb62923d fix: title subagent sessions by their task (#1479)
A subagent's `subagent_update.title` was its type (`general-purpose`),
or
`name (type)`, so a client showing the title couldn't tell siblings
apart;
the task was only in `description`. The RFD makes the title the parent's
display label and the description the child's role or purpose.

The title is now the agent's name when it has one, else the task's short
description, else its type. The description is the type, unless the type
is already the title.
2026-10-29 11:16:05 +00:00
Kirill Alekhnovich
4b4b2c4de7 fix: add approval id to plan (#1469)
Include the approval tool call ID in each Air plan ID so the client can
bind the plan to its review. Keep the legacy plan ID for other clients.
2026-10-28 16:34:01 +01:00
25 changed files with 1144 additions and 88 deletions

View File

@@ -1,3 +1,3 @@
{
".": "0.99.0"
".": "0.99.1"
}

View File

@@ -1,5 +1,16 @@
# Changelog
## [0.99.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.99.0...v0.99.1) (2026-10-29)
### Bug Fixes
* add approval id to plan ([#1469](https://github.com/agentclientprotocol/claude-agent-acp/issues/1469)) ([4b4b2c4](https://github.com/agentclientprotocol/claude-agent-acp/commit/4b4b2c4de7c492070f6260cacea5680b2e045a5a))
* allow forking sessions into a different directory ([#1483](https://github.com/agentclientprotocol/claude-agent-acp/issues/1483)) ([8bd0bc9](https://github.com/agentclientprotocol/claude-agent-acp/commit/8bd0bc9b03fb4a30e0b1755128baad12a214a58d))
* detect "Run /login" login-expired messages as authRequired ([#1481](https://github.com/agentclientprotocol/claude-agent-acp/issues/1481)) ([fccaebc](https://github.com/agentclientprotocol/claude-agent-acp/commit/fccaebcf87e8f37144ec1a64920c0c53997edabe))
* mark plugin, bundled and nested Claude skills as skills for AIR ([#1473](https://github.com/agentclientprotocol/claude-agent-acp/issues/1473)) ([52c241e](https://github.com/agentclientprotocol/claude-agent-acp/commit/52c241e71512ec19c4dd79c95c51ce67f04d7aac))
* title subagent sessions by their task ([#1479](https://github.com/agentclientprotocol/claude-agent-acp/issues/1479)) ([7dcb629](https://github.com/agentclientprotocol/claude-agent-acp/commit/7dcb62923d38ac24e386b0626fe123334341c445))
## [0.99.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.98.0...v0.99.0) (2026-10-28)

View File

@@ -515,6 +515,17 @@ While the adapter does not know the path, a report carries no `planFilePath` key
The adapter never sends a blank path, because AIR reads a present non-file value as a retraction.
Without `rawInputRendering`, a `planFile` client gets no copy of the plan in `content` either.
### Approved plan identity
For an AIR client with plan operations, the approved plan ID is `plan-mode:<tool-call ID>`.
Claude uses the SDK tool-call ID of ExitPlanMode.
Each review keeps its own plan. Repeated updates of that review keep the same plan ID.
AIR joins the plan to its review by ID, even when the text includes user edits.
This link needs no extra metadata.
The live result, history replay, and clear-context approval use the same identity.
The identity does not depend on `planFile`: it also applies when the request carries inline plan text.
Other clients keep the `plan-mode` ID.
### Fallback
The plan text goes out as before in these cases:

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.99.0",
"version": "0.99.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@agentclientprotocol/claude-agent-acp",
"version": "0.99.0",
"version": "0.99.1",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "2.1.0",

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.99.0",
"version": "0.99.1",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",

View File

@@ -1148,6 +1148,20 @@ export type Session = {
* the command name. A `commands_changed` clears it, because the skill
* files can have changed then. */
skillPaths?: Map<string, string | undefined>;
/** The skill files Claude Code reports in `getContextUsage().skills.skillFrontmatter`,
* keyed by the skill name as the slash command is named (`plug:pskill`).
* `path` is the SKILL.md file, absent for a skill bundled with the CLI and
* on remote connections. Filled by
* {@link ClaudeAcpAgent.refreshSkillFiles}; undefined until the first read. */
skillFiles?: Map<string, { path?: string }>;
/** True once the first `system`/init has started the first skill file read,
* so the per-turn init re-emit does not start another. */
skillFilesRequested?: boolean;
/** A skill file read is running; a trigger meanwhile sets `skillFilesRerun`. */
skillFilesRefreshing?: boolean;
/** A trigger came in during a running read, which may have read the state
* from before it, so one more read follows. */
skillFilesRerun?: boolean;
/** Serialized `system`/init `plugin_errors` last logged, so the per-turn
* init re-emit logs a plugin load failure once, not every turn. */
loggedPluginErrors?: string;
@@ -2319,6 +2333,17 @@ function isTaskNotificationRecord(message: unknown): boolean {
return origin?.kind === "task-notification" && origin.subkind === undefined;
}
/**
* True when CLI error text tells the user to sign in with `/login`. The CLI
* words this several ways — "Not logged in · Please run /login", "Not logged
* in · Run /login", "Login expired · Run /login to sign in again, or
* re-authenticate your Anthropic profile" — so match the "run /login"
* instruction rather than one sentence.
*/
export function isLoginRequiredText(text: string): boolean {
return /\brun \/login\b/i.test(text);
}
/**
* 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",
@@ -2331,9 +2356,13 @@ function isTaskNotificationRecord(message: unknown): boolean {
* 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: "authentication_failed"`
* marker is stripped by `getSessionMessages`, so the synthetic model + text is
* all both paths have to match on.
* `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") {
@@ -2348,7 +2377,8 @@ export function isSyntheticLoginMessage(apiMessage: unknown): boolean {
!!block &&
block.type === "text" &&
typeof block.text === "string" &&
block.text.includes("Please run /login")
isLoginRequiredText(block.text) &&
!isSyntheticUsageLimitMessage(apiMessage)
);
}
@@ -2838,6 +2868,7 @@ export class ClaudeAcpAgent {
applyUltracode: (session, enabled) => this.applyUltracode(session, enabled),
sessionUpdate: (notification) => this.client.sessionUpdate(notification),
supportsPlanOperations: () => this.clientCapabilities?.plan != null,
isAirClient: () => this.toolCallCapabilities.air.client,
ensureConsumer: (session, id) => this.ensureConsumer(session, id),
logError: (message, error) => this.logger.error(message, error),
destroyReplacement: (id, session) => {
@@ -6178,6 +6209,12 @@ export class ClaudeAcpAgent {
message.fast_mode_state,
message.fast_mode_disabled_reason,
);
// The skill files are read once per session here; commands_changed
// reads them again. Not awaited, the read takes about a second.
if (!session.skillFilesRequested) {
session.skillFilesRequested = true;
void this.refreshSkillFiles(params.sessionId);
}
// Terminal-bound slash commands (absent when none, and on
// older CLIs). The session/new advertisement runs before any
// init frame can be observed, so the first latch (or a
@@ -6514,9 +6551,11 @@ export class ClaudeAcpAgent {
this.toolCallCapabilities.air.client ? session.cwd : undefined,
session.skillPaths,
session.query,
session.skillFiles,
),
},
});
void this.refreshSkillFiles(params.sessionId);
break;
}
case "mirror_error": {
@@ -7104,7 +7143,7 @@ export class ClaudeAcpAgent {
message.stop_reason === "refusal" ||
("result" in message &&
message.is_error &&
message.result.includes("Please run /login"));
isLoginRequiredText(message.result));
if (goalCommandFailed) {
await this.publishGoal(params.sessionId, pendingGoalUpdate.previous ?? null);
}
@@ -7334,7 +7373,7 @@ export class ClaudeAcpAgent {
// drained hold at their results is as good as the idle
// fallback.) Then stop: everything below is user-turn
// lifecycle, and an autonomous outcome must never touch it —
// its is_error or "Please run /login" text would otherwise
// its is_error or "run /login" text would otherwise
// failActive a live turn (the held one, or the user's next
// prompt) whose own result recorded a different outcome.
if (isAutonomousResult) {
@@ -7588,7 +7627,7 @@ export class ClaudeAcpAgent {
switch (message.subtype) {
case "success": {
if (message.is_error && message.result.includes("Please run /login")) {
if (message.is_error && isLoginRequiredText(message.result)) {
await failActiveWithSessionFailure(
"auth_required",
RequestError.authRequired(),
@@ -9677,7 +9716,7 @@ export class ClaudeAcpAgent {
replaySession.messageIdToUuid.set(replayMessageId, message.uuid);
}
// The live prompt loop converts the synthetic "Please run /login"
// The live prompt loop converts the synthetic "run /login"
// assistant message into an authRequired error instead of showing its
// TUI-specific text; skip it on replay too (issue #863).
if (message.type === "assistant" && isSyntheticLoginMessage(message.message)) {
@@ -10980,9 +11019,54 @@ export class ClaudeAcpAgent {
this.toolCallCapabilities.air.client ? session.cwd : undefined,
(session.skillPaths ??= new Map()),
session.query,
session.skillFiles,
);
}
/**
* Reads the skill files from Claude Code's own skill list
* (`getContextUsage().skills.skillFrontmatter`) and, when they differ from
* the last read, re-publishes the slash commands with the skill paths. Only
* for AIR clients, the only ones that use the paths.
*
* Callers do not await it: the control request takes about 1 s on the first
* call, and it must not delay the turn or the first command list. A trigger
* during a running read does not start a parallel request; one more read
* runs after it. Errors are logged, never thrown. Older CLIs lack the method.
*/
private async refreshSkillFiles(sessionId: string): Promise<void> {
const session = this.sessions[sessionId];
if (!session || !this.toolCallCapabilities.air.client) return;
if (typeof session.query.getContextUsage !== "function") return;
if (session.skillFilesRefreshing) {
session.skillFilesRerun = true;
return;
}
session.skillFilesRefreshing = true;
try {
do {
session.skillFilesRerun = false;
const usage = await session.query.getContextUsage();
if (this.sessions[sessionId] !== session) return;
const skillFiles = new Map<string, { path?: string }>();
for (const skill of usage.skills?.skillFrontmatter ?? []) {
// A prompt command of `.claude/commands` (`foo.md`) is no skill, even if Claude Code lists it here.
if (skill.path && path.basename(skill.path) !== "SKILL.md") continue;
skillFiles.set(skill.name, { path: skill.path });
}
if (JSON.stringify([...skillFiles]) !== JSON.stringify([...(session.skillFiles ?? [])])) {
session.skillFiles = skillFiles;
session.skillPaths = new Map();
await this.sendAvailableCommandsUpdate(sessionId);
}
} while (session.skillFilesRerun);
} catch (error) {
this.logger.error(`Failed to read the skill files: ${error}`);
} finally {
session.skillFilesRefreshing = false;
}
}
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
if (!this.sessions[sessionId]) return;
await this.client.sessionUpdate({
@@ -12134,15 +12218,15 @@ export class ClaudeAcpAgent {
}
}
// Apply user's `availableModels` allowlist from settings.json before any
// Apply the effective `availableModels` allowlist before any
// downstream model handling. `initializationResult.models` is already
// policy-filtered by the CLI; we rebuild the picker from the allowlist
// so the user's exact spellings (and `modelOverrides` targets) are the
// values passed to `setModel`, keeping configOptions, the current-model
// resolver, and the stored modelInfos consistent with what the user
// configured. Managed `deniedModels` drops entries the CLI would refuse.
const settingsAvailableModels = settingsManager.getSettings().availableModels;
const settingsModelOverrides = settingsManager.getSettings().modelOverrides;
const { availableModels: settingsAvailableModels, modelOverrides: settingsModelOverrides } =
settingsManager.getModelSelectionSettings(configuredSettingsObject);
const settingsAllowedModels = Array.isArray(settingsAvailableModels)
? applyAvailableModelsAllowlist(
initializationResult.models,
@@ -13267,6 +13351,10 @@ function getAvailableSlashCommands(
// 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();
@@ -13318,10 +13406,12 @@ function getAvailableSlashCommands(
if (mcpPrompt) {
name = `mcp:${name.replace(" (MCP)", "")}`;
}
const skillFile = airSkillCwd && !mcpPrompt ? skillFiles?.get(command.name) : undefined;
const skillMdPath =
airSkillCwd && !command.builtin && !mcpPrompt
skillFile?.path ??
(airSkillCwd && !command.builtin && !mcpPrompt
? skillPath(command.name, airSkillCwd)
: undefined;
: undefined);
return {
name,
description: command.description || "",
@@ -13334,7 +13424,10 @@ function getAvailableSlashCommands(
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));
@@ -14060,7 +14153,12 @@ export function toAcpNotifications(
!("is_error" in chunk && chunk.is_error)
) {
const plan = approvedPlan(toolUse.input, toolUseResult);
if (plan) approvedPlanUpdate = planModeUpdate(plan);
if (plan) {
approvedPlanUpdate = planModeUpdate(
plan,
renderer.capabilities.air.client ? toolUse.id : undefined,
);
}
}
update = finalUpdate;
}

View File

@@ -45,6 +45,7 @@ export type ClearContextSession<Turn extends ClearContextTurn = ClearContextTurn
turnQueue?: Turn[];
pendingExitPlanContextReset?: ClearContextReset;
contextUsedTokens?: number;
contextWindowSize: number;
sentUserUuids?: Set<string>;
input: { push(message: SDKUserMessage): unknown };
};
@@ -61,10 +62,13 @@ export type ClearContextCoordinatorHost<
): Promise<Session>;
applyFastMode(session: Session, enabled: boolean): Promise<void>;
applyUltracode(session: Session, enabled: boolean): Promise<void>;
/** Publishes the fresh context's mode, config options, and usage: the
* context starts empty in a window of `contextWindowSize` tokens. */
publishSessionState(
sessionId: string,
mode: PermissionMode,
configOptions: SessionConfigOption[],
contextWindowSize: number,
): Promise<void>;
continuationMessage(sessionId: string, plan: string, promptUuid: string): SDKUserMessage;
ensureConsumer(session: Session, sessionId: string): void;
@@ -165,7 +169,12 @@ export async function continuePlanInFreshContext<
freshSession.contextUsedTokens = 0;
try {
await host.publishSessionState(sessionId, reset.mode, freshSession.configOptions);
await host.publishSessionState(
sessionId,
reset.mode,
freshSession.configOptions,
freshSession.contextWindowSize,
);
} catch (error) {
host.logError("Failed to publish clear-context session state:", error);
}

View File

@@ -126,10 +126,9 @@ export function isExitPlanInterruptionResult(
);
}
/** The id of the plan that an approved ExitPlanMode publishes. Claude Code
* keeps one plan file per session, which a later plan mode rewrites, so each
* approval replaces the plan before it. The TodoWrite checklist is a
* separate plan. */
/** The base ID of an approved plan. AIR appends the review ID to keep each
* approval in the transcript. Other clients replace one plan per session.
* The TodoWrite checklist is a separate plan. */
export const PLAN_MODE_PLAN_ID = "plan-mode";
/** The plan, trimmed, that a successful ExitPlanMode result approved: the plan
@@ -150,11 +149,19 @@ export function approvedPlan(input: unknown, output: unknown): string | undefine
}
/** The update that shows an approved plan to a client that supports plan
* operations (the `plan` client capability, which every v2 client has). */
export function planModeUpdate(plan: string): SessionNotification["update"] {
* operations (the `plan` client capability, which every v2 client has).
* For AIR, pass the review ID to join the plan to its approval card. */
export function planModeUpdate(
plan: string,
reviewToolCallId?: string,
): SessionNotification["update"] {
return {
sessionUpdate: "plan_update",
plan: { type: "markdown", planId: PLAN_MODE_PLAN_ID, content: plan },
plan: {
type: "markdown",
planId: reviewToolCallId ? `${PLAN_MODE_PLAN_ID}:${reviewToolCallId}` : PLAN_MODE_PLAN_ID,
content: plan,
},
};
}
@@ -178,6 +185,7 @@ export type ExitPlanRestartHost<
sessionUpdate(notification: SessionNotification): Promise<void>;
/** Whether the client takes plan operations (see planModeUpdate). */
supportsPlanOperations(): boolean;
isAirClient(): boolean;
destroyReplacement(sessionId: string, session: Session): void;
settleCancelledTurn(oldSession: Session, turnSession: Session, turn: Turn): void;
settleFailedTurn(turnSession: Session, turn: Turn, error: unknown): void;
@@ -205,7 +213,7 @@ export class ExitPlanCoordinator<
try {
const clearContextHost: ClearContextCoordinatorHost<Session, Turn> = {
...this.host,
publishSessionState: async (id, mode, configOptions) => {
publishSessionState: async (id, mode, configOptions, contextWindowSize) => {
await this.host.sessionUpdate({
sessionId: id,
update: { sessionUpdate: "current_mode_update", currentModeId: mode },
@@ -214,11 +222,23 @@ export class ExitPlanCoordinator<
sessionId: id,
update: { sessionUpdate: "config_option_update", configOptions },
});
// Otherwise the client shows the old context's usage until the fresh
// context first reports its own.
await this.host.sessionUpdate({
sessionId: id,
update: { sessionUpdate: "usage_update", used: 0, size: contextWindowSize },
});
// The approved plan becomes the session's plan once the fresh
// context that carries it out exists. Its ExitPlanMode ended with
// an error result, so toAcpNotifications did not publish it.
if (this.host.supportsPlanOperations()) {
await this.host.sessionUpdate({ sessionId: id, update: planModeUpdate(reset.plan) });
await this.host.sessionUpdate({
sessionId: id,
update: planModeUpdate(
reset.plan,
this.host.isAirClient() ? reset.toolUseId : undefined,
),
});
}
},
continuationMessage: (id, plan, promptUuid) => ({

View File

@@ -69,10 +69,7 @@ function forkPointMessageIdCandidates(messageId: string): string[] {
return protocolMessageId === messageId ? [messageId] : [messageId, protocolMessageId];
}
async function loadFullSessionHistory(
sessionId: string,
cwd: string,
): Promise<SessionStoreEntry[]> {
async function loadFullSessionHistory(sessionId: string): Promise<SessionStoreEntry[]> {
// getSessionMessages returns only the active parentUuid chain. The import keeps inactive branches that AIR can still reference.
const entries: SessionStoreEntry[] = [];
const store: SessionStore = {
@@ -81,7 +78,7 @@ async function loadFullSessionHistory(
},
load: async () => null,
};
await importSessionToStore(sessionId, store, { dir: cwd, includeSubagents: false });
await importSessionToStore(sessionId, store, { includeSubagents: false });
return entries;
}
@@ -185,8 +182,10 @@ export async function forkSession(
): Promise<ForkSessionResponse> {
const timing = new SessionTiming(dependencies.logger, "fork", params.sessionId);
const point = forkPoint(params._meta);
// SDK `dir` scopes source lookup, not the fork's working directory. Search
// all projects: params.cwd can name a different workspace for the new session.
if (!point) {
const forked = await forkClaudeSession(params.sessionId, { dir: params.cwd });
const forked = await forkClaudeSession(params.sessionId);
timing.phase("sdk-fork", " resolution=latest");
return { sessionId: forked.sessionId };
}
@@ -195,9 +194,7 @@ export async function forkSession(
const liveUuid = candidateIds
.map((candidateId) => dependencies.liveMessageIdToUuid?.get(candidateId))
.find(Boolean);
const history = liveUuid
? undefined
: await getSessionMessages(params.sessionId, { dir: params.cwd });
const history = liveUuid ? undefined : await getSessionMessages(params.sessionId);
timing.phase("active-history", ` resolution=${liveUuid ? "live" : "pending"}`);
const messageUuid =
liveUuid ??
@@ -213,7 +210,7 @@ export async function forkSession(
const fullHistoryUuid = messageUuid
? undefined
: resolveFromFullHistory(
await loadFullSessionHistory(params.sessionId, params.cwd),
await loadFullSessionHistory(params.sessionId),
candidateIds,
point,
dependencies.messageIdForGrouping,
@@ -231,7 +228,6 @@ export async function forkSession(
}
const forked = await forkClaudeSession(params.sessionId, {
dir: params.cwd,
upToMessageId: messageUuid ?? fullHistoryUuid,
});
timing.phase("sdk-fork");

View File

@@ -25,11 +25,14 @@ export type NativeSubagent = {
* form. It is absent when the adapter has no prompt.
*/
prompt?: string;
/** The RFD form: the parent's label for the child, `subagent_update.title`. */
/**
* The RFD form: the parent's label for the child, `subagent_update.title`:
* its name, else its task.
*/
title?: string;
/** The RFD form: the child's role, its `subagent_type`, for the title. */
/** The RFD form: the child's role, its `subagent_type`. */
role?: string;
/** The RFD form: the child's purpose, `subagent_update.description`. */
/** The RFD form: `subagent_update.description`, the child's role. */
description?: string;
/**
* The RFD form: the work state the client holds. The child is reusable, so
@@ -1868,26 +1871,22 @@ export function rfdEndState(state: SubagentState, end: TaskEnd | undefined): Wor
}
/**
* The RFD form: the parent's labels for a child. The title is the agent's
* name, with its role after it when both are known, else the role. The
* description is the task's short description, never the prompt.
* The RFD form: the parent's labels for a child. The title is what tells
* siblings apart: the agent's name when it has one, else the task's short
* description (never the prompt), else its role. The description is the
* role, its `subagent_type`, unless the role is already the title.
*/
export function rfdLabels(
name: unknown,
role: unknown,
description: unknown,
): { title?: string; role?: string; description?: string } {
const agentName = nonBlankString(name);
const agentRole = nonBlankString(role);
const title =
agentName && agentRole && agentName !== agentRole
? `${agentName} (${agentRole})`
: (agentName ?? agentRole);
const text = nonBlankString(description);
const title = nonBlankString(name) ?? nonBlankString(description) ?? agentRole;
return {
...(title ? { title } : {}),
...(agentRole ? { role: agentRole } : {}),
...(text ? { description: text } : {}),
...(agentRole && agentRole !== title ? { description: agentRole } : {}),
};
}

View File

@@ -50,6 +50,7 @@ export class SettingsManager {
private cwd: string;
private effective: Settings = {};
private managedDeniedModels: string[] = [];
private managedModelSettings: Pick<Settings, "availableModels" | "modelOverrides"> = {};
private managedApiKeyHelper?: string;
private watchers: fs.FSWatcher[] = [];
private onChange?: () => void;
@@ -112,6 +113,15 @@ export class SettingsManager {
this.managedDeniedModels = resolved.sources
.filter((entry) => entry.source === "managed")
.flatMap((entry) => entry.settings.deniedModels ?? []);
this.managedModelSettings = Object.assign(
{},
...resolved.sources
.filter((entry) => entry.source === "managed")
.map((entry) => ({
availableModels: entry.settings.availableModels,
modelOverrides: entry.settings.modelOverrides,
})),
);
this.managedApiKeyHelper =
resolved.provenance.apiKeyHelper?.source === "managed"
? resolved.effective.apiKeyHelper
@@ -120,6 +130,7 @@ export class SettingsManager {
this.logger.error("Failed to resolve settings:", error);
this.effective = {};
this.managedDeniedModels = [];
this.managedModelSettings = {};
this.managedApiKeyHelper = undefined;
}
}
@@ -193,6 +204,28 @@ export class SettingsManager {
);
}
/** Model selection settings including the inline SDK settings tier. */
getModelSelectionSettings(
override?: Settings,
): Pick<Settings, "availableModels" | "modelOverrides"> {
// The SDK unions non-managed allowlists, but a managed list owns the
// whole allowlist. Keep that policy when adding
// models the SDK did not include in its initialization result.
const lists = [this.effective.availableModels, override?.availableModels].filter(
(list): list is string[] => Array.isArray(list),
);
const managed = this.managedModelSettings;
return {
availableModels:
managed.availableModels ?? (lists.length > 0 ? [...new Set(lists.flat())] : undefined),
modelOverrides: {
...this.effective.modelOverrides,
...override?.modelOverrides,
...managed.modelOverrides,
},
};
}
/**
* `deniedModels` entries from the managed tier, the only tier the CLI
* honors them from.

View File

@@ -800,6 +800,136 @@ describe("ClaudeAcpAgent settings", () => {
return { setModelSpy };
}
it.each(["environment", "caller object", "caller file"])(
"includes configured models omitted by the SDK from %s settings",
async (source) => {
const configured = {
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
modelOverrides: {
"claude-haiku-4-5": "global.anthropic.claude-haiku-4-5-20251001-v1:0",
"claude-haiku-5-5": "global.anthropic.claude-haiku-5-5",
},
};
let settings: object | string | undefined;
if (source === "environment") {
vi.stubEnv("CLAUDE_MODEL_CONFIG", JSON.stringify(configured));
} else if (source === "caller file") {
settings = "caller-settings.json";
await fs.promises.writeFile(path.join(tempDir, settings), JSON.stringify(configured));
} else {
settings = configured;
}
const { setModelSpy } = mockQueryWithModels([
{ value: "default", displayName: "Default", description: "" },
{ value: "sonnet", displayName: "Sonnet", description: "" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
try {
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: { claudeCode: { options: { settings } } },
});
expect(querySpy.mock.calls[0][0].options.settings).toEqual(configured);
const ids = ["default", ...Object.values(configured.modelOverrides)];
expect(
agent.sessions[response.sessionId].models.availableModels.map(
(model: any) => model.modelId,
),
).toEqual(ids);
const modelOption = response.configOptions.find((option: any) => option.id === "model");
expect(modelOption.options.map((option: any) => option.value)).toEqual(ids);
await agent.setSessionConfigOption({
sessionId: response.sessionId,
configId: "model",
value: ids[2],
});
expect(setModelSpy).toHaveBeenLastCalledWith(ids[2]);
expect(agent.sessions[response.sessionId].modelInfos.map((model) => model.value)).toEqual(
ids,
);
} finally {
await agent.dispose();
vi.unstubAllEnvs();
}
},
);
it("merges file settings with caller settings after caller keys replace the env config", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: ["claude-sonnet-4-6", "claude-haiku-4-5"],
modelOverrides: {
"claude-sonnet-4-6": "file-sonnet",
"claude-haiku-4-5": "file-haiku",
},
}),
);
vi.stubEnv(
"CLAUDE_MODEL_CONFIG",
JSON.stringify({
availableModels: ["claude-opus-4-6"],
modelOverrides: { "claude-sonnet-4-6": "env-sonnet", "claude-haiku-4-5": "env-haiku" },
}),
);
mockQueryWithModels([{ value: "default", displayName: "Default", description: "" }]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
const settings = {
availableModels: ["claude-haiku-4-5", "claude-haiku-5-5"],
modelOverrides: { "claude-haiku-4-5": "caller-haiku" },
};
try {
const response = await (agent as any).createSession({
cwd: tempDir,
mcpServers: [],
_meta: { claudeCode: { options: { settings } } },
});
expect(querySpy.mock.calls[0][0].options.settings).toEqual(settings);
const modelOption = response.configOptions.find((option: any) => option.id === "model");
expect(modelOption.options.map((option: any) => option.value)).toEqual([
"default",
"file-sonnet",
"caller-haiku",
"claude-haiku-5-5",
]);
} finally {
await agent.dispose();
vi.unstubAllEnvs();
}
});
it.each([[], undefined])(
"preserves empty and absent configured allowlists: %j",
async (availableModels) => {
vi.stubEnv(
"CLAUDE_MODEL_CONFIG",
JSON.stringify({
availableModels,
modelOverrides: { "claude-haiku-4-5": "provider-haiku" },
}),
);
mockQueryWithModels([
{ value: "default", displayName: "Default", description: "" },
{ value: "sonnet", displayName: "Sonnet", description: "" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent = new ClaudeAcpAgent(createMockClient());
try {
const response = await (agent as any).createSession({ cwd: tempDir, mcpServers: [] });
const modelOption = response.configOptions.find((option: any) => option.id === "model");
expect(modelOption.options.map((option: any) => option.value)).toEqual(
availableModels ? ["default"] : ["default", "sonnet"],
);
} finally {
await agent.dispose();
vi.unstubAllEnvs();
}
},
);
it("restricts configOptions to the user's allowlist using their exact IDs", async () => {
// Reproduces the scenario from
// https://github.com/agentclientprotocol/claude-agent-acp/issues/620:

View File

@@ -2629,6 +2629,15 @@ describe("synthetic login message (issue #863)", () => {
content: [{ type: "text", text: "Session expired. Please run /login to sign in again." }],
}),
).toBe(true);
// Newer CLI wording that drops "Please".
for (const text of [
"Not logged in · Run /login",
"Login expired · Run /login to sign in again, or re-authenticate your Anthropic profile",
]) {
expect(
isSyntheticLoginMessage({ ...syntheticLoginApiMessage, content: [{ type: "text", text }] }),
).toBe(true);
}
});
it("isSyntheticLoginMessage does not match real assistant messages", () => {
@@ -2646,6 +2655,19 @@ describe("synthetic login message (issue #863)", () => {
content: [{ type: "text", text: "API Error: 500 Internal Server Error" }],
}),
).toBe(false);
// Usage-limit banners can mention /login (to switch accounts) but are
// handled as usage limits, not as a sign-out.
expect(
isSyntheticLoginMessage({
...syntheticLoginApiMessage,
content: [
{
type: "text",
text: "You've hit your limit · resets 5pm · run /login to switch to an API usage-billed account.",
},
],
}),
).toBe(false);
expect(isSyntheticLoginMessage(undefined)).toBe(false);
expect(isSyntheticLoginMessage("Not logged in · Please run /login")).toBe(false);
});
@@ -7747,8 +7769,8 @@ describe("subagent permission attribution (issue #851)", () => {
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-42",
title: "Explore",
description: "Investigate",
title: "Investigate",
description: "Explore",
capabilities: { cancel: {} },
state: { state: "running" },
},
@@ -8302,8 +8324,8 @@ describe("subagent permission attribution (issue #851)", () => {
update: {
sessionUpdate: "subagent_update",
sessionId: "agent-7",
title: "echoer (Explore)",
description: "Echo a word",
title: "echoer",
description: "Explore",
capabilities: { cancel: {} },
state: { state: "running" },
},
@@ -12100,6 +12122,41 @@ describe("stop reason propagation", () => {
expect(logged.join("\n")).not.toContain("cannot fail active turn");
});
it("rejects an expired login the CLI reports as invalid_request with authRequired", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (update: SessionNotification) => {
updates.push(update);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, {});
// An expired token with no refresh token: the CLI tags this synthetic
// message `invalid_request`, not `authentication_failed`.
const text =
"Login expired · Run /login to sign in again, or re-authenticate your Anthropic profile";
const loginExpired = createAssistantError("invalid_request");
loginExpired.message.model = "<synthetic>";
loginExpired.message.content = [{ type: "text", text, citations: null }] as any;
injectSession(agent, [
loginExpired,
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: text,
}),
]);
await expect(
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
).rejects.toMatchObject({ code: RequestError.authRequired().code });
await agent.sessions["test-session"]?.consumer;
expect(JSON.stringify(updates)).not.toContain("/login");
});
it("does not treat a successful answer mentioning /login as auth_required", async () => {
const updates: SessionNotification[] = [];
const agent = new ClaudeAcpAgent(
@@ -13841,6 +13898,374 @@ describe("skill commands in available_commands_update", () => {
});
});
describe("skill files from getContextUsage in available_commands_update", () => {
let cwd: string;
beforeEach(async () => {
cwd = await mkdtemp(path.join(os.tmpdir(), "acp-skill-files-"));
});
afterEach(async () => {
await rm(cwd, { recursive: true, force: true });
});
const skillEntry = (entry: Record<string, unknown>) => ({
skills: { skillFrontmatter: [entry] },
});
/** Lets the background skill file reads and their updates finish. */
const flush = () => new Promise((resolve) => setTimeout(resolve, 20));
/** One turn that emits `inits` init frames, then `changes` commands_changed frames for
* `commands`. `getContextUsage` is put on the query only when given. */
async function runTurn(options: {
capabilities: Record<string, unknown>;
commands: Array<Record<string, unknown>>;
getContextUsage?: ReturnType<typeof vi.fn>;
inits?: number;
changes?: number;
}) {
const sessionUpdate = vi.fn(async () => {});
const logged: string[] = [];
const agent = new ClaudeAcpAgent({ sessionUpdate } as unknown as AcpClient, {
log: () => {},
error: (...args: unknown[]) => logged.push(args.join(" ")),
});
await agent.initialize({ protocolVersion: 1, clientCapabilities: options.capabilities });
async function* generator(input: Pushable<any>) {
const iter = input[Symbol.asyncIterator]();
const { value: userMessage } = await iter.next();
yield {
type: "user",
message: userMessage.message,
parent_tool_use_id: null,
uuid: userMessage.uuid,
session_id: "test-session",
isReplay: true,
};
for (let i = 0; i < (options.inits ?? 1); i++) {
yield { type: "system", subtype: "init", session_id: "test-session" };
}
for (let i = 0; i < (options.changes ?? 0); i++) {
yield {
type: "system",
subtype: "commands_changed",
session_id: "test-session",
commands: options.commands,
};
}
yield {
type: "result",
subtype: "success",
stop_reason: null,
is_error: false,
result: "",
errors: [],
duration_ms: 0,
duration_api_ms: 0,
num_turns: 1,
total_cost_usd: 0,
usage: {
input_tokens: 0,
output_tokens: 0,
cache_read_input_tokens: 0,
cache_creation_input_tokens: 0,
},
modelUsage: {},
permission_denials: [],
uuid: randomUUID(),
session_id: "test-session",
};
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
injectGeneratorSession(agent, generator, { cwd });
Object.assign(agent.sessions["test-session"].query, {
supportedCommands: vi.fn(async () => options.commands),
...(options.getContextUsage ? { getContextUsage: options.getContextUsage } : {}),
});
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
await flush();
const commandUpdates = () =>
sessionUpdate.mock.calls
.map((c: any[]) => (c[0] as { update: any }).update)
.filter((u) => u.sessionUpdate === "available_commands_update");
return { commandUpdates, logged };
}
/** The `_meta` of the command `name` in the last available_commands_update. */
function lastMeta(commandUpdates: () => any[], name: string) {
const updates = commandUpdates();
return updates[updates.length - 1].availableCommands.find((c: any) => c.name === name)?._meta;
}
/** The air `_meta` of a skill, with a path when there is one. */
const skillMeta = (skillPath?: string) => ({
jetbrains: { air: { version: 1, kind: "skill", ...(skillPath ? { skillPath } : {}) } },
});
/**
* Scenario:
* 1. An AIR session lists the command `plug:pskill`.
* 2. getContextUsage reports it with source "plugin" and a path in the plugin directory.
* 3. The first init triggers the read.
* Expected:
* - The last update marks `plug:pskill` as a skill with that path.
*/
it("takes the path of a plugin skill from getContextUsage", async () => {
const pluginSkill = "/plugins/plug/skills/pskill/SKILL.md";
const getContextUsage = vi.fn(async () =>
skillEntry({ name: "plug:pskill", source: "plugin", pluginName: "plug", path: pluginSkill }),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "plug:pskill", description: "Plugin skill", argumentHint: "" }],
getContextUsage,
});
expect(lastMeta(commandUpdates, "plug:pskill")).toEqual(skillMeta(pluginSkill));
});
/**
* Scenario:
* 1. An AIR session lists the prompt command `deploy`.
* 2. getContextUsage reports it with a `.claude/commands/deploy.md` path.
* 3. commands_changed sends the command list.
* Expected:
* - `deploy` has no `_meta`: a prompt command is no skill.
*/
it("does not mark a prompt command that getContextUsage lists with a .md path", async () => {
const getContextUsage = vi.fn(async () =>
skillEntry({
name: "deploy",
source: "projectSettings",
path: "/proj/.claude/commands/deploy.md",
}),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "deploy", description: "Deploy", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(lastMeta(commandUpdates, "deploy")).toBeUndefined();
});
/**
* Scenario:
* 1. An AIR session lists the command `nested`.
* 2. getContextUsage lists no skills on the first read (init) and the nested one on the second.
* 3. commands_changed arrives after the first read.
* Expected:
* - Two reads happened.
* - The first update, from commands_changed, has no `_meta` on `nested`.
* - The last update marks `nested` with the path from the second read.
*/
it("gives a nested skill reported after commands_changed its path", async () => {
const nestedSkill = "/proj/sub/.claude/skills/nested/SKILL.md";
const getContextUsage = vi
.fn()
.mockResolvedValueOnce({ skills: { skillFrontmatter: [] } })
.mockResolvedValue(
skillEntry({ name: "nested", source: "projectSettings", path: nestedSkill }),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "nested", description: "Nested skill", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(getContextUsage).toHaveBeenCalledTimes(2);
expect(commandUpdates()[0].availableCommands[0]._meta).toBeUndefined();
expect(lastMeta(commandUpdates, "nested")).toEqual(skillMeta(nestedSkill));
});
/**
* Scenario:
* 1. An AIR session lists the builtin commands `simplify` and `compact`.
* 2. getContextUsage reports `simplify` with source "built-in" and no path; `compact` is absent.
* Expected:
* - `simplify` has kind "skill" and no skillPath.
* - `compact` has no `_meta`.
*/
it("marks a built-in skill with the kind only and leaves compact unmarked", async () => {
const getContextUsage = vi.fn(async () => skillEntry({ name: "simplify", source: "built-in" }));
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [
{ name: "simplify", description: "Simplify", argumentHint: "", builtin: true },
{ name: "compact", description: "Compact", argumentHint: "", builtin: true },
],
getContextUsage,
});
expect(lastMeta(commandUpdates, "simplify")).toEqual(skillMeta());
expect(lastMeta(commandUpdates, "compact")).toBeUndefined();
});
/**
* Scenario:
* 1. cwd/.claude/skills/review/SKILL.md exists.
* 2. An AIR session lists `review`; getContextUsage reports no skills.
* Expected:
* - `review` is marked as a skill with the path found in cwd/.claude/skills.
*/
it("looks up a command that getContextUsage does not list on disk", async () => {
const reviewSkill = path.join(cwd, ".claude", "skills", "review", "SKILL.md");
await mkdir(path.dirname(reviewSkill), { recursive: true });
await writeFile(reviewSkill, "# review\n");
const getContextUsage = vi.fn(async () => ({ skills: { skillFrontmatter: [] } }));
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(lastMeta(commandUpdates, "review")).toEqual(skillMeta(reviewSkill));
});
/**
* Scenario:
* 1. cwd/.claude/skills/review/SKILL.md exists.
* 2. The query has no getContextUsage.
* 3. An AIR session gets a commands_changed.
* Expected:
* - `review` still gets its path from the disk lookup.
* - No error is logged.
*/
it("lists the commands as before when getContextUsage is missing", async () => {
const reviewSkill = path.join(cwd, ".claude", "skills", "review", "SKILL.md");
await mkdir(path.dirname(reviewSkill), { recursive: true });
await writeFile(reviewSkill, "# review\n");
const { commandUpdates, logged } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
changes: 1,
});
expect(lastMeta(commandUpdates, "review")).toEqual(skillMeta(reviewSkill));
expect(logged.filter((line) => line.includes("skill files"))).toEqual([]);
});
/**
* Scenario:
* 1. cwd/.claude/skills/review/SKILL.md exists.
* 2. getContextUsage rejects.
* 3. An AIR session gets a commands_changed.
* Expected:
* - The prompt completes.
* - The error is logged.
* - `review` still gets its path from the disk lookup.
*/
it("logs a failing getContextUsage and keeps the turn going", async () => {
const reviewSkill = path.join(cwd, ".claude", "skills", "review", "SKILL.md");
await mkdir(path.dirname(reviewSkill), { recursive: true });
await writeFile(reviewSkill, "# review\n");
const getContextUsage = vi.fn(async () => {
throw new Error("control request failed");
});
const { commandUpdates, logged } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(logged.some((line) => line.includes("control request failed"))).toBe(true);
expect(lastMeta(commandUpdates, "review")).toEqual(skillMeta(reviewSkill));
});
/**
* Scenario:
* 1. A client without the AIR capability runs a turn with init and commands_changed.
* Expected:
* - getContextUsage is not called.
* - No command has `_meta`.
*/
it("does not call getContextUsage for a client that is not AIR", async () => {
const getContextUsage = vi.fn(async () =>
skillEntry({ name: "review", source: "projectSettings", path: "/x/SKILL.md" }),
);
const { commandUpdates } = await runTurn({
capabilities: {},
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 1,
});
expect(getContextUsage).not.toHaveBeenCalled();
const metas = commandUpdates().flatMap((u) => u.availableCommands.map((c: any) => c._meta));
expect(metas.every((meta) => meta === undefined)).toBe(true);
});
/**
* Scenario:
* 1. An AIR session gets three init frames and then one commands_changed.
* 2. Every getContextUsage read returns the same skill list.
* Expected:
* - Two updates: the one for commands_changed and the one for the first read.
* - getContextUsage ran exactly twice: once for init, once for commands_changed.
*/
it("sends no extra update for an identical result and reads once for repeated inits", async () => {
const getContextUsage = vi.fn(async () =>
skillEntry({ name: "review", source: "projectSettings", path: "/p/review/SKILL.md" }),
);
const { commandUpdates } = await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
inits: 3,
changes: 1,
});
expect(commandUpdates()).toHaveLength(2);
expect(getContextUsage).toHaveBeenCalledTimes(2);
});
/**
* Scenario:
* 1. The first getContextUsage call stays pending.
* 2. An AIR session gets an init and two commands_changed meanwhile.
* 3. The pending call then resolves.
* Expected:
* - Only one call is made while the first is pending.
* - After it resolves, exactly one more call is made.
*/
it("coalesces triggers during a running read into one more read", async () => {
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
const getContextUsage = vi.fn(async () => {
if (getContextUsage.mock.calls.length === 1) await gate;
return { skills: { skillFrontmatter: [] } };
});
await runTurn({
capabilities: AIR_CLIENT_CAPABILITIES,
commands: [{ name: "review", description: "Review", argumentHint: "" }],
getContextUsage,
changes: 2,
});
expect(getContextUsage).toHaveBeenCalledTimes(1);
release();
await vi.waitFor(() => expect(getContextUsage).toHaveBeenCalledTimes(2));
});
});
describe("logout", () => {
function createMockAgent() {
const mockClient = {
@@ -14071,7 +14496,7 @@ describe("session/fork", () => {
});
expect(response.sessionId).toBe("fork-id");
expect(forkSession).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
expect(forkSession).toHaveBeenCalledWith("source-id");
expect(createSession).toHaveBeenCalledWith(
expect.objectContaining({ cwd: "/workspace", additionalDirectories: ["/workspace/extra"] }),
expect.objectContaining({ resume: "fork-id" }),
@@ -14115,10 +14540,9 @@ describe("session/fork", () => {
});
expect(response.sessionId).toBe("fork-id");
expect(getSessionMessages).toHaveBeenCalledWith("source-id", { dir: "/workspace" });
expect(getSessionMessages).toHaveBeenCalledWith("source-id");
expect(importSessionToStore).not.toHaveBeenCalled();
expect(forkSession).toHaveBeenCalledWith("source-id", {
dir: "/workspace",
upToMessageId: "assistant-uuid",
});
});
@@ -14243,11 +14667,9 @@ describe("session/fork", () => {
expect(response.sessionId).toBe("fork-id");
expect(importSessionToStore).toHaveBeenLastCalledWith("source-id", expect.any(Object), {
dir: "/workspace",
includeSubagents: false,
});
expect(forkSession).toHaveBeenLastCalledWith("source-id", {
dir: "/workspace",
upToMessageId: "inactive-assistant-uuid",
});
});

View File

@@ -5,7 +5,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Background review","sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"general-purpose"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"general-purpose","sessionId":"a2","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Background review"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientMessageId":"toolu_agent","recipientSessionId":"a2","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"a2","update":{"content":[{"text":"Review the diff","type":"text"}],"messageId":"toolu_agent","recipientSessionId":"a2","senderMessageId":"toolu_agent","senderSessionId":"<session>","sessionUpdate":"session_message"}}}

View File

@@ -5,7 +5,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Check the logs","sessionId":"agent_late","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"general-purpose"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"general-purpose","sessionId":"agent_late","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Check the logs"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientMessageId":"toolu_late","recipientSessionId":"agent_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_late","update":{"content":[{"text":"Check logs","type":"text"}],"messageId":"toolu_late","recipientSessionId":"agent_late","senderMessageId":"toolu_late","senderSessionId":"<session>","sessionUpdate":"session_message"}}}

View File

@@ -5,7 +5,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Fix the build","sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"general-purpose"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"general-purpose","sessionId":"agent_n","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Fix the build"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientMessageId":"toolu_native","recipientSessionId":"agent_n","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_n","update":{"content":[{"text":"Make the build pass","type":"text"}],"messageId":"toolu_native","recipientSessionId":"agent_n","senderMessageId":"toolu_native","senderSessionId":"<session>","sessionUpdate":"session_message"}}}

View File

@@ -5,12 +5,12 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Plan the work","sessionId":"agent_outer","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Plan"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Plan","sessionId":"agent_outer","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Plan the work"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientMessageId":"toolu_outer","recipientSessionId":"agent_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Plan it","type":"text"}],"messageId":"toolu_outer","recipientSessionId":"agent_outer","senderMessageId":"toolu_outer","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_outer"}},"configOptions":[{"category":"model","configId":"model","currentValue":"claude-sonnet-4-6","name":"Model","options":[{"name":"Claude Sonnet 4.6","value":"claude-sonnet-4-6"}],"type":"select"}],"sessionUpdate":"config_option_update"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"capabilities":{"cancel":{}},"description":"Read the spec","sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"capabilities":{"cancel":{}},"description":"Explore","sessionId":"agent_inner","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Read the spec"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_outer","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientMessageId":"toolu_inner","recipientSessionId":"agent_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_inner","update":{"content":[{"text":"Read spec.md","type":"text"}],"messageId":"toolu_inner","recipientSessionId":"agent_inner","senderMessageId":"toolu_inner","senderSessionId":"agent_outer","sessionUpdate":"session_message"}}}

View File

@@ -5,7 +5,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Explore code","sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Explore","sessionId":"agent_1","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore code"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientMessageId":"toolu_task","recipientSessionId":"agent_1","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_1","update":{"content":[{"text":"Find the parser","type":"text"}],"messageId":"toolu_task","recipientSessionId":"agent_1","senderMessageId":"toolu_task","senderSessionId":"<session>","sessionUpdate":"session_message"}}}

View File

@@ -5,7 +5,7 @@
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Summarize","sessionId":"agent_t","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Explore"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"capabilities":{"cancel":{}},"description":"Explore","sessionId":"agent_t","sessionUpdate":"subagent_update","state":{"state":"running"},"title":"Summarize"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"sessionUpdate":"state_update","state":"running"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"<session>","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientMessageId":"toolu_transcript","recipientSessionId":"agent_t","senderSessionId":"<session>","sessionUpdate":"session_message"}}}
{"kind":"notification","method":"session/update","payload":{"sessionId":"agent_t","update":{"content":[{"text":"Summarize the repo","type":"text"}],"messageId":"toolu_transcript","recipientSessionId":"agent_t","senderMessageId":"toolu_transcript","senderSessionId":"<session>","sessionUpdate":"session_message"}}}

View File

@@ -1079,8 +1079,9 @@ describe("ACP v2 prompts", () => {
]);
});
it("forks a session into one that the client can prompt at once", async () => {
it.each(["same", "different"])("forks a promptable session in the %s cwd", async (dir) => {
const forkId = "99999999-8888-4777-8666-555555555555";
const forkCwd = dir === "same" ? cwd : await mkdtemp(path.join(tempDir, "fork-"));
const echoed = scriptTurns(async function* (options) {
yield assistantText(options, "Forked.");
yield result(options);
@@ -1091,17 +1092,18 @@ describe("ACP v2 prompts", () => {
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
const fork = await agent.request(v2.methods.agent.session.fork, {
sessionId,
cwd,
cwd: forkCwd,
mcpServers: [{ type: "http", name: "linear", url: "https://mcp.linear.app/mcp" }],
});
expect(fork.sessionId).toBe(forkId);
expect(fork.configOptions?.map((option) => option.configId)).toContain("mode");
expect(forkSession).toHaveBeenCalledWith(sessionId, { dir: cwd });
expect(forkSession).toHaveBeenCalledWith(sessionId);
// The fork runs as its own Claude Code session, resumed from the copied
// transcript, with the MCP servers of the fork request.
const forkQuery = sdk.queryOptions.at(-1)!;
expect(forkQuery.resume).toBe(forkId);
expect(forkQuery.cwd).toBe(forkCwd);
expect(Object.keys(forkQuery.mcpServers ?? {})).toContain("linear");
// No session/resume needed before the first prompt.

View File

@@ -28,6 +28,7 @@ function testSession(overrides: Partial<TestSession> = {}): TestSession {
},
},
models: { currentModelId: "default" },
contextWindowSize: 200_000,
configOptions: [],
currentAgent: "default",
fastModeEnabled: false,
@@ -357,6 +358,7 @@ describe("ExitPlanCoordinator", () => {
...baseHost,
sessionUpdate: vi.fn(async () => {}),
supportsPlanOperations: () => true,
isAirClient: () => false,
destroyReplacement,
settleCancelledTurn,
settleFailedTurn: vi.fn(),
@@ -402,6 +404,7 @@ describe("ExitPlanCoordinator", () => {
...baseHost,
sessionUpdate: vi.fn(async () => {}),
supportsPlanOperations: () => true,
isAirClient: () => false,
destroyReplacement: vi.fn(),
settleCancelledTurn: vi.fn(),
settleFailedTurn,
@@ -421,9 +424,14 @@ describe("ExitPlanCoordinator", () => {
});
it.each([
["publishes the approved plan after the session state, before the fresh context runs", true],
["publishes no plan to a client without plan operations", false],
])("%s", async (_, supportsPlanOperations) => {
[
"publishes the approved plan after the session state, before the fresh context runs",
true,
false,
],
["links the approved AIR plan after a context reset", true, true],
["publishes no plan to a client without plan operations", false, false],
])("%s", async (_, supportsPlanOperations, airClient) => {
const turn: ClearContextTurn = {
settled: false,
promptUuid: "00000000-0000-4000-8000-000000000000",
@@ -442,6 +450,7 @@ describe("ExitPlanCoordinator", () => {
);
}),
supportsPlanOperations: () => supportsPlanOperations,
isAirClient: () => airClient,
destroyReplacement: vi.fn(),
settleCancelledTurn: vi.fn(),
settleFailedTurn: vi.fn(),
@@ -456,14 +465,32 @@ describe("ExitPlanCoordinator", () => {
expect(order).toEqual([
"current_mode_update",
"config_option_update",
"usage_update",
...(supportsPlanOperations
? [
"plan_update " +
JSON.stringify({ type: "markdown", planId: "plan-mode", content: "Ship it" }),
JSON.stringify({
type: "markdown",
planId: airClient ? "plan-mode:tool-plan" : "plan-mode",
content: "Ship it",
}),
]
: []),
"continuation",
]);
expect(host.sessionUpdate).toHaveBeenCalledWith({
sessionId: "public-session",
update: { sessionUpdate: "usage_update", used: 0, size: 200_000 },
});
if (airClient) {
expect(host.sessionUpdate).toHaveBeenCalledWith({
sessionId: "public-session",
update: {
sessionUpdate: "plan_update",
plan: { type: "markdown", planId: "plan-mode:tool-plan", content: "Ship it" },
},
});
}
});
});

View File

@@ -0,0 +1,188 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { getSessionMessages } from "@anthropic-ai/claude-agent-sdk";
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 { forkSession } from "../fork-session.js";
import { encodeProjectPath } from "../session-index/project-dirs.js";
// Use the real SDK against temporary transcripts, without starting Claude Code.
let root: string;
let sourceCwd: string;
let destinationCwd: string;
let sessionId: string;
let sourceFile: string;
let transcript: string;
let activeUuid: string;
beforeEach(async () => {
root = await fs.realpath(await fs.mkdtemp(path.join(os.tmpdir(), "fork-session-")));
sourceCwd = path.join(root, "source");
destinationCwd = path.join(root, "destination");
const configDir = path.join(root, "config");
vi.stubEnv("CLAUDE_CONFIG_DIR", configDir);
await fs.mkdir(sourceCwd);
await fs.mkdir(destinationCwd);
sessionId = randomUUID();
const userUuid = randomUUID();
const firstUuid = randomUUID();
const followupUuid = randomUUID();
activeUuid = randomUUID();
const common = { sessionId, cwd: sourceCwd, isSidechain: false };
const entries = [
{
...common,
type: "user",
uuid: userUuid,
parentUuid: null,
message: { role: "user", content: "First prompt" },
},
{
...common,
type: "assistant",
uuid: firstUuid,
parentUuid: userUuid,
message: {
id: "msg_first",
role: "assistant",
content: [{ type: "text", text: "First answer" }],
},
},
{
...common,
type: "user",
uuid: followupUuid,
parentUuid: firstUuid,
message: { role: "user", content: "Follow-up prompt" },
},
{
...common,
type: "assistant",
uuid: randomUUID(),
parentUuid: followupUuid,
message: {
id: "msg_inactive",
role: "assistant",
content: [{ type: "text", text: "Inactive answer" }],
},
},
{
...common,
type: "assistant",
uuid: activeUuid,
parentUuid: followupUuid,
message: {
id: "msg_active",
role: "assistant",
content: [{ type: "text", text: "Active answer" }],
},
},
];
transcript =
entries
.map((entry, index) =>
JSON.stringify({ ...entry, timestamp: new Date(1_700_000_000_000 + index).toISOString() }),
)
.join("\n") + "\n";
const projectDir = path.join(configDir, "projects", encodeProjectPath(sourceCwd));
await fs.mkdir(projectDir, { recursive: true });
sourceFile = path.join(projectDir, `${sessionId}.jsonl`);
await fs.writeFile(sourceFile, transcript);
});
afterEach(async () => {
vi.unstubAllEnvs();
await fs.rm(root, { recursive: true, force: true });
});
describe.each(["same directory", "different directory"])("fork into %s", (directory) => {
it.each([
{ resolution: "latest", messageId: undefined, expectedAnswer: "Active answer", count: 4 },
{
resolution: "active history",
messageId: "msg_first",
expectedAnswer: "First answer",
count: 2,
},
{
resolution: "inactive branch",
messageId: "msg_inactive",
expectedAnswer: "Inactive answer",
count: 4,
},
{
resolution: "fingerprint",
messageId: "stale-message-id",
expectedAnswer: "Inactive answer",
count: 4,
},
{ resolution: "live map", messageId: "msg_active", expectedAnswer: "Active answer", count: 4 },
])("resolves the fork point through $resolution", async (scenario) => {
const originalMessages = await getSessionMessages(sessionId);
expect(originalMessages.map(messageIdForGrouping)).not.toContain("msg_inactive");
const cwd = directory === "same directory" ? sourceCwd : destinationCwd;
const result = await forkSession(
{
sessionId,
cwd,
mcpServers: [],
...(scenario.messageId && {
_meta: {
jetbrains: {
air: {
fork: {
version: 1,
messageId: scenario.messageId,
...(scenario.resolution === "fingerprint" && {
messageFingerprint: `sha256:${createHash("sha256").update("Inactive answer").digest("hex")}`,
messageOccurrence: 1,
}),
},
},
},
},
}),
},
{
messageIdForGrouping,
...(scenario.resolution === "live map" && {
liveMessageIdToUuid: new Map([["msg_active", activeUuid]]),
}),
},
);
expect(result.sessionId).not.toBe(sessionId);
const messages = await getSessionMessages(result.sessionId);
expect(messages).toHaveLength(scenario.count);
expect(messages.at(-1)?.message).toMatchObject({
content: [{ type: "text", text: scenario.expectedAnswer }],
});
const originalUuids = originalMessages.map((message) => message.uuid);
for (const message of messages) {
expect(originalUuids).not.toContain(message.uuid);
}
expect(await fs.readFile(sourceFile, "utf8")).toBe(transcript);
});
it("rejects a fork point that is not in the source session", async () => {
await expect(
forkSession(
{
sessionId,
cwd: directory === "same directory" ? sourceCwd : destinationCwd,
mcpServers: [],
_meta: { jetbrains: { air: { fork: { version: 1, messageId: "missing" } } } },
},
{ messageIdForGrouping },
),
).rejects.toMatchObject({
code: -32602,
message: expect.stringContaining(
`Fork point message missing was not found in session ${sessionId}`,
),
});
expect(await fs.readFile(sourceFile, "utf8")).toBe(transcript);
});
});

View File

@@ -0,0 +1,92 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { SettingsManager } from "../settings.js";
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({
...(await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>()),
resolveSettings: vi.fn(),
}));
describe("model selection settings policy", () => {
let manager: SettingsManager;
afterEach(() => manager?.dispose());
it.each([{ availableModels: ["claude-haiku-4-5"] }, { availableModels: [] }])(
"keeps the managed allowlist %j and mappings above inline settings",
async ({ availableModels }) => {
const managed = {
availableModels,
modelOverrides: { "claude-haiku-4-5": "managed-haiku" },
deniedModels: ["claude-opus-5"],
};
vi.mocked(resolveSettings).mockResolvedValue({
effective: managed,
provenance: {},
sources: [{ source: "managed", settings: managed }],
});
manager = new SettingsManager(process.cwd());
await manager.initialize();
expect(
manager.getModelSelectionSettings({
availableModels: ["claude-opus-5"],
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
}),
).toEqual({
availableModels: managed.availableModels,
modelOverrides: managed.modelOverrides,
});
expect(manager.getManagedDeniedModels()).toEqual(managed.deniedModels);
},
);
it("keeps managed override keys above inline keys without discarding other inline mappings", async () => {
const managed = { modelOverrides: { "claude-haiku-4-5": "managed-haiku" } };
vi.mocked(resolveSettings).mockResolvedValue({
effective: managed,
provenance: {},
sources: [{ source: "managed", settings: managed }],
});
manager = new SettingsManager(process.cwd());
await manager.initialize();
expect(
manager.getModelSelectionSettings({
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
modelOverrides: {
"claude-haiku-4-5": "inline-haiku",
"claude-sonnet-4-6": "inline-sonnet",
},
}),
).toEqual({
availableModels: ["claude-haiku-4-5", "claude-sonnet-4-6"],
modelOverrides: { "claude-haiku-4-5": "managed-haiku", "claude-sonnet-4-6": "inline-sonnet" },
});
});
it("preserves file and inline mappings when the managed allowlist supplies no mappings", async () => {
vi.mocked(resolveSettings).mockResolvedValue({
effective: {
availableModels: ["claude-haiku-4-5"],
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
},
provenance: {},
sources: [
{ source: "user", settings: { modelOverrides: { "claude-haiku-4-5": "file-haiku" } } },
{ source: "managed", settings: { availableModels: ["claude-haiku-4-5"] } },
],
});
manager = new SettingsManager(process.cwd());
await manager.initialize();
expect(manager.getModelSelectionSettings()).toEqual({
availableModels: ["claude-haiku-4-5"],
modelOverrides: { "claude-haiku-4-5": "file-haiku" },
});
expect(
manager.getModelSelectionSettings({
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
}),
).toEqual({
availableModels: ["claude-haiku-4-5"],
modelOverrides: { "claude-haiku-4-5": "inline-haiku" },
});
});
});

View File

@@ -1087,8 +1087,8 @@ describe("NativeSubagentRuntime in the RFD form", () => {
update: {
sessionUpdate: "subagent_update",
sessionId: "worker-1",
title: "builder (Explore)",
description: "Fix the build",
title: "builder",
description: "Explore",
capabilities: { cancel: {} },
state: { state: "running" },
},
@@ -1297,10 +1297,10 @@ describe("NativeSubagentRuntime in the RFD form", () => {
expect(published).toEqual([]);
});
it("titles a child without a name by its role", async () => {
it("titles a child without a name by its task, and describes it by its role", async () => {
const { published } = await runningWorker();
expect(published[0]?.update).toMatchObject({ title: "Explore", description: "Fix the build" });
expect(published[0]?.update).toMatchObject({ title: "Fix the build", description: "Explore" });
});
it("keeps the session of an idle child, which a resume runs again", async () => {
@@ -1569,8 +1569,8 @@ describe("NativeSubagentRuntime in the RFD form", () => {
update: {
sessionUpdate: "subagent_update",
sessionId: "echoer-1",
title: "echoer (Explore)",
description: "Echo a word",
title: "echoer",
description: "Explore",
capabilities: { cancel: {} },
state: { state: "running" },
},
@@ -1623,8 +1623,8 @@ describe("NativeSubagentRuntime in the RFD form", () => {
expect(published[0]?.update).toEqual({
sessionUpdate: "subagent_update",
sessionId: "coordinator-1",
title: "coordinator (general-purpose)",
description: "Coordinate",
title: "coordinator",
description: "general-purpose",
});
});
@@ -1825,7 +1825,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
});
});
it("keeps the role in the title when a late frame names the child", async () => {
it("titles the child by the name a late frame gives it, and keeps its role", async () => {
const published: AcpSessionNotification[] = [];
const runtime = new NativeSubagentRuntime(
"rfd",
@@ -1842,7 +1842,7 @@ describe("NativeSubagentRuntime in the RFD form", () => {
);
await runtime.route(launch("launch-1", { name: "builder" }), async () => {});
expect(published[0]?.update).toMatchObject({ title: "builder (Explore)" });
expect(published[0]?.update).toMatchObject({ title: "builder", description: "Explore" });
});
it("reports nothing when the parent is cancelled, until the SDK ends the child", async () => {

View File

@@ -4947,9 +4947,9 @@ describe("toAcpNotifications - the plan of an approved ExitPlanMode", () => {
.map(({ update }) => update);
}
const markdownPlan = (content: string) => ({
const markdownPlan = (content: string, planId = "plan-mode") => ({
sessionUpdate: "plan_update",
plan: { type: "markdown", planId: "plan-mode", content },
plan: { type: "markdown", planId, content },
});
it("shows the plan of the structured result, which includes the user's edits", () => {
@@ -4968,6 +4968,24 @@ describe("toAcpNotifications - the plan of an approved ExitPlanMode", () => {
expect(planUpdates({}, { toolUseResult })).toEqual([markdownPlan("Implement it")]);
});
it.each([false, true])("links an AIR plan by ID with planFile=%s", (planFile) => {
const clientCapabilities: ClientCapabilities = {
plan: {},
_meta: {
jetbrains: { air: { version: 1, capabilities: planFile ? ["planFile"] : [] } },
},
};
expect(
planUpdates(
{},
{ clientCapabilities, toolUseResult: { plan: "Edited plan", isAgent: false } },
),
).toEqual([markdownPlan("Edited plan", "plan-mode:toolu_plan")]);
expect(planUpdates({}, { clientCapabilities }, { plan: "\nOriginal plan\n" })).toEqual([
markdownPlan("Original plan", "plan-mode:toolu_plan"),
]);
});
it("shows the plan after the tool call's result", () => {
const toolUseResult = { plan: "Implement it", isAgent: false };
const kinds = toAcpNotifications(