mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-04 08:22:03 +00:00
Compare commits
10 Commits
v0.99.0
...
fix/config
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8749dd76f | ||
|
|
9f543dd1bf | ||
|
|
fb97d576fb | ||
|
|
2fd46c866d | ||
|
|
0f78dbbdd0 | ||
|
|
8bd0bc9b03 | ||
|
|
52c241e715 | ||
|
|
fccaebcf87 | ||
|
|
7dcb62923d | ||
|
|
4b4b2c4de7 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.99.0"
|
||||
".": "0.99.1"
|
||||
}
|
||||
|
||||
11
CHANGELOG.md
11
CHANGELOG.md
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
4
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
128
src/acp-agent.ts
128
src/acp-agent.ts
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
188
src/tests/fork-session.test.ts
Normal file
188
src/tests/fork-session.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
92
src/tests/model-selection-settings.test.ts
Normal file
92
src/tests/model-selection-settings.test.ts
Normal 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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user