mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-12 04:12:45 +00:00
Compare commits
12 Commits
v0.89.2-pr
...
feat/subag
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7184fd3cfe | ||
|
|
9382e10f2c | ||
|
|
4b67682ff9 | ||
|
|
529718af76 | ||
|
|
69cd49afeb | ||
|
|
764c5abdf1 | ||
|
|
5588538fe2 | ||
|
|
fd41b69e05 | ||
|
|
bce759a258 | ||
|
|
f826b6025e | ||
|
|
020dab8dce | ||
|
|
e34c447e96 |
37
README.md
37
README.md
@@ -37,14 +37,35 @@ npm install @agentclientprotocol/claude-agent-acp@preview
|
||||
|
||||
### Subagent sessions
|
||||
|
||||
Subagents are exposed only after bilateral capability negotiation. Until the released ACP SDKs
|
||||
preserve the draft `clientCapabilities.subagents` field, a supporting client may advertise
|
||||
`nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`; the adapter mirrors the capability
|
||||
in its initialize response. The canonical field remains supported and takes precedence once it is
|
||||
available. Without either client signal, Agent/Task lifecycle keeps its legacy ordinary ACP
|
||||
tool-call representation and child interactions stay on the root session. Clients that use the
|
||||
historical `_meta["subagent-transcript"]` capability or `forwardSubagentText` session option retain
|
||||
the flattened child transcript behavior. See [AIR extensions](docs/air-extensions.md#native-subagent-sessions).
|
||||
A client that declares `clientCapabilities.subagents`, other than JetBrains AIR, gets each Agent or
|
||||
Task subagent as its own child session, following the [subagents RFD](https://github.com/agentclientprotocol/agent-client-protocol/blob/main/docs/rfds/subagents.mdx):
|
||||
|
||||
- `subagent_update` on the parent announces the child before any of its traffic, with the
|
||||
parent's `title` and `description` for it, and reports its work state: `running`,
|
||||
`requires_action` while one of its requests is open, and `idle` when its work ends, with
|
||||
`end_turn` or `cancelled` when the SDK gives the reason. A failure is `idle` with the `error`
|
||||
stop reason and the SDK's error text as its `error`. The child keeps its session for every
|
||||
delegation to it.
|
||||
- The prompt of the Agent or Task call that launches the child, and the message of each
|
||||
SendMessage call that resumes it, is a `session_message` in the child's transcript. The tool
|
||||
call stays a tool call of the session that made it.
|
||||
- `session/cancel` on the parent interrupts Claude Code, which stops the foreground children that
|
||||
the turn waits on and, currently, background children too. Each stopped child reports `idle`
|
||||
with `cancelled` from Claude Code's own events, and the open requests of the foreground ones are
|
||||
withdrawn. The adapter ends no child at a cancel itself, so a child that keeps running keeps its
|
||||
open requests. A child that still runs when Claude Code exits is `idle` with no stop reason.
|
||||
- A child whose parent is not yet known when it ends is not exposed, since the RFD forbids
|
||||
guessing its parent.
|
||||
- A client cannot prompt, cancel, or otherwise change a child yet, and a replay does not restore
|
||||
children yet.
|
||||
|
||||
JetBrains AIR keeps the earlier draft of the RFD (`subagent_spawned`, `subagent_state_update`),
|
||||
which it enables with `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`. See
|
||||
[AIR extensions](docs/air-extensions.md#native-subagent-sessions).
|
||||
|
||||
Without either signal, Agent and Task stay ordinary tool calls and child interactions stay on the
|
||||
root session. Clients that use the historical `_meta["subagent-transcript"]` capability or the
|
||||
`forwardSubagentText` session option retain the flattened child transcript.
|
||||
|
||||
## Contribution Policy
|
||||
|
||||
|
||||
@@ -206,20 +206,6 @@ The adapter keeps them where they are.
|
||||
| `quota` | `PromptResponse._meta.quota` | Token usage of the turn, also split by model. |
|
||||
| `authStatus` | `initialize` response `agentCapabilities._meta.authStatus` | The agent pushes `_auth/status_update`. The object carries no payload. |
|
||||
|
||||
## Auth source display
|
||||
|
||||
The connection-scoped `_auth/status_update` continues to report agent-owned authentication, not client-owned `providers/set` routing. When the active key source is `apiKeyHelper`, the adapter reads the effective helper command through SDK `resolveSettings` / the session's `SettingsManager`, including programmatic settings overrides.
|
||||
|
||||
A command containing the whole word `central` and the word sequence `proxy start` is displayed as:
|
||||
|
||||
```json
|
||||
{ "authStatus": { "kind": "gateway", "label": "JetBrains Air Gateway" } }
|
||||
```
|
||||
|
||||
Matching is case-insensitive and accepts multiple whitespace characters between `proxy` and `start`. The command's flags, URL, host and route are not checked. This is a weak display heuristic, not proof of configuration provenance, account ownership or entitlement. Inactive helpers and external cloud backends retain their existing classification. No helper command, key, headers, private route or inferred account is included in this payload.
|
||||
|
||||
Other active helpers use `label: "Anthropic API key"` and `detail: "API key helper"`; unknown key-source identifiers are not exposed as display text. CLI and SDK reads share this presentation, retain stale-probe protection, and suppress unchanged notifications.
|
||||
|
||||
## Zed conventions
|
||||
|
||||
The adapter keeps these Zed and upstream conventions unchanged for every client.
|
||||
@@ -1200,8 +1186,9 @@ This section covers only the AIR bridge.
|
||||
- The canonical client field is `clientCapabilities.subagents: {}`.
|
||||
- Released ACP SDKs can strip that draft field.
|
||||
AIR can instead declare `nativeSubagentSessions` in `_meta.jetbrains.air.capabilities`.
|
||||
- Either signal enables native subagent sessions. The canonical field takes precedence when it is available.
|
||||
- The agent advertises `nativeSubagentSessions` to AIR, and `agentCapabilities.sessionCapabilities.subagents` to every client.
|
||||
- Either signal enables native subagent sessions, and AIR gets this earlier draft of the RFD whichever it declares.
|
||||
- The agent advertises `nativeSubagentSessions` and `agentCapabilities.sessionCapabilities.subagents` to AIR.
|
||||
- A client that is not AIR and declares `clientCapabilities.subagents` gets the revised RFD instead (`subagent_update`, `session_message`), see the README.
|
||||
- With native sessions, the adapter sends `subagent_spawned` and `subagent_state_update`.
|
||||
The child output goes to the child session. The Agent or Task tool call is not the subagent card.
|
||||
- `subagent_spawned` can carry an optional `prompt`: the exact prompt of the child session.
|
||||
|
||||
208
src/acp-agent.ts
208
src/acp-agent.ts
@@ -63,7 +63,6 @@ import {
|
||||
CanUseTool,
|
||||
FastModeDisabledReason,
|
||||
FastModeState,
|
||||
filterEscalatingDefaultMode,
|
||||
getSessionMessages,
|
||||
getSubagentMessages,
|
||||
HookCallback,
|
||||
@@ -80,7 +79,6 @@ import {
|
||||
PreToolUseHookInput,
|
||||
Query,
|
||||
query,
|
||||
resolveSettings,
|
||||
SDKAssistantMessageError,
|
||||
SDKActiveGoalMessage,
|
||||
SDKMessage,
|
||||
@@ -111,6 +109,7 @@ import {
|
||||
asSdkSessionNotification,
|
||||
clientSupportsSubagents,
|
||||
SubagentAwareSessionCapabilities,
|
||||
subagentForm,
|
||||
type SubagentState,
|
||||
} from "./acp-subagents.js";
|
||||
import {
|
||||
@@ -120,6 +119,7 @@ import {
|
||||
NativeSubagentRuntime,
|
||||
nativeSubagentState,
|
||||
resumedNativeSubagentId,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
} from "./native-subagents.js";
|
||||
import {
|
||||
@@ -2560,12 +2560,6 @@ export class ClaudeAcpAgent {
|
||||
/** Last auth identity reported to the client, connection-scoped like
|
||||
* `authenticate`/`logout`. Undefined means "not determined yet". */
|
||||
currentAuthStatus?: AuthStatus;
|
||||
private authStatusSettings?: {
|
||||
manager: SettingsManager;
|
||||
override?: Settings;
|
||||
env?: Options["env"];
|
||||
};
|
||||
private authStatusHelper?: string;
|
||||
/** In-flight `claude auth status --json` probe, shared by every caller so
|
||||
* a concurrent `initialize` and start-of-prompt read never spawn two CLI
|
||||
* processes. */
|
||||
@@ -2804,6 +2798,8 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
// The subagents RFD has only the client capability. AIR's draft also had
|
||||
// this agent capability, which AIR gets (`docs/air-extensions.md`).
|
||||
const sessionCapabilities: SubagentAwareSessionCapabilities = {
|
||||
additionalDirectories: {},
|
||||
close: {},
|
||||
@@ -2811,7 +2807,7 @@ export class ClaudeAcpAgent {
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
subagents: {},
|
||||
...(this.toolCallCapabilities.air.client ? { subagents: {} } : {}),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -3160,31 +3156,7 @@ export class ClaudeAcpAgent {
|
||||
* under an apiKeyHelper) means "nothing to add", not "logged out" — keep
|
||||
* what the CLI probe already established instead of overwriting it.
|
||||
*/
|
||||
private useAuthStatusSettings(
|
||||
manager: SettingsManager,
|
||||
settingsOverride?: Settings,
|
||||
launchEnv?: Options["env"],
|
||||
): void {
|
||||
const helper = manager.getApiKeyHelper(settingsOverride);
|
||||
const changed =
|
||||
manager.getCwd() !== (this.authStatusSettings?.manager.getCwd() ?? process.cwd()) ||
|
||||
helper !== this.authStatusHelper ||
|
||||
JSON.stringify(launchEnv) !== JSON.stringify(this.authStatusSettings?.env);
|
||||
this.authStatusSettings = { manager, override: settingsOverride, env: launchEnv };
|
||||
this.authStatusHelper = helper;
|
||||
if (changed) {
|
||||
// The epoch bump discards any probe that read the old context, and the
|
||||
// session account may carry no identity signal (an apiKeyHelper), so
|
||||
// only a probe in the new context can classify it.
|
||||
this.authEpoch += 1;
|
||||
void this.probeCliAuthStatus({ fresh: true });
|
||||
}
|
||||
}
|
||||
|
||||
private publishSessionAccountIdentity(
|
||||
account: AccountInfo | undefined,
|
||||
apiKeyHelper?: string,
|
||||
): void {
|
||||
private publishSessionAccountIdentity(account: AccountInfo | undefined): void {
|
||||
if (this.providerConfig) {
|
||||
if (!this.loggedOverriddenAccount) {
|
||||
this.loggedOverriddenAccount = true;
|
||||
@@ -3197,7 +3169,7 @@ export class ClaudeAcpAgent {
|
||||
if (this.gatewayAuthRequest) {
|
||||
return;
|
||||
}
|
||||
const fromSession = fromAccountInfo(account, apiKeyHelper);
|
||||
const fromSession = fromAccountInfo(account);
|
||||
if (fromSession) {
|
||||
this.setAuthStatus(fromSession);
|
||||
} else if (!this.loggedUninformativeAccount) {
|
||||
@@ -3231,8 +3203,6 @@ export class ClaudeAcpAgent {
|
||||
// auth-affecting event describes a world that no longer exists. Remember
|
||||
// which one this read belongs to and drop the answer if it moved on.
|
||||
const epoch = this.authEpoch;
|
||||
const context = this.authStatusSettings;
|
||||
const apiKeyHelper = context?.manager.getApiKeyHelper(context.override);
|
||||
// ACP gateway auth bypasses the CLI credential store entirely, so the
|
||||
// probe would report an identity that is not the one being used. A mere
|
||||
// client provider override (`providers/set`) does NOT skip the probe: it
|
||||
@@ -3244,12 +3214,8 @@ export class ClaudeAcpAgent {
|
||||
let stdout: string;
|
||||
try {
|
||||
const cliPath = await claudeCliPath();
|
||||
const args = ["auth", "status", "--json"];
|
||||
if (context?.override) args.push("--settings", JSON.stringify(context.override));
|
||||
({ stdout } = await execFileAsync(cliPath, args, {
|
||||
({ stdout } = await execFileAsync(cliPath, ["auth", "status", "--json"], {
|
||||
timeout: AUTH_STATUS_PROBE_TIMEOUT_MS,
|
||||
cwd: context?.manager.getCwd() ?? process.cwd(),
|
||||
env: context?.env ? { ...process.env, ...context.env } : process.env,
|
||||
}));
|
||||
} catch (error) {
|
||||
const failed = error as { stdout?: unknown; killed?: boolean; signal?: unknown } | null;
|
||||
@@ -3276,15 +3242,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
stdout = failed.stdout;
|
||||
}
|
||||
let status = fromCliStatus(stdout, apiKeyHelper);
|
||||
if (!context && status?.kind === "api_key" && status.detail === "API key helper") {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: process.cwd() });
|
||||
status = fromCliStatus(stdout, filterEscalatingDefaultMode(resolved).apiKeyHelper);
|
||||
} catch {
|
||||
this.logger.error("Could not resolve auth source settings");
|
||||
}
|
||||
}
|
||||
const status = fromCliStatus(stdout);
|
||||
if (!status) {
|
||||
this.logger.error("claude auth status returned unparseable output");
|
||||
return undefined;
|
||||
@@ -3585,11 +3543,6 @@ export class ClaudeAcpAgent {
|
||||
// that finds the new credential must not be the one the refusal skipped.
|
||||
// The guard itself consumes the result only at the next turn boundary (see
|
||||
// `markSessionsWhoseAccountKindChanged`).
|
||||
this.useAuthStatusSettings(
|
||||
session.settingsManager,
|
||||
session.effortSettingsOverride,
|
||||
(session.creationParams?._meta as NewSessionMeta | undefined)?.claudeCode?.options?.env,
|
||||
);
|
||||
void this.probeCliAuthStatus();
|
||||
const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
|
||||
if (signOutRespawn) session = await signOutRespawn;
|
||||
@@ -3842,11 +3795,7 @@ export class ClaudeAcpAgent {
|
||||
logger: this.logger,
|
||||
// The guard reads the account anyway; reuse that read to keep the
|
||||
// reported identity current, refusal or not.
|
||||
onAccount: (account) =>
|
||||
this.publishSessionAccountIdentity(
|
||||
account,
|
||||
session.settingsManager.getApiKeyHelper(session.effortSettingsOverride),
|
||||
),
|
||||
onAccount: (account) => this.publishSessionAccountIdentity(account),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4147,7 +4096,7 @@ export class ClaudeAcpAgent {
|
||||
* stamps from `parent_tool_use_id`, and never reach the top-level feed
|
||||
* as the turn's answer. */
|
||||
const subagents = (session.nativeSubagentRuntime ??= new NativeSubagentRuntime(
|
||||
clientSupportsSubagents(this.clientCapabilities),
|
||||
subagentForm(this.clientCapabilities),
|
||||
params.sessionId,
|
||||
session,
|
||||
async (notification) => this.client.sessionUpdate(asSdkSessionNotification(notification)),
|
||||
@@ -5904,6 +5853,10 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
break;
|
||||
}
|
||||
// In the RFD form, a resumed child runs under the call that
|
||||
// resumed it, and the end of an earlier delegation ends
|
||||
// nothing: the child and its live task belong to the new run.
|
||||
if (subagents.isStaleEnd(message.task_id, message.tool_use_id)) break;
|
||||
// The task settled — no further tool calls can originate
|
||||
// from it, so its registry entry can be dropped.
|
||||
await subagents.finishTask(
|
||||
@@ -5911,6 +5864,7 @@ export class ClaudeAcpAgent {
|
||||
message.status,
|
||||
sendUpdate,
|
||||
message.tool_use_id,
|
||||
{ reason: message.reason },
|
||||
);
|
||||
await asyncTasks.taskNotification({
|
||||
task_id: message.task_id,
|
||||
@@ -5953,7 +5907,13 @@ export class ClaudeAcpAgent {
|
||||
message.patch.status === "failed" ||
|
||||
message.patch.status === "killed"
|
||||
) {
|
||||
await subagents.finishTask(message.task_id, message.patch.status, sendUpdate);
|
||||
await subagents.finishTask(
|
||||
message.task_id,
|
||||
message.patch.status,
|
||||
sendUpdate,
|
||||
undefined,
|
||||
{ error: message.patch.status === "failed" ? message.patch.error : undefined },
|
||||
);
|
||||
const parentToolUseId = session.liveBackgroundTasks.get(
|
||||
message.task_id,
|
||||
)?.parentToolUseId;
|
||||
@@ -7416,20 +7376,26 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
const resumedAgentId = resumedNativeSubagentId(message.tool_use_result);
|
||||
if (resumedAgentId) {
|
||||
resumeLiveTask(resumedAgentId);
|
||||
await subagents.taskResumed(
|
||||
const resume = sendMessageResume(
|
||||
session.toolUseCache,
|
||||
resumedAgentId,
|
||||
sendUpdate,
|
||||
sendMessageResumePrompt(
|
||||
session.toolUseCache,
|
||||
resumedAgentId,
|
||||
Array.isArray(content)
|
||||
? content.flatMap((block) =>
|
||||
block.type === "tool_result" ? [block.tool_use_id] : [],
|
||||
)
|
||||
: [],
|
||||
),
|
||||
Array.isArray(content)
|
||||
? content.flatMap((block) =>
|
||||
block.type === "tool_result" ? [block.tool_use_id] : [],
|
||||
)
|
||||
: [],
|
||||
);
|
||||
// In the RFD form the result can arrive after the run it
|
||||
// started ended, and then resumes nothing.
|
||||
if (!subagents.isEndedDelegation(resumedAgentId, resume?.toolUseId)) {
|
||||
resumeLiveTask(resumedAgentId);
|
||||
await subagents.sendMessageResumed(
|
||||
resumedAgentId,
|
||||
sendUpdate,
|
||||
resume?.text,
|
||||
resume?.toolUseId,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7495,13 +7461,18 @@ export class ClaudeAcpAgent {
|
||||
if (toolCallId === null || !session.emittedToolCalls.has(toolCallId)) {
|
||||
break;
|
||||
}
|
||||
const subagentParentToolUseId = message.parent_tool_use_id
|
||||
? [...session.liveBackgroundTasks.values()].some(
|
||||
(task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id,
|
||||
)
|
||||
// The beat is in the subagent whose Agent call is
|
||||
// `parent_tool_use_id`, unless it reports on that Agent call
|
||||
// itself: the Agent call's own heartbeat falls back to its id, and
|
||||
// a tool call is never its own parent.
|
||||
const subagentParentToolUseId =
|
||||
message.parent_tool_use_id &&
|
||||
message.parent_tool_use_id !== toolCallId &&
|
||||
[...session.liveBackgroundTasks.values()].some(
|
||||
(task) => task.isSubagent && task.parentToolUseId === message.parent_tool_use_id,
|
||||
)
|
||||
? message.parent_tool_use_id
|
||||
: undefined
|
||||
: undefined;
|
||||
: undefined;
|
||||
// `tool_name` names the tool that reports the beat. When the beat
|
||||
// falls back to the parent call, AIR gets the name of that call,
|
||||
// or no name. Every other client gets `tool_name`, like upstream.
|
||||
@@ -7746,9 +7717,18 @@ export class ClaudeAcpAgent {
|
||||
// their compaction is running. Close it before ANY cancelled prompt
|
||||
// resolves, not just the active-turn idle/backstop paths.
|
||||
await session.contextCompaction?.interrupt();
|
||||
// A cancel drops the state of each tool call (the session that a
|
||||
// permission request emitted it in, the fields the client holds, the hook
|
||||
// callbacks), even when publishing fails. AIR's draft ends every child
|
||||
// here, so a later update of an ended child's tool call is then dropped
|
||||
// instead of reaching that child's session. The RFD form keeps the state:
|
||||
// a background subagent can keep running after a cancel
|
||||
// (agentclientprotocol/agent-client-protocol#2308), and its tool call that
|
||||
// a permission request emitted on the root before the child was announced
|
||||
// must stay on the root, which the RFD requires. Each tool call drops its
|
||||
// own state when it ends.
|
||||
try {
|
||||
await session.nativeSubagentRuntime?.finishAll(
|
||||
"cancelled",
|
||||
await session.nativeSubagentRuntime?.parentCancelled(
|
||||
session.nativeSubagentDeliver ??
|
||||
(async (notification) =>
|
||||
this.client.sessionUpdate(asSdkSessionNotification(notification))),
|
||||
@@ -7759,9 +7739,11 @@ export class ClaudeAcpAgent {
|
||||
error,
|
||||
);
|
||||
} finally {
|
||||
session.eagerToolCallSessions?.clear();
|
||||
session.toolCallFields?.clear();
|
||||
clearHookCallbacks(params.sessionId);
|
||||
if (session.nativeSubagentRuntime?.form !== "rfd") {
|
||||
session.eagerToolCallSessions?.clear();
|
||||
session.toolCallFields?.clear();
|
||||
clearHookCallbacks(params.sessionId);
|
||||
}
|
||||
}
|
||||
// The user messages this cancel abandons while Claude Code may still have
|
||||
// them queued: they are dropped from its queue before the interrupt.
|
||||
@@ -8301,7 +8283,10 @@ export class ClaudeAcpAgent {
|
||||
})
|
||||
: undefined;
|
||||
let replayTurnId: string | undefined;
|
||||
const nativeReplayEnabled = clientSupportsSubagents(this.clientCapabilities);
|
||||
// Only AIR's draft replays child sessions so far. A client on the RFD gets
|
||||
// no children on replay, which the RFD allows (it SHOULD replay them), until
|
||||
// the replay keeps the children's live session ids.
|
||||
const nativeReplayEnabled = subagentForm(this.clientCapabilities) === "air";
|
||||
const replayCompactionUpdates = clientSupportsCompactionUpdates(this.clientCapabilities);
|
||||
const replayTerminalStates = new Map<string, "completed" | "failed" | "cancelled">();
|
||||
const replayChildren = new Map<
|
||||
@@ -8719,6 +8704,21 @@ export class ClaudeAcpAgent {
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Runs `request`, a request that `requestSessionId` sends for the session
|
||||
* `ownerSessionId`. When that is a subagent's child session, the child
|
||||
* reports that it awaits the user while the request is open (the RFD form,
|
||||
* see `NativeSubagentRuntime.awaitingUser`). */
|
||||
private withChildAwaitingUser<T>(
|
||||
ownerSessionId: string,
|
||||
requestSessionId: string,
|
||||
request: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const runtime = this.sessions[ownerSessionId]?.nativeSubagentRuntime;
|
||||
return runtime && requestSessionId !== ownerSessionId
|
||||
? runtime.awaitingUser(requestSessionId, request)
|
||||
: request();
|
||||
}
|
||||
|
||||
/** Mark a client request of the subagent `agentID` (absent for the main
|
||||
* conversation) as awaiting user input for exactly the lifetime of its
|
||||
* promise (see `Session.openUserInputs`). Steering consults the open
|
||||
@@ -8873,10 +8873,12 @@ export class ClaudeAcpAgent {
|
||||
// The request goes to a subagent's child session for a tool call of that
|
||||
// subagent, but it blocks the turn of the owner session.
|
||||
try {
|
||||
return await this.withPendingUserInput(
|
||||
ownerSessionId,
|
||||
() => raceWithAbort(this.client.requestPermission(params, signal), signal),
|
||||
agentID,
|
||||
return await this.withChildAwaitingUser(ownerSessionId, params.sessionId, () =>
|
||||
this.withPendingUserInput(
|
||||
ownerSessionId,
|
||||
() => raceWithAbort(this.client.requestPermission(params, signal), signal),
|
||||
agentID,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (signal.aborted) {
|
||||
@@ -9344,10 +9346,12 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
let response;
|
||||
try {
|
||||
response = await this.withPendingUserInput(
|
||||
sessionId,
|
||||
() => this.client.createElicitation(createRequest, signal),
|
||||
agentID,
|
||||
response = await this.withChildAwaitingUser(sessionId, requestSessionId, () =>
|
||||
this.withPendingUserInput(
|
||||
sessionId,
|
||||
() => this.client.createElicitation(createRequest, signal),
|
||||
agentID,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
// A cancellation we requested (signal aborted) settles as an aborted tool
|
||||
@@ -10393,8 +10397,6 @@ export class ClaudeAcpAgent {
|
||||
prompt: input,
|
||||
options,
|
||||
});
|
||||
this.useAuthStatusSettings(settingsManager, configuredSettingsObject, userProvidedOptions?.env);
|
||||
const authSettingsEpoch = this.authEpoch;
|
||||
timing.phase("prepare-query");
|
||||
|
||||
// `query()` spawns the CLI at once. Any throw between here and the
|
||||
@@ -10420,12 +10422,7 @@ export class ClaudeAcpAgent {
|
||||
// Publish the identity BEFORE the guard can refuse this session. A
|
||||
// refusal is exactly when the client most needs to know which account it
|
||||
// was refused for.
|
||||
if (authSettingsEpoch === this.authEpoch) {
|
||||
this.publishSessionAccountIdentity(
|
||||
initializationResult.account,
|
||||
settingsManager.getApiKeyHelper(configuredSettingsObject),
|
||||
);
|
||||
}
|
||||
this.publishSessionAccountIdentity(initializationResult.account);
|
||||
|
||||
// Shared with the per-turn guard, so "warn once" spans the whole session.
|
||||
const claudeSubscriptionGuard: ClaudeSubscriptionGuardState = {};
|
||||
@@ -10668,10 +10665,7 @@ export class ClaudeAcpAgent {
|
||||
sessionFailureState:
|
||||
creationOpts.replayState?.sessionFailureState ?? createSessionFailureState(),
|
||||
claudeSubscriptionGuard,
|
||||
accountKind: fromAccountInfo(
|
||||
initializationResult.account,
|
||||
settingsManager.getApiKeyHelper(configuredSettingsObject),
|
||||
)?.kind,
|
||||
accountKind: fromAccountInfo(initializationResult.account)?.kind,
|
||||
fileChangeReporter,
|
||||
};
|
||||
timing.phase("register");
|
||||
|
||||
@@ -6,17 +6,33 @@ import type {
|
||||
import {
|
||||
AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY,
|
||||
clientSupportsAirCapability,
|
||||
isAirClient,
|
||||
} from "./air-extension.js";
|
||||
|
||||
export { AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY } from "./air-extension.js";
|
||||
|
||||
/**
|
||||
* Temporary typed surface for agentclientprotocol/agent-client-protocol#1992.
|
||||
* How a client gets native subagent sessions.
|
||||
*
|
||||
* The wire contract is already defined by the ACP draft, but the published
|
||||
* TypeScript SDK does not contain it yet. Keep the compatibility boundary in
|
||||
* this file so it can be replaced by SDK exports without changing lifecycle
|
||||
* code when the draft ships.
|
||||
* - `rfd`: the subagents RFD (agentclientprotocol/agent-client-protocol#1992,
|
||||
* as revised): `subagent_update` and `session_message`, which the SDK types.
|
||||
* A client that declares `clientCapabilities.subagents` and is not AIR gets
|
||||
* it. (ACP v2 clients do not get subagents yet.)
|
||||
* - `air`: the earlier draft of that RFD, which AIR implements:
|
||||
* `subagent_spawned` and `subagent_state_update`, typed below. AIR gets it,
|
||||
* whichever capability it declares (`docs/air-extensions.md`).
|
||||
*/
|
||||
export type SubagentForm = "rfd" | "air";
|
||||
|
||||
/** The form of native subagent sessions that the client gets, or none. */
|
||||
export function subagentForm(capabilities?: ClientCapabilities | null): SubagentForm | undefined {
|
||||
if (!clientSupportsSubagents(capabilities)) return undefined;
|
||||
return isAirClient(capabilities) ? "air" : "rfd";
|
||||
}
|
||||
|
||||
/**
|
||||
* The earlier draft of the subagents RFD, which AIR implements and the SDK
|
||||
* does not type.
|
||||
*/
|
||||
export type SubagentSessionCapabilities = {
|
||||
cancel?: boolean;
|
||||
@@ -116,7 +132,7 @@ export function clientSupportsSubagents(capabilities?: ClientCapabilities | null
|
||||
return clientSupportsAirCapability(capabilities, AIR_NATIVE_SUBAGENT_SESSIONS_CAPABILITY);
|
||||
}
|
||||
|
||||
/** The only cast needed until the TypeScript SDK publishes PR #1992. */
|
||||
/** The cast that AIR's draft updates and the async task updates need. */
|
||||
export function asSdkSessionNotification(
|
||||
notification: AcpSessionNotification,
|
||||
): SessionNotification {
|
||||
|
||||
@@ -103,13 +103,6 @@ const NOT_LOGGED_IN: AuthStatus = {
|
||||
label: "Not logged in",
|
||||
};
|
||||
|
||||
const API_KEY_SOURCE_DETAILS = new Map([
|
||||
["apiKeyHelper", "API key helper"],
|
||||
["env", "env"],
|
||||
["ANTHROPIC_API_KEY", "ANTHROPIC_API_KEY"],
|
||||
["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_AUTH_TOKEN"],
|
||||
]);
|
||||
|
||||
/** Display names for the non-firstParty backends, where auth lives outside the
|
||||
* agent (AWS credentials, gcloud ADC, …). */
|
||||
const EXTERNAL_PROVIDER_LABELS: Record<string, string> = {
|
||||
@@ -169,10 +162,7 @@ export function accountInfoHasIdentitySignal(account: AccountInfo | undefined):
|
||||
/** Session-time mapping from the SDK's `AccountInfo`, the richest source when
|
||||
* it is populated. Returns `undefined` when the account carries no identity
|
||||
* signal, so callers keep the status they already know. */
|
||||
export function fromAccountInfo(
|
||||
account: AccountInfo | undefined,
|
||||
apiKeyHelper?: string,
|
||||
): AuthStatus | undefined {
|
||||
export function fromAccountInfo(account: AccountInfo | undefined): AuthStatus | undefined {
|
||||
if (!account || !accountInfoHasIdentitySignal(account)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -182,14 +172,13 @@ export function fromAccountInfo(
|
||||
apiKeySource: account.apiKeySource,
|
||||
email: account.email,
|
||||
organization: account.organization,
|
||||
apiKeyHelper,
|
||||
});
|
||||
}
|
||||
|
||||
/** Pre-session mapping from `claude auth status --json` stdout. Returns
|
||||
* `undefined` when the output is not the expected JSON object, so callers can
|
||||
* report "not known" instead of guessing. */
|
||||
export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus | undefined {
|
||||
export function fromCliStatus(stdout: string): AuthStatus | undefined {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(stdout);
|
||||
@@ -216,7 +205,6 @@ export function fromCliStatus(stdout: string, apiKeyHelper?: string): AuthStatus
|
||||
apiKeySource: status.apiKeySource,
|
||||
email: status.email,
|
||||
organization: status.orgName,
|
||||
apiKeyHelper,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -236,7 +224,6 @@ function mapAuthFields(fields: {
|
||||
apiKeySource?: string;
|
||||
email?: string;
|
||||
organization?: string;
|
||||
apiKeyHelper?: string;
|
||||
}): AuthStatus {
|
||||
const { apiProvider, subscriptionType, apiKeySource, email, organization } = fields;
|
||||
|
||||
@@ -250,19 +237,11 @@ function mapAuthFields(fields: {
|
||||
};
|
||||
}
|
||||
if (apiKeySource) {
|
||||
if (
|
||||
apiKeySource === "apiKeyHelper" &&
|
||||
typeof fields.apiKeyHelper === "string" &&
|
||||
/\bcentral\b/i.test(fields.apiKeyHelper) &&
|
||||
/\bproxy\s+start\b/i.test(fields.apiKeyHelper)
|
||||
) {
|
||||
return { kind: "gateway", label: "JetBrains Air Gateway" };
|
||||
}
|
||||
const detail = API_KEY_SOURCE_DETAILS.get(apiKeySource);
|
||||
return {
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
...(detail ? { detail } : {}),
|
||||
// The source ("apiKeyHelper", "env", …) is the specifics line.
|
||||
detail: apiKeySource,
|
||||
};
|
||||
}
|
||||
if (subscriptionType) {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { AcpSessionNotification, SubagentState } from "./acp-subagents.js";
|
||||
import { RequestError, type SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { AcpSessionNotification, SubagentForm, SubagentState } from "./acp-subagents.js";
|
||||
import { AIR_SUBAGENT_KEY, airExtensionMeta } from "./air-extension.js";
|
||||
|
||||
/** The work state of a child, as the RFD's `subagent_update.state` carries it. */
|
||||
type WorkState = NonNullable<
|
||||
Extract<SessionNotification["update"], { sessionUpdate: "subagent_update" }>["state"]
|
||||
>;
|
||||
|
||||
export type NativeSubagent = {
|
||||
sessionId: string;
|
||||
parentSessionId: string;
|
||||
@@ -9,9 +15,32 @@ export type NativeSubagent = {
|
||||
task: string;
|
||||
/**
|
||||
* The exact prompt of this generation, sent as `prompt` in
|
||||
* `subagent_spawned`. It is absent when the adapter has no prompt.
|
||||
* `subagent_spawned`, or as the child's first `session_message` in the RFD
|
||||
* 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`. */
|
||||
title?: string;
|
||||
/** The RFD form: the child's role, its `subagent_type`, for the title. */
|
||||
role?: string;
|
||||
/** The RFD form: the child's purpose, `subagent_update.description`. */
|
||||
description?: string;
|
||||
/**
|
||||
* The RFD form: the work state the client holds. The child is reusable, so
|
||||
* `idle` is not terminal. AIR's draft uses {@link terminalState} instead.
|
||||
*/
|
||||
workState?: WorkState["state"];
|
||||
/**
|
||||
* The RFD form: the child's idle snapshot reports a failure without the
|
||||
* SDK's error text, which a later event of the same failure may carry.
|
||||
*/
|
||||
missingError?: boolean;
|
||||
/** The RFD form: the open permission and question requests of the child. */
|
||||
openRequests?: number;
|
||||
/** The RFD form: the ids of the prompt messages in the child's transcript. */
|
||||
promptIds?: Set<string>;
|
||||
/** The RFD form: the calls that delegated to the child, whose runs ended. */
|
||||
endedRuns?: Set<string>;
|
||||
announced?: boolean;
|
||||
terminalState?: SubagentState;
|
||||
/** Connection-local single-flight state; never serialized on the wire. */
|
||||
@@ -29,6 +58,19 @@ export type NativeSubagentSession = {
|
||||
type Publish = (notification: AcpSessionNotification) => Promise<void>;
|
||||
type Logger = { log(message: string): void };
|
||||
|
||||
/** What the SDK says about the end of a task, beyond its status. */
|
||||
export type TaskEnd = {
|
||||
/** The SDK's text for a failure. */
|
||||
error?: string;
|
||||
/** The SDK's reason for an unusual end, such as `worker_restart`. */
|
||||
reason?: string;
|
||||
/**
|
||||
* The adapter knows that the work ended, but not why: the stream ended, or
|
||||
* the conversation was reset, with the task still running.
|
||||
*/
|
||||
unexplained?: boolean;
|
||||
};
|
||||
|
||||
type TaskStarted = {
|
||||
taskId: string;
|
||||
toolUseId?: string | null;
|
||||
@@ -56,6 +98,8 @@ const MAX_CHILD_TOOL_CALLS = 2048;
|
||||
*/
|
||||
export class NativeSubagentRuntime {
|
||||
readonly enabled: boolean;
|
||||
/** The form of the child sessions; see {@link SubagentForm}. */
|
||||
readonly form: SubagentForm | undefined;
|
||||
|
||||
private readonly children: Map<string, NativeSubagent>;
|
||||
private readonly taskByToolUse: Map<string, string>;
|
||||
@@ -70,18 +114,22 @@ export class NativeSubagentRuntime {
|
||||
*/
|
||||
private readonly childByToolCall = new Map<string, NativeSubagent>();
|
||||
private readonly taskFinishPromises = new Map<string, Promise<void>>();
|
||||
/** The RFD form: the session that made each SendMessage call. */
|
||||
private readonly senderByToolUse = new Map<string, string>();
|
||||
private readonly generationByTaskId = new Map<string, number>();
|
||||
private readonly pending = new Map<string, AcpSessionNotification[]>();
|
||||
private pendingCount = 0;
|
||||
|
||||
/** `form` is undefined for a client without native subagent sessions. */
|
||||
constructor(
|
||||
enabled: boolean,
|
||||
form: SubagentForm | undefined,
|
||||
private readonly rootSessionId: string,
|
||||
private readonly session: NativeSubagentSession,
|
||||
private readonly publish: Publish,
|
||||
private readonly logger: Logger,
|
||||
) {
|
||||
this.enabled = enabled;
|
||||
this.form = form;
|
||||
this.enabled = form !== undefined;
|
||||
this.children = session.nativeSubagentsByTaskId ??= new Map();
|
||||
this.taskByToolUse = session.nativeSubagentTaskIdByToolUseId ??= new Map();
|
||||
this.parentByToolUse = session.nativeSubagentParentByToolUseId ??= new Map();
|
||||
@@ -96,6 +144,16 @@ export class NativeSubagentRuntime {
|
||||
notification: AcpSessionNotification,
|
||||
deliver: Publish,
|
||||
forcedSessionId?: string,
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const routed = await this.routeUpdate(notification, deliver, forcedSessionId);
|
||||
if (routed && this.form === "rfd") this.rememberSendMessage(routed);
|
||||
return routed;
|
||||
}
|
||||
|
||||
private async routeUpdate(
|
||||
notification: AcpSessionNotification,
|
||||
deliver: Publish,
|
||||
forcedSessionId?: string,
|
||||
): Promise<AcpSessionNotification | null> {
|
||||
const { update } = notification;
|
||||
const claudeMeta = update._meta?.claudeCode as
|
||||
@@ -116,28 +174,47 @@ export class NativeSubagentRuntime {
|
||||
mergeSubagentIdentity(this.identityByToolUse.get(toolCallId), identity),
|
||||
);
|
||||
}
|
||||
const parentChild = claudeMeta?.parentToolUseId
|
||||
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)
|
||||
: undefined;
|
||||
const parentSessionId = claudeMeta?.parentToolUseId
|
||||
? this.childByParentToolUse.get(claudeMeta.parentToolUseId)?.sessionId
|
||||
? parentChild?.sessionId
|
||||
: this.rootSessionId;
|
||||
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
|
||||
|
||||
// The RFD form: a call made inside a child that is not announced yet
|
||||
// establishes no parent, because the RFD forbids announcing its child
|
||||
// under the root or before that child. The update is buffered below
|
||||
// under the child's tool use, and routed again, with the child as the
|
||||
// parent, once the child is announced.
|
||||
const parentPending =
|
||||
this.form === "rfd" && !!claudeMeta?.parentToolUseId && !parentChild?.announced;
|
||||
const child = this.childByParentToolUse.get(toolCallId);
|
||||
if (child && !child.announced) {
|
||||
child.parentSessionId = parentSessionId ?? this.rootSessionId;
|
||||
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
for (const pending of this.takePending(toolCallId)) await deliver(pending);
|
||||
}
|
||||
if (!child && isFailedToolCallUpdate(update)) {
|
||||
this.takePending(toolCallId);
|
||||
const initial = this.controlByToolUse.get(toolCallId);
|
||||
this.cleanupControl(toolCallId);
|
||||
if (forcedSessionId) {
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
if (!parentPending) {
|
||||
this.parentByToolUse.set(toolCallId, parentSessionId ?? this.rootSessionId);
|
||||
if (child && !child.announced) {
|
||||
child.parentSessionId = parentSessionId ?? this.rootSessionId;
|
||||
applySubagentIdentity(child, this.identityByToolUse.get(toolCallId));
|
||||
await this.announce(child);
|
||||
for (const pending of this.takePending(toolCallId)) await deliver(pending);
|
||||
}
|
||||
return failedControlFallback(initial, notification, parentSessionId ?? this.rootSessionId);
|
||||
}
|
||||
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
||||
if (this.form === "air") {
|
||||
if (!child && isFailedToolCallUpdate(update)) {
|
||||
this.takePending(toolCallId);
|
||||
const initial = this.controlByToolUse.get(toolCallId);
|
||||
this.cleanupControl(toolCallId);
|
||||
if (forcedSessionId) {
|
||||
return { ...notification, sessionId: forcedSessionId };
|
||||
}
|
||||
return failedControlFallback(
|
||||
initial,
|
||||
notification,
|
||||
parentSessionId ?? this.rootSessionId,
|
||||
);
|
||||
}
|
||||
return forcedSessionId ? { ...notification, sessionId: forcedSessionId } : null;
|
||||
}
|
||||
// The RFD form: the Agent/Task tool call is the parent's record of the
|
||||
// delegation, so it stays a tool call of the session that made it.
|
||||
}
|
||||
|
||||
const toolCallId =
|
||||
@@ -172,6 +249,19 @@ export class NativeSubagentRuntime {
|
||||
|
||||
async taskStarted(task: TaskStarted, deliver: Publish): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const known = this.form === "rfd" ? this.children.get(task.taskId) : undefined;
|
||||
if (known) {
|
||||
// A child keeps its session for every delegation to it. The SDK starts
|
||||
// a resumed agent again under the id of the call that resumed it.
|
||||
await this.resumeChild(
|
||||
known,
|
||||
task.taskId,
|
||||
promptText(task.prompt),
|
||||
task.toolUseId ?? undefined,
|
||||
deliver,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!task.subagentType) {
|
||||
if (task.toolUseId) {
|
||||
this.takePending(task.toolUseId);
|
||||
@@ -207,6 +297,11 @@ export class NativeSubagentRuntime {
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
...promptField(promptText(task.prompt) ?? identity?.prompt),
|
||||
...rfdLabels(
|
||||
identity?.name,
|
||||
identity?.subagentType ?? task.subagentType,
|
||||
identity?.description ?? task.description,
|
||||
),
|
||||
},
|
||||
!!knownParentSessionId || !task.toolUseId,
|
||||
deliver,
|
||||
@@ -219,6 +314,10 @@ export class NativeSubagentRuntime {
|
||||
* running `task_updated` patch or a SendMessage `resumedAgentId` is the
|
||||
* signal. A child that did not finish is not changed. The `prompt` is the
|
||||
* SendMessage text that resumed the child, when the adapter knows it.
|
||||
*
|
||||
* In the RFD form, the running patch only reports that the child runs: its
|
||||
* prompt is a guess (the latest SendMessage to the agent), and the
|
||||
* `task_started` or the SendMessage result carries the delegation.
|
||||
*/
|
||||
async taskResumed(taskId: string, deliver: Publish, prompt?: string): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
@@ -226,6 +325,10 @@ export class NativeSubagentRuntime {
|
||||
if (!previous) return;
|
||||
const finishing = this.taskFinishPromises.get(taskId) ?? previous.terminalPromise;
|
||||
if (finishing) await finishing.catch(() => {});
|
||||
if (this.form === "rfd") {
|
||||
await this.resumeChild(previous, taskId, undefined, undefined, deliver);
|
||||
return;
|
||||
}
|
||||
if (this.children.get(taskId) !== previous || previous.terminalState === undefined) return;
|
||||
await this.openGeneration(
|
||||
taskId,
|
||||
@@ -242,27 +345,99 @@ export class NativeSubagentRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The SDK reports in a SendMessage result that the call `toolUseId`, with
|
||||
* the message `prompt`, resumed the agent `taskId`. AIR's draft treats it as
|
||||
* any resume signal. In the RFD form, the call is the delegation, which the
|
||||
* child takes unless its run already ended: the result can arrive after it.
|
||||
*/
|
||||
async sendMessageResumed(
|
||||
taskId: string,
|
||||
deliver: Publish,
|
||||
prompt?: string,
|
||||
toolUseId?: string,
|
||||
): Promise<void> {
|
||||
if (this.form !== "rfd") return this.taskResumed(taskId, deliver, prompt);
|
||||
const child = this.children.get(taskId);
|
||||
if (!child) return;
|
||||
const finishing = this.taskFinishPromises.get(taskId);
|
||||
if (finishing) await finishing.catch(() => {});
|
||||
if (this.isEndedDelegation(taskId, toolUseId)) return;
|
||||
await this.resumeChild(child, taskId, promptText(prompt), toolUseId, deliver);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: whether the run of the call `toolUseId` that delegated to
|
||||
* the child `taskId` already ended. AIR's draft has no such runs.
|
||||
*/
|
||||
isEndedDelegation(taskId: string, toolUseId: string | null | undefined): boolean {
|
||||
if (this.form !== "rfd" || !toolUseId) return false;
|
||||
return this.children.get(taskId)?.endedRuns?.has(toolUseId) ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: whether `toolUseId` is an earlier delegation of the child
|
||||
* `taskId`, whose end does not end the current one. AIR's draft has no
|
||||
* such delegations.
|
||||
*/
|
||||
isStaleEnd(taskId: string, toolUseId: string | null | undefined): boolean {
|
||||
if (this.form !== "rfd" || !toolUseId) return false;
|
||||
const child = this.children.get(taskId);
|
||||
return (
|
||||
!!child &&
|
||||
child.parentToolUseId !== undefined &&
|
||||
toolUseId !== child.parentToolUseId &&
|
||||
this.childByParentToolUse.get(toolUseId) === child
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports that the SDK ended the task's work. `end` has what the SDK says
|
||||
* about it: its text for a failure, and its reason for an unusual end.
|
||||
*/
|
||||
async finishTask(
|
||||
taskId: string,
|
||||
status: unknown,
|
||||
deliver: Publish,
|
||||
toolUseId?: string | null,
|
||||
end?: TaskEnd,
|
||||
): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
const state = nativeSubagentState(status);
|
||||
const child = toolUseId ? this.childByParentToolUse.get(toolUseId) : this.children.get(taskId);
|
||||
if (child && toolUseId && this.taskByToolUse.get(toolUseId) !== taskId) return;
|
||||
if (!state || !child || child.terminalState !== undefined) return;
|
||||
if (!state || !child) return;
|
||||
if (this.form === "rfd") {
|
||||
if (this.isStaleEnd(taskId, toolUseId)) return;
|
||||
if (!child.announced && !child.announcePromise) {
|
||||
this.withdraw(taskId, child);
|
||||
return;
|
||||
}
|
||||
}
|
||||
const existing = this.taskFinishPromises.get(taskId);
|
||||
if (existing) return existing;
|
||||
if (existing) {
|
||||
await existing;
|
||||
if (this.form === "rfd") await this.supplyError(child, state, end?.error);
|
||||
return;
|
||||
}
|
||||
if (this.form === "air" ? child.terminalState !== undefined : child.workState === "idle") {
|
||||
if (this.form === "rfd") await this.supplyError(child, state, end?.error);
|
||||
return;
|
||||
}
|
||||
|
||||
const finish = Promise.resolve().then(async () => {
|
||||
try {
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
await this.announce(child);
|
||||
if (child.parentToolUseId) {
|
||||
for (const pending of this.takePending(child.parentToolUseId)) await deliver(pending);
|
||||
}
|
||||
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
||||
if (this.form === "rfd") {
|
||||
await this.reportState(child, rfdEndState(state, end));
|
||||
child.missingError = state === "failed" && !end?.error && !end?.unexplained;
|
||||
if (child.parentToolUseId) (child.endedRuns ??= new Set()).add(child.parentToolUseId);
|
||||
} else {
|
||||
await finishNativeSubagent(this.session, taskId, state, this.publish);
|
||||
}
|
||||
} finally {
|
||||
if (child.parentToolUseId) {
|
||||
this.cleanupControl(child.parentToolUseId);
|
||||
@@ -277,12 +452,20 @@ export class NativeSubagentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends every child that still runs, because the stream ended or the
|
||||
* conversation was reset. AIR's draft reports `state` for each. In the RFD
|
||||
* form the cause of each child's end is unknown, so it is idle with no stop
|
||||
* reason: a child that a cancel stopped already reported it from the SDK's
|
||||
* events, and a background child that outlived the cancel was not cancelled.
|
||||
*/
|
||||
async finishAll(state: SubagentState, deliver: Publish): Promise<void> {
|
||||
const errors: unknown[] = [];
|
||||
const end = this.form === "rfd" ? { unexplained: true } : undefined;
|
||||
try {
|
||||
for (const taskId of [...this.children.keys()].reverse()) {
|
||||
try {
|
||||
await this.finishTask(taskId, state, deliver);
|
||||
await this.finishTask(taskId, state, deliver, undefined, end);
|
||||
} catch (error) {
|
||||
errors.push(error);
|
||||
}
|
||||
@@ -298,20 +481,145 @@ export class NativeSubagentRuntime {
|
||||
if (errors.length > 1) throw new AggregateError(errors, "Failed to finish native subagents");
|
||||
}
|
||||
|
||||
/**
|
||||
* The parent session got `session/cancel`. AIR's draft ends every child as
|
||||
* cancelled here. The RFD form reports nothing: acknowledging a cancel is no
|
||||
* evidence that a child stopped. Claude Code's interrupt stops the
|
||||
* foreground children that the turn waits on and, currently, background
|
||||
* children too, and the SDK's task events report each as cancelled. A child
|
||||
* that keeps running keeps its open requests
|
||||
* (agentclientprotocol/agent-client-protocol#2308).
|
||||
*/
|
||||
async parentCancelled(deliver: Publish): Promise<void> {
|
||||
if (this.form === "air") await this.finishAll("cancelled", deliver);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs `request`, a permission or question request of the session
|
||||
* `sessionId`. In the RFD form, a child waiting on it is `requires_action`
|
||||
* until its last open request ends.
|
||||
*/
|
||||
async awaitingUser<T>(sessionId: string, request: () => Promise<T>): Promise<T> {
|
||||
const child = this.form === "rfd" ? this.childOfSession(sessionId) : undefined;
|
||||
if (!child?.announced) return request();
|
||||
child.openRequests = (child.openRequests ?? 0) + 1;
|
||||
if (child.openRequests === 1 && child.workState === "running") {
|
||||
await this.reportState(child, { state: "requires_action" }).catch((error) =>
|
||||
this.logger.log(`Failed to report that subagent ${child.sessionId} waits: ${error}`),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return await request();
|
||||
} finally {
|
||||
child.openRequests = (child.openRequests ?? 1) - 1;
|
||||
if (child.openRequests === 0 && child.workState === "requires_action") {
|
||||
await this.reportState(child, { state: "running" }).catch((error) =>
|
||||
this.logger.log(`Failed to report that subagent ${child.sessionId} runs: ${error}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
discardPending(parentToolUseId: string): void {
|
||||
this.takePending(parentToolUseId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an update to the child session. An update of a finished child is
|
||||
* dropped: it never goes to the root session.
|
||||
* The RFD form: the child runs again, and keeps its session. `toolUseId` is
|
||||
* the call that delegated, usually a SendMessage: the SDK runs the resumed
|
||||
* work under its id, so it becomes the child's current delegation, whose
|
||||
* end ends the run, and its prompt is the message with that id. A child
|
||||
* that is already working only takes the delegation; a prompt already in
|
||||
* its transcript is not sent again. A child that was never announced is
|
||||
* not changed.
|
||||
*/
|
||||
private async resumeChild(
|
||||
child: NativeSubagent,
|
||||
taskId: string,
|
||||
prompt: string | undefined,
|
||||
toolUseId: string | undefined,
|
||||
deliver: Publish,
|
||||
): Promise<void> {
|
||||
if (!child.announced) return;
|
||||
if (toolUseId && toolUseId !== child.parentToolUseId) {
|
||||
this.taskByToolUse.set(toolUseId, taskId);
|
||||
this.childByParentToolUse.set(toolUseId, child);
|
||||
child.parentToolUseId = toolUseId;
|
||||
}
|
||||
if (!isWorking(child)) {
|
||||
await this.reportState(child, {
|
||||
state: child.openRequests ? "requires_action" : "running",
|
||||
});
|
||||
}
|
||||
if (prompt && toolUseId) {
|
||||
await publishPrompt(
|
||||
child,
|
||||
prompt,
|
||||
toolUseId,
|
||||
this.senderByToolUse.get(toolUseId),
|
||||
this.publish,
|
||||
);
|
||||
}
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: a child that ends before its parent is known stays
|
||||
* unexposed, since the RFD forbids guessing its parent. Its buffered updates
|
||||
* are dropped, and it is forgotten, so a late frame does not announce it.
|
||||
* (Buffering until that frame would also meet the RFD, but the frame comes
|
||||
* before the task in the SDK's order, so a child ending without it is rare.)
|
||||
*/
|
||||
private withdraw(taskId: string, child: NativeSubagent): void {
|
||||
this.children.delete(taskId);
|
||||
if (child.parentToolUseId) {
|
||||
this.takePending(child.parentToolUseId);
|
||||
this.cleanupControl(child.parentToolUseId);
|
||||
this.childByParentToolUse.delete(child.parentToolUseId);
|
||||
this.taskByToolUse.delete(child.parentToolUseId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: a later event of a failure can carry the SDK's error text
|
||||
* that the reported idle snapshot lacks.
|
||||
*/
|
||||
private async supplyError(
|
||||
child: NativeSubagent,
|
||||
state: SubagentState,
|
||||
error: string | undefined,
|
||||
): Promise<void> {
|
||||
if (state !== "failed" || !error || !child.missingError) return;
|
||||
await this.reportState(child, rfdEndState("failed", { error }));
|
||||
}
|
||||
|
||||
/** Announces `child` in the client's form. A runtime without one never does. */
|
||||
private async announce(child: NativeSubagent): Promise<void> {
|
||||
if (this.form) await announceNativeSubagent(child, this.publish, this.form);
|
||||
}
|
||||
|
||||
/** The RFD form: publishes the work state of `child` on its parent. */
|
||||
private async reportState(child: NativeSubagent, state: WorkState): Promise<void> {
|
||||
child.workState = state.state;
|
||||
child.missingError = false;
|
||||
await this.publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: { sessionUpdate: "subagent_update", sessionId: child.sessionId, state },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Routes an update to the child session. It never goes to the root session:
|
||||
* in AIR's draft, an update of a finished child is dropped.
|
||||
*/
|
||||
private toChild(
|
||||
child: NativeSubagent,
|
||||
notification: AcpSessionNotification,
|
||||
toolCallId: string | undefined,
|
||||
): AcpSessionNotification | null {
|
||||
if (child.terminalState !== undefined || child.terminalPromise) {
|
||||
// AIR's draft ends a child for good. In the RFD form an idle child is
|
||||
// reusable, and its background work can still report.
|
||||
if (this.form === "air" && (child.terminalState !== undefined || child.terminalPromise)) {
|
||||
this.logger.log(
|
||||
`Session ${this.rootSessionId}: ignoring late update for terminal subagent ${child.sessionId}`,
|
||||
);
|
||||
@@ -327,10 +635,11 @@ export class NativeSubagentRuntime {
|
||||
* tool call when the work started. An async task can outlive that child: a
|
||||
* subagent can start a background command and end its turn. So the route
|
||||
* still sends `async_task_spawned`, `async_task_progress` and
|
||||
* `async_task_state_update` to the child generation after it finished. The route drops every other update
|
||||
* after the child finished. `undefined` means that the root session owns the
|
||||
* tool call. `eagerSessionId` is the session where a permission request
|
||||
* created the tool call before the stream routed it.
|
||||
* `async_task_state_update` to the child generation after it finished. In
|
||||
* AIR's draft, the route drops every other update after the child finished;
|
||||
* in the RFD form, an idle child gets them all. `undefined` means that the
|
||||
* root session owns the tool call. `eagerSessionId` is the session where a
|
||||
* permission request created the tool call before the stream routed it.
|
||||
*/
|
||||
routeOfToolCall(
|
||||
toolCallId: string,
|
||||
@@ -349,6 +658,23 @@ export class NativeSubagentRuntime {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: remembers the session that made a SendMessage call, the
|
||||
* sender of the prompt when the call resumes a child.
|
||||
*/
|
||||
private rememberSendMessage(notification: AcpSessionNotification): void {
|
||||
const { update } = notification;
|
||||
if (update.sessionUpdate !== "tool_call") return;
|
||||
const claudeMeta = update._meta?.claudeCode as { toolName?: string } | undefined;
|
||||
if (claudeMeta?.toolName !== "SendMessage") return;
|
||||
this.senderByToolUse.delete(update.toolCallId);
|
||||
this.senderByToolUse.set(update.toolCallId, notification.sessionId);
|
||||
if (this.senderByToolUse.size > MAX_CHILD_TOOL_CALLS) {
|
||||
const oldest = this.senderByToolUse.keys().next().value;
|
||||
if (oldest !== undefined) this.senderByToolUse.delete(oldest);
|
||||
}
|
||||
}
|
||||
|
||||
private rememberToolCallOwner(toolCallId: string, child: NativeSubagent): void {
|
||||
this.childByToolCall.delete(toolCallId);
|
||||
this.childByToolCall.set(toolCallId, child);
|
||||
@@ -376,6 +702,7 @@ export class NativeSubagentRuntime {
|
||||
this.controlByToolUse.clear();
|
||||
this.childByParentToolUse.clear();
|
||||
this.taskFinishPromises.clear();
|
||||
this.senderByToolUse.clear();
|
||||
this.generationByTaskId.clear();
|
||||
this.pending.clear();
|
||||
this.pendingCount = 0;
|
||||
@@ -438,7 +765,14 @@ export class NativeSubagentRuntime {
|
||||
previous: NativeSubagent | undefined,
|
||||
fields: Pick<
|
||||
NativeSubagent,
|
||||
"parentSessionId" | "parentToolUseId" | "name" | "task" | "prompt"
|
||||
| "parentSessionId"
|
||||
| "parentToolUseId"
|
||||
| "name"
|
||||
| "task"
|
||||
| "prompt"
|
||||
| "title"
|
||||
| "role"
|
||||
| "description"
|
||||
>,
|
||||
announce: boolean,
|
||||
deliver: Publish,
|
||||
@@ -455,10 +789,20 @@ export class NativeSubagentRuntime {
|
||||
this.controlByToolUse.delete(toolUseId);
|
||||
}
|
||||
if (!announce) return;
|
||||
await announceNativeSubagent(child, this.publish);
|
||||
await this.announce(child);
|
||||
for (const pending of toolUseId ? this.takePending(toolUseId) : []) await deliver(pending);
|
||||
}
|
||||
|
||||
/**
|
||||
* The ACP session id of a child: the SDK agent id, and in AIR's draft a
|
||||
* generation suffix for each resume. The RFD needs it unique within the
|
||||
* connection and never under two parents. Agent ids are random, and an
|
||||
* agent keeps its parent, so only a fork could repeat one under another
|
||||
* root. That holds today because the SDK's `forkSession` copies no
|
||||
* `subagents/` history, so a fork cannot resume an agent of the original.
|
||||
* If a fork ever can, the RFD requires remapping the copied ids, for
|
||||
* example by deriving the child id from the root session id.
|
||||
*/
|
||||
private nextChildSessionId(taskId: string, previous: NativeSubagent | undefined): string {
|
||||
if (!previous) {
|
||||
this.generationByTaskId.set(taskId, 1);
|
||||
@@ -470,13 +814,45 @@ export class NativeSubagentRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Announces the child on its parent, once, before any update of the child.
|
||||
* The RFD form announces it as running and then sends the prompt as the
|
||||
* first message of the child. The child gets no capabilities, so the client
|
||||
* may not cancel or otherwise change it.
|
||||
*/
|
||||
export async function announceNativeSubagent(
|
||||
child: NativeSubagent,
|
||||
publish: Publish,
|
||||
form: SubagentForm,
|
||||
): Promise<void> {
|
||||
if (child.announced) return;
|
||||
if (child.announcePromise) return child.announcePromise;
|
||||
const announce = Promise.resolve().then(async () => {
|
||||
if (form === "rfd") {
|
||||
child.workState = "running";
|
||||
await publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: child.sessionId,
|
||||
...(child.title ? { title: child.title } : {}),
|
||||
...(child.description ? { description: child.description } : {}),
|
||||
state: { state: "running" },
|
||||
},
|
||||
});
|
||||
child.announced = true;
|
||||
// The immediate parent made the Agent/Task call that launched the child.
|
||||
if (child.prompt) {
|
||||
await publishPrompt(
|
||||
child,
|
||||
child.prompt,
|
||||
child.parentToolUseId,
|
||||
child.parentSessionId,
|
||||
publish,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
@@ -508,7 +884,7 @@ export async function finishNativeSubagent(
|
||||
if (!child || child.terminalState !== undefined) return;
|
||||
if (child.terminalPromise) return child.terminalPromise;
|
||||
const finish = Promise.resolve().then(async () => {
|
||||
await announceNativeSubagent(child, publish);
|
||||
await announceNativeSubagent(child, publish, "air");
|
||||
await publish({
|
||||
sessionId: child.parentSessionId,
|
||||
update: {
|
||||
@@ -527,6 +903,98 @@ export async function finishNativeSubagent(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: a message to the child, in the child's transcript (its
|
||||
* incoming view). Its id is the id of the tool use that sent it, the
|
||||
* Agent/Task call or a SendMessage, so a prompt whose id is already in the
|
||||
* transcript is the same delegation and is not sent again. `sender` is the
|
||||
* session that made that call, when known.
|
||||
*/
|
||||
async function publishPrompt(
|
||||
child: NativeSubagent,
|
||||
prompt: string,
|
||||
toolUseId: string | undefined,
|
||||
sender: string | undefined,
|
||||
publish: Publish,
|
||||
): Promise<void> {
|
||||
child.promptIds ??= new Set();
|
||||
const messageId = toolUseId ?? `${child.sessionId}:prompt:${child.promptIds.size + 1}`;
|
||||
if (child.promptIds.has(messageId)) return;
|
||||
child.promptIds.add(messageId);
|
||||
await publish({
|
||||
sessionId: child.sessionId,
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId,
|
||||
...(sender ? { senderSessionId: sender } : {}),
|
||||
recipientSessionId: child.sessionId,
|
||||
content: [{ type: "text", text: prompt }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** The RFD form: whether the child's foreground work is in progress. */
|
||||
function isWorking(child: NativeSubagent): boolean {
|
||||
return child.workState === "running" || child.workState === "requires_action";
|
||||
}
|
||||
|
||||
/**
|
||||
* The RFD form: the work state at the end of a child's work.
|
||||
*
|
||||
* A failure is idle with the `error` stop reason and, when the SDK gives its
|
||||
* text, the JSON-RPC `error`, as the RFD asks: a v1 child has no prompt
|
||||
* response, so this is how a client learns that its work failed. The error is
|
||||
* an internal error, as for a failed turn on the v2 surface.
|
||||
*
|
||||
* A task that a worker restart orphaned, or that was still running when the
|
||||
* stream ended, is idle with no stop reason: its work ended, but the adapter
|
||||
* did not see a cancellation or a failure, which the RFD forbids claiming.
|
||||
*/
|
||||
function rfdEndState(state: SubagentState, end: TaskEnd | undefined): WorkState {
|
||||
if (end?.unexplained || end?.reason === "worker_restart") return { state: "idle" };
|
||||
switch (state) {
|
||||
case "completed":
|
||||
return { state: "idle", stopReason: "end_turn" };
|
||||
case "cancelled":
|
||||
return { state: "idle", stopReason: "cancelled" };
|
||||
case "failed":
|
||||
return {
|
||||
state: "idle",
|
||||
stopReason: "error",
|
||||
...(end?.error
|
||||
? { error: { code: RequestError.internalError().code, message: end.error } }
|
||||
: {}),
|
||||
};
|
||||
default:
|
||||
// "disconnected": the child can no longer be observed.
|
||||
return { state: "unknown" };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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);
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
...(agentRole ? { role: agentRole } : {}),
|
||||
...(text ? { description: text } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The agent id that a successful SendMessage result resumed. The SDK puts it
|
||||
* in `tool_use_result.resumedAgentId` when a finished agent runs again.
|
||||
@@ -547,17 +1015,34 @@ export function sendMessageResumePrompt(
|
||||
agentId: string,
|
||||
resultToolUseIds: readonly string[] = [],
|
||||
): string | undefined {
|
||||
return sendMessageResume(toolUses, agentId, resultToolUseIds)?.text;
|
||||
}
|
||||
|
||||
/**
|
||||
* The SendMessage call that resumed the agent `agentId`, and its text when it
|
||||
* has one; see {@link sendMessageResumePrompt}. The call is known even
|
||||
* without text, so a late result still names the delegation it reports.
|
||||
*/
|
||||
export function sendMessageResume(
|
||||
toolUses: Record<string, { name: string; input: unknown } | undefined>,
|
||||
agentId: string,
|
||||
resultToolUseIds: readonly string[] = [],
|
||||
): { toolUseId: string; text?: string } | undefined {
|
||||
let withoutText: string | undefined;
|
||||
for (const toolUseId of resultToolUseIds) {
|
||||
const toolUse = toolUses[toolUseId];
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const text = promptText((toolUse.input as { message?: unknown } | null)?.message);
|
||||
if (text) return text;
|
||||
if (text) return { toolUseId, text };
|
||||
withoutText ??= toolUseId;
|
||||
}
|
||||
if (resultToolUseIds.length > 0) return undefined;
|
||||
for (const toolUse of Object.values(toolUses).reverse()) {
|
||||
if (resultToolUseIds.length > 0) return withoutText ? { toolUseId: withoutText } : undefined;
|
||||
for (const [toolUseId, toolUse] of Object.entries(toolUses).reverse()) {
|
||||
if (toolUse?.name !== "SendMessage") continue;
|
||||
const input = toolUse.input as { to?: unknown; message?: unknown } | null;
|
||||
if (input?.to === agentId) return promptText(input.message);
|
||||
if (input?.to !== agentId) continue;
|
||||
const text = promptText(input.message);
|
||||
return text ? { toolUseId, text } : { toolUseId };
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -746,6 +1231,14 @@ function applySubagentIdentity(
|
||||
child.task = subagentDescription(identity.prompt, identity.description);
|
||||
}
|
||||
child.prompt ??= identity.prompt;
|
||||
const labels = rfdLabels(
|
||||
identity.name,
|
||||
identity.subagentType ?? child.role,
|
||||
identity.description,
|
||||
);
|
||||
child.title = labels.title ?? child.title;
|
||||
child.role = labels.role ?? child.role;
|
||||
child.description = labels.description ?? child.description;
|
||||
}
|
||||
|
||||
/** The prompt text unchanged, or `undefined` when it is not a non-blank string. */
|
||||
|
||||
@@ -50,7 +50,6 @@ export class SettingsManager {
|
||||
private cwd: string;
|
||||
private effective: Settings = {};
|
||||
private managedDeniedModels: string[] = [];
|
||||
private managedApiKeyHelper?: string;
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -112,15 +111,10 @@ export class SettingsManager {
|
||||
this.managedDeniedModels = resolved.sources
|
||||
.filter((entry) => entry.source === "managed")
|
||||
.flatMap((entry) => entry.settings.deniedModels ?? []);
|
||||
this.managedApiKeyHelper =
|
||||
resolved.provenance.apiKeyHelper?.source === "managed"
|
||||
? resolved.effective.apiKeyHelper
|
||||
: undefined;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
this.managedDeniedModels = [];
|
||||
this.managedApiKeyHelper = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,16 +177,6 @@ export class SettingsManager {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `apiKeyHelper` the CLI runs: managed settings outrank the `--settings`
|
||||
* override, which outranks the user/project/local files.
|
||||
*/
|
||||
getApiKeyHelper(settingsOverride?: Settings): string | undefined {
|
||||
return (
|
||||
this.managedApiKeyHelper ?? settingsOverride?.apiKeyHelper ?? this.effective.apiKeyHelper
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* `deniedModels` entries from the managed tier, the only tier the CLI
|
||||
* honors them from.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,9 @@
|
||||
* - An `available_commands_update` also lists the `mcp` command of the
|
||||
* adapter (see {@link ADAPTER_COMMANDS}), when origin/main did not list
|
||||
* `mcp`. The adapter replaces the text of `/mcp` for every client.
|
||||
* - The `initialize` response does not advertise
|
||||
* `agentCapabilities.sessionCapabilities.subagents`: the subagents RFD has
|
||||
* only the client capability, and only AIR's draft keeps the agent one.
|
||||
* - A `terminal_exit` has a `null` exit code where origin/main sent a code
|
||||
* that the tool result does not say (see `CommandOutput.exitCode`): 1 for a
|
||||
* failure that names no code or an interrupted command, 0 for a
|
||||
@@ -198,13 +201,29 @@ function isMeta(key: string): boolean {
|
||||
return key.startsWith("_meta.");
|
||||
}
|
||||
|
||||
/** `record` without the agent subagent capability, when it is an `initialize` response. */
|
||||
function withoutAgentSubagentCapability(record: Recorded): Recorded {
|
||||
if (record.kind !== "initialize") return record;
|
||||
const payload = record.payload as Json;
|
||||
const agent = payload.agentCapabilities as Json | undefined;
|
||||
const session = agent?.sessionCapabilities as Json | undefined;
|
||||
if (!session || !("subagents" in session)) return record;
|
||||
return {
|
||||
...record,
|
||||
payload: {
|
||||
...payload,
|
||||
agentCapabilities: { ...agent, sessionCapabilities: without(session, "subagents") },
|
||||
},
|
||||
} as Recorded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the differences between the baseline and the current traffic that
|
||||
* the compatibility rule does not allow. An empty list means compatible.
|
||||
*/
|
||||
export function compareWithBaseline(baseline: Recorded[], current: Recorded[]): string[] {
|
||||
const expected = baseline
|
||||
.map((record) => withoutAirOnlyKeys(record) as Recorded)
|
||||
.map((record) => withoutAgentSubagentCapability(withoutAirOnlyKeys(record) as Recorded))
|
||||
.filter((record) => {
|
||||
const update = updateOf(record);
|
||||
return !(update?.sessionUpdate === "session_info_update" && Object.keys(update).length === 1);
|
||||
|
||||
@@ -6,9 +6,6 @@ import type { AuthStatus } from "../auth-status.js";
|
||||
import { fromAccountInfo, fromCliStatus, mergeAuthStatus, sameIdentity } from "../auth-status.js";
|
||||
import { DEFAULT_CONTEXT_USAGE, makeMockQuery } from "./helpers.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as path from "node:path";
|
||||
import * as os from "node:os";
|
||||
|
||||
const mockQuery = vi.hoisted(() => vi.fn());
|
||||
|
||||
@@ -145,52 +142,6 @@ function scriptedTurnQuery(args: any, account: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
describe("auth status mappers", () => {
|
||||
it.each([
|
||||
"central proxy start",
|
||||
"/opt/tools/central proxy start --arbitrary-flag",
|
||||
"CeNtRaL ProXy StArT --return-key",
|
||||
"central.exe proxy\tstart",
|
||||
])("names the active Central helper %j without exposing its command", (helper) => {
|
||||
const expected = { kind: "gateway", label: "JetBrains Air Gateway" };
|
||||
expect(fromCliStatus(CLI_KEY_HELPER_AND_SUBSCRIPTION, helper)).toEqual(expected);
|
||||
expect(
|
||||
fromAccountInfo(
|
||||
{ apiProvider: "firstParty", apiKeySource: "apiKeyHelper", subscriptionType: "max" },
|
||||
helper,
|
||||
),
|
||||
).toEqual(expected);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"decentralized proxy start",
|
||||
"centralized proxy start",
|
||||
"central proxy stop",
|
||||
"another proxy start",
|
||||
"central proxy starter",
|
||||
])("keeps a non-Central helper %j human readable", (helper) => {
|
||||
expect(fromCliStatus(CLI_API_KEY, helper)).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "API key helper",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not use an inactive helper or override an external backend", () => {
|
||||
const helper = "central proxy start";
|
||||
expect(fromCliStatus(CLI_BEDROCK, helper)).toEqual({ kind: "external", label: "AWS Bedrock" });
|
||||
expect(fromCliStatus(CLI_LOGGED_OUT, helper)).toEqual({ kind: "none", label: "Not logged in" });
|
||||
expect(fromAccountInfo({ apiProvider: "firstParty", apiKeySource: "env" }, helper)).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "env",
|
||||
});
|
||||
expect(fromAccountInfo({ apiKeySource: "futureInternalIdentifier" }, helper)).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
});
|
||||
});
|
||||
it("maps a first-party subscription account", () => {
|
||||
expect(
|
||||
fromAccountInfo({
|
||||
@@ -237,7 +188,7 @@ describe("auth status mappers", () => {
|
||||
expect(fromCliStatus(CLI_KEY_HELPER_AND_SUBSCRIPTION)).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "API key helper",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
expect(
|
||||
fromAccountInfo({
|
||||
@@ -250,7 +201,7 @@ describe("auth status mappers", () => {
|
||||
).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "API key helper",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -293,7 +244,7 @@ describe("auth status mappers", () => {
|
||||
expect(fromCliStatus(CLI_API_KEY)).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "API key helper",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
expect(fromCliStatus(CLI_LOGGED_OUT)).toEqual({
|
||||
kind: "none",
|
||||
@@ -347,7 +298,7 @@ describe("auth status mappers", () => {
|
||||
// Key sources identify api_key payloads.
|
||||
expect(
|
||||
sameIdentity(
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "API key helper" },
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "apiKeyHelper" },
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "env" },
|
||||
),
|
||||
).toBe(false);
|
||||
@@ -366,8 +317,6 @@ describe("auth status over ACP", () => {
|
||||
* Each probe captures the output configured when it was spawned. */
|
||||
let deferStatus: boolean;
|
||||
let pendingStatus: Array<() => void>;
|
||||
let configDir: string;
|
||||
let originalConfigDir: string | undefined;
|
||||
|
||||
/** Let every already-scheduled promise chain run to completion. */
|
||||
function settle() {
|
||||
@@ -394,10 +343,7 @@ describe("auth status over ACP", () => {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
configDir = await fs.mkdtemp(path.join(os.tmpdir(), "auth-status-test-"));
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
beforeEach(() => {
|
||||
// Skip native-binary resolution; the exec itself is mocked.
|
||||
process.env.CLAUDE_CODE_EXECUTABLE = "claude";
|
||||
statusStdout = CLI_SUBSCRIPTION;
|
||||
@@ -436,16 +382,7 @@ describe("auth status over ACP", () => {
|
||||
} as unknown as AcpClient);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
for (const sessionId of Object.keys(agent.sessions)) {
|
||||
await agent.closeSession({ sessionId });
|
||||
}
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
}
|
||||
await fs.rm(configDir, { recursive: true, force: true });
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_CODE_EXECUTABLE;
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -488,94 +425,6 @@ describe("auth status over ACP", () => {
|
||||
return response;
|
||||
}
|
||||
|
||||
it("pushes Central from the effective pre-session settings and suppresses duplicates", async () => {
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
apiKeyHelper: "central proxy start --return-key",
|
||||
env: { ANTHROPIC_BASE_URL: "https://example.invalid/private-route" },
|
||||
}),
|
||||
);
|
||||
statusStdout = CLI_API_KEY;
|
||||
await initialize();
|
||||
await vi.waitFor(() =>
|
||||
expect(lastPush()).toEqual({ kind: "gateway", label: "JetBrains Air Gateway" }),
|
||||
);
|
||||
const sessionId = await scriptedSession({
|
||||
apiProvider: "firstParty",
|
||||
apiKeySource: "apiKeyHelper",
|
||||
});
|
||||
await promptOnce(sessionId);
|
||||
expect(updates()).toHaveLength(1);
|
||||
expect(JSON.stringify(updates())).not.toContain("private-route");
|
||||
expect(JSON.stringify(updates())).not.toContain("return-key");
|
||||
});
|
||||
|
||||
it("discards a late probe when programmatic settings replace the active helper", async () => {
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "settings.json"),
|
||||
JSON.stringify({ apiKeyHelper: "ordinary-helper" }),
|
||||
);
|
||||
statusStdout = CLI_API_KEY;
|
||||
deferStatus = true;
|
||||
await initialize();
|
||||
mockQuery.mockImplementation((args: any) =>
|
||||
scriptedTurnQuery(args, { apiKeySource: "apiKeyHelper" }),
|
||||
);
|
||||
const { sessionId } = await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: { apiKeyHelper: "central proxy start" },
|
||||
env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await flushStatus();
|
||||
expect(lastPush()).toEqual({ kind: "gateway", label: "JetBrains Air Gateway" });
|
||||
expect(updates()).toHaveLength(1);
|
||||
expect(statusCalls().at(-1)?.[2]).toMatchObject({
|
||||
cwd: process.cwd(),
|
||||
env: { ANTHROPIC_BASE_URL: "https://example.invalid/override" },
|
||||
});
|
||||
expect(statusCalls().at(-1)?.[1]).toEqual([
|
||||
"auth",
|
||||
"status",
|
||||
"--json",
|
||||
"--settings",
|
||||
JSON.stringify({ apiKeyHelper: "central proxy start" }),
|
||||
]);
|
||||
await promptOnce(sessionId);
|
||||
await flushStatus();
|
||||
expect(updates()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("re-probes when a session switches to a Central helper after the initialize probe settled", async () => {
|
||||
await fs.writeFile(
|
||||
path.join(configDir, "settings.json"),
|
||||
JSON.stringify({ apiKeyHelper: "ordinary-helper" }),
|
||||
);
|
||||
statusStdout = CLI_API_KEY;
|
||||
await initialize();
|
||||
expect(lastPush()?.kind).toBe("api_key");
|
||||
const probesBefore = statusCalls().length;
|
||||
mockQuery.mockImplementation((args: any) =>
|
||||
scriptedTurnQuery(args, { apiProvider: "firstParty", apiKeySource: "apiKeyHelper" }),
|
||||
);
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: { claudeCode: { options: { settings: { apiKeyHelper: "central proxy start" } } } },
|
||||
});
|
||||
await settle();
|
||||
// No prompt yet: the session switch itself must reclassify the helper.
|
||||
expect(statusCalls()).toHaveLength(probesBefore + 1);
|
||||
expect(lastPush()).toEqual({ kind: "gateway", label: "JetBrains Air Gateway" });
|
||||
});
|
||||
|
||||
it("pushes the logged-out CLI verdict even though the probe exits non-zero", async () => {
|
||||
statusStdout = CLI_LOGGED_OUT;
|
||||
statusFails = true;
|
||||
@@ -731,7 +580,7 @@ describe("auth status over ACP", () => {
|
||||
authStatus: {
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "API key helper",
|
||||
detail: "apiKeyHelper",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -766,7 +615,7 @@ describe("auth status over ACP", () => {
|
||||
expect(agent.currentAuthStatus).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "API key helper",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -798,7 +647,7 @@ describe("auth status over ACP", () => {
|
||||
expect(agent.currentAuthStatus).toEqual({
|
||||
kind: "api_key",
|
||||
label: "Anthropic API key",
|
||||
detail: "API key helper",
|
||||
detail: "apiKeyHelper",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
|
||||
import type { AccountInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
|
||||
@@ -48,13 +45,7 @@ vi.mock("node:child_process", async () => {
|
||||
});
|
||||
|
||||
describe("authorization", () => {
|
||||
let configDir: string;
|
||||
let originalConfigDir: string | undefined;
|
||||
|
||||
beforeEach(async () => {
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
configDir = await mkdtemp(path.join(os.tmpdir(), "authorization-test-"));
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
// Skip native-binary resolution; the exec itself is stubbed.
|
||||
process.env.CLAUDE_CODE_EXECUTABLE = "claude";
|
||||
@@ -83,7 +74,7 @@ describe("authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
afterEach(() => {
|
||||
//await all pending events like
|
||||
vi.runAllTimers();
|
||||
vi.useRealTimers();
|
||||
@@ -91,9 +82,6 @@ describe("authorization", () => {
|
||||
delete process.env.CLAUDE_CODE_EXECUTABLE;
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetAllMocks();
|
||||
if (originalConfigDir === undefined) delete realProcess.env.CLAUDE_CONFIG_DIR;
|
||||
else realProcess.env.CLAUDE_CONFIG_DIR = originalConfigDir;
|
||||
await rm(configDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** Third element: the `extNotification` spy, so a test can read the
|
||||
@@ -1136,7 +1124,7 @@ describe("authorization", () => {
|
||||
// account by itself — no CLI probe is needed for the post-login state.
|
||||
expect(authStatusUpdates(extNotification)).toEqual([
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "ANTHROPIC_API_KEY" },
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "API key helper" },
|
||||
{ kind: "api_key", label: "Anthropic API key", detail: "apiKeyHelper" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NativeSubagentRuntime,
|
||||
NativeSubagentSession,
|
||||
resumedNativeSubagentId,
|
||||
sendMessageResume,
|
||||
sendMessageResumePrompt,
|
||||
} from "../native-subagents.js";
|
||||
|
||||
@@ -68,8 +69,8 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
await release.promise;
|
||||
});
|
||||
|
||||
const first = announceNativeSubagent(value, publish);
|
||||
const second = announceNativeSubagent(value, publish);
|
||||
const first = announceNativeSubagent(value, publish, "air");
|
||||
const second = announceNativeSubagent(value, publish, "air");
|
||||
|
||||
await Promise.resolve();
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
@@ -101,7 +102,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("falls back to one ordinary failed tool call when no child ever starts", async () => {
|
||||
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
|
||||
const runtime = new NativeSubagentRuntime("air", "root", {}, async () => {}, { log: () => {} });
|
||||
|
||||
await expect(
|
||||
runtime.route(control("tool_call", "pending"), async () => {}),
|
||||
@@ -124,7 +125,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
});
|
||||
|
||||
it("creates a schema-valid non-native failed tool call without a cached initial call", async () => {
|
||||
const runtime = new NativeSubagentRuntime(true, "root", {}, async () => {}, { log: () => {} });
|
||||
const runtime = new NativeSubagentRuntime("air", "root", {}, async () => {}, { log: () => {} });
|
||||
|
||||
const fallback = await runtime.route(
|
||||
{
|
||||
@@ -166,7 +167,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
const session = sessionWith(outer);
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
session,
|
||||
async (notification) => {
|
||||
@@ -211,7 +212,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const delivered: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
@@ -258,7 +259,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
it("creates a distinct ACP lifecycle when the SDK restarts the same task id", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
@@ -332,7 +333,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
it("announces resumed subagent generation immediately when SendMessage restarts the task (#1158)", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
@@ -393,7 +394,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
it("announces a resumed nested subagent under a live ancestor when its parent has finished", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
@@ -439,7 +440,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
): Promise<Record<string, unknown>> {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
@@ -507,7 +508,7 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const logged: string[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
@@ -622,10 +623,22 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
expect(sendMessageResumePrompt(toolUses, "worker-3")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("names the SendMessage call that resumed the agent even without a text", () => {
|
||||
const toolUses = {
|
||||
blank: { name: "SendMessage", input: { to: "worker-1", message: " " } },
|
||||
bash: { name: "Bash", input: { command: "ls" } },
|
||||
};
|
||||
|
||||
expect(sendMessageResume(toolUses, "worker-1", ["blank"])).toEqual({ toolUseId: "blank" });
|
||||
expect(sendMessageResume(toolUses, "worker-1")).toEqual({ toolUseId: "blank" });
|
||||
expect(sendMessageResume(toolUses, "worker-1", ["bash"])).toBeUndefined();
|
||||
expect(sendMessageResumePrompt(toolUses, "worker-1", ["blank"])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the work of a child tool call in the generation that started it", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
true,
|
||||
"air",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
@@ -729,3 +742,514 @@ describe("NativeSubagentRuntime lifecycle", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The subagents RFD (agentclientprotocol/agent-client-protocol#1992, as
|
||||
* revised), for a client that declares `subagents` and is not AIR.
|
||||
*/
|
||||
describe("NativeSubagentRuntime in the RFD form", () => {
|
||||
const launch = (
|
||||
toolCallId: string,
|
||||
input: Record<string, unknown>,
|
||||
parentToolUseId?: string,
|
||||
): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: "Agent",
|
||||
status: "pending",
|
||||
rawInput: input,
|
||||
_meta: {
|
||||
claudeCode: { toolName: "Agent", ...(parentToolUseId ? { parentToolUseId } : {}) },
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
const output = (parentToolUseId: string): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "working" },
|
||||
_meta: { claudeCode: { parentToolUseId } },
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
/** A SendMessage call, of the root or of the child that `parentToolUseId` launched. */
|
||||
const sendMessage = (toolCallId: string, parentToolUseId?: string): AcpSessionNotification =>
|
||||
({
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId,
|
||||
title: "SendMessage",
|
||||
status: "pending",
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: "SendMessage",
|
||||
...(parentToolUseId ? { parentToolUseId } : {}),
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as AcpSessionNotification;
|
||||
|
||||
/** A runtime whose child `worker-1` was launched by `launch-1` and runs. */
|
||||
async function runningWorker(input: Record<string, unknown> = {}) {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const control = await runtime.route(
|
||||
launch("launch-1", { description: "Fix the build", prompt: "Make it pass", ...input }),
|
||||
async () => {},
|
||||
);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
return { runtime, published, control };
|
||||
}
|
||||
const states = (published: AcpSessionNotification[]) =>
|
||||
published.flatMap(({ update }) =>
|
||||
update.sessionUpdate === "subagent_update" && update.state ? [update.state] : [],
|
||||
);
|
||||
|
||||
it("keeps the Agent tool call as a tool call of the session that made it", async () => {
|
||||
const { runtime, control } = await runningWorker();
|
||||
|
||||
expect(control).toMatchObject({ sessionId: "root", update: { toolCallId: "launch-1" } });
|
||||
// A nested launch is a tool call of the child that made it.
|
||||
await expect(
|
||||
runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}),
|
||||
).resolves.toMatchObject({ sessionId: "worker-1", update: { toolCallId: "launch-2" } });
|
||||
});
|
||||
|
||||
it("announces the child with the parent's labels, then sends it the prompt", async () => {
|
||||
const { published } = await runningWorker({ name: "builder" });
|
||||
|
||||
expect(published).toEqual([
|
||||
{
|
||||
sessionId: "root",
|
||||
update: {
|
||||
sessionUpdate: "subagent_update",
|
||||
sessionId: "worker-1",
|
||||
title: "builder (Explore)",
|
||||
description: "Fix the build",
|
||||
state: { state: "running" },
|
||||
},
|
||||
},
|
||||
{
|
||||
sessionId: "worker-1",
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "launch-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "worker-1",
|
||||
content: [{ type: "text", text: "Make it pass" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("titles a child without a name by its role", async () => {
|
||||
const { published } = await runningWorker();
|
||||
|
||||
expect(published[0]?.update).toMatchObject({ title: "Explore", description: "Fix the build" });
|
||||
});
|
||||
|
||||
it("keeps the session of an idle child, which a resume runs again", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {});
|
||||
|
||||
// An idle child is reusable, so its updates still go to it.
|
||||
await expect(runtime.route(output("launch-1"), async () => {})).resolves.toMatchObject({
|
||||
sessionId: "worker-1",
|
||||
});
|
||||
await runtime.route(sendMessage("send-1"), async () => {});
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
// A repeated resume signal of the running child changes nothing.
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
await runtime.finishTask("worker-1", "completed", async () => {});
|
||||
|
||||
expect(states(published)).toEqual([
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
]);
|
||||
expect(published.filter(({ update }) => update.sessionUpdate === "session_message")).toEqual([
|
||||
expect.objectContaining({ sessionId: "worker-1" }),
|
||||
{
|
||||
sessionId: "worker-1",
|
||||
update: {
|
||||
sessionUpdate: "session_message",
|
||||
messageId: "send-1",
|
||||
senderSessionId: "root",
|
||||
recipientSessionId: "worker-1",
|
||||
content: [{ type: "text", text: "Check the tests too" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
published.some(({ update }) => "sessionId" in update && update.sessionId !== "worker-1"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("moves the work of a resume to the child, under the id of the call that resumed it", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
await runtime.route(sendMessage("send-1"), async () => {});
|
||||
// The resumed run's output can arrive before the SDK's task_started.
|
||||
await expect(runtime.route(output("send-1"), async () => {})).resolves.toBeNull();
|
||||
|
||||
const delivered: AcpSessionNotification[] = [];
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "send-1", prompt: "Check the tests too" },
|
||||
async (notification) => {
|
||||
delivered.push(notification);
|
||||
},
|
||||
);
|
||||
// The resume signal of the SendMessage result repeats it, and changes nothing.
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
|
||||
// The waiting output is delivered for routing again, which now finds the child.
|
||||
expect(delivered).toEqual([output("send-1")]);
|
||||
await expect(runtime.route(output("send-1"), async () => {})).resolves.toMatchObject({
|
||||
sessionId: "worker-1",
|
||||
});
|
||||
// The end of the first delegation, again, does not end the second.
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
expect(states(published).at(-1)).toEqual({ state: "running" });
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
|
||||
expect(states(published)).toEqual([
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
]);
|
||||
expect(
|
||||
published
|
||||
.filter(({ update }) => update.sessionUpdate === "session_message")
|
||||
.map(({ update }) => (update as { messageId: string }).messageId),
|
||||
).toEqual(["launch-1", "send-1"]);
|
||||
});
|
||||
|
||||
it("ignores a SendMessage result that arrives after the run it started ended", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "send-1", prompt: "Check the tests too" },
|
||||
async () => {},
|
||||
);
|
||||
// The terminal patch carries no tool id; the notification repeats it.
|
||||
await runtime.finishTask("worker-1", "failed", async () => {});
|
||||
await runtime.finishTask("worker-1", "failed", async () => {}, "send-1");
|
||||
|
||||
expect(runtime.isEndedDelegation("worker-1", "send-1")).toBe(true);
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, "Check the tests too", "send-1");
|
||||
expect(states(published)).toEqual([
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
{ state: "running" },
|
||||
{ state: "idle", stopReason: "error" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ends a run under an id the child used before, which the SDK falls back to", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
|
||||
// A resume that no call made runs under the launch's id again.
|
||||
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "launch-1" }, async () => {});
|
||||
|
||||
expect(runtime.isStaleEnd("worker-1", "send-1")).toBe(true);
|
||||
expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false);
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
|
||||
expect(states(published)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it("reports a running patch as running, without its guessed prompt or call", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
await runtime.taskResumed("worker-1", async () => {}, "An old message");
|
||||
|
||||
expect(states(published).at(-1)).toEqual({ state: "running" });
|
||||
expect(
|
||||
published.filter(({ update }) => update.sessionUpdate === "session_message"),
|
||||
).toHaveLength(1);
|
||||
expect(runtime.isStaleEnd("worker-1", "launch-1")).toBe(false);
|
||||
});
|
||||
|
||||
it("resumes a child with an open request as requires_action", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
let answer!: () => void;
|
||||
const request = runtime.awaitingUser(
|
||||
"worker-1",
|
||||
() => new Promise<void>((resolve) => (answer = resolve)),
|
||||
);
|
||||
await Promise.resolve();
|
||||
// The work ends while the request is open; then a resume runs it again.
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
|
||||
answer();
|
||||
await request;
|
||||
|
||||
expect(states(published)).toEqual([
|
||||
{ state: "running" },
|
||||
{ state: "requires_action" },
|
||||
{ state: "idle", stopReason: "end_turn" },
|
||||
{ state: "requires_action" },
|
||||
{ state: "running" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the session that sent a resume's prompt, and no sender when it is unknown", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
// worker-1 launches worker-2, which the root then resumes.
|
||||
await runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {});
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
await runtime.finishTask("worker-2", "completed", async () => {}, "launch-2");
|
||||
await runtime.route(sendMessage("send-root"), async () => {});
|
||||
await runtime.sendMessageResumed("worker-2", async () => {}, "From the root", "send-root");
|
||||
await runtime.finishTask("worker-2", "completed", async () => {}, "send-root");
|
||||
// worker-1 resumes it too.
|
||||
await runtime.route(sendMessage("send-worker", "launch-1"), async () => {});
|
||||
await runtime.sendMessageResumed("worker-2", async () => {}, "From worker-1", "send-worker");
|
||||
await runtime.finishTask("worker-2", "completed", async () => {}, "send-worker");
|
||||
// A call the runtime never routed has no known sender.
|
||||
await runtime.sendMessageResumed("worker-2", async () => {}, "From someone", "send-unknown");
|
||||
|
||||
const senders = published.flatMap(({ sessionId, update }) =>
|
||||
sessionId === "worker-2" && update.sessionUpdate === "session_message"
|
||||
? [[update.messageId, update.senderSessionId]]
|
||||
: [],
|
||||
);
|
||||
expect(senders).toEqual([
|
||||
["send-root", "root"],
|
||||
["send-worker", "worker-1"],
|
||||
["send-unknown", undefined],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a nested child that ends before its parent is known unexposed", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
// worker-2's spawning frame has not arrived, so its parent is unknown.
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
await expect(runtime.route(output("launch-2"), async () => {})).resolves.toBeNull();
|
||||
// A resume signal does not give the unexposed child another call.
|
||||
await runtime.taskStarted({ taskId: "worker-2", toolUseId: "send-9" }, async () => {});
|
||||
await runtime.finishTask("worker-2", "completed", async () => {});
|
||||
// The frame arrives late: it is the tool call of worker-1 that made it.
|
||||
await expect(
|
||||
runtime.route(launch("launch-2", { description: "Dig" }, "launch-1"), async () => {}),
|
||||
).resolves.toMatchObject({ sessionId: "worker-1" });
|
||||
|
||||
expect(
|
||||
published.some(
|
||||
({ update }) =>
|
||||
(update.sessionUpdate === "subagent_update" && update.sessionId === "worker-2") ||
|
||||
(update.sessionUpdate === "session_message" && update.recipientSessionId === "worker-2"),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(published.some(({ sessionId }) => sessionId === "worker-2")).toBe(false);
|
||||
});
|
||||
|
||||
describe("a nested child whose own parent is not announced yet", () => {
|
||||
/** A runtime, the updates it sends in order, and the deliver function of
|
||||
* the agent, which routes a buffered update again (as `sendUpdate` does). */
|
||||
function setup() {
|
||||
const sent: AcpSessionNotification[] = [];
|
||||
const runtime: NativeSubagentRuntime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
sent.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
const deliver = async (notification: AcpSessionNotification): Promise<void> => {
|
||||
const routed = await runtime.route(notification, deliver);
|
||||
if (routed) sent.push(routed);
|
||||
};
|
||||
return { runtime, sent, deliver };
|
||||
}
|
||||
/** The announcements, as [parent, child], and the sessions that got
|
||||
* traffic before each was announced. */
|
||||
function check(sent: AcpSessionNotification[]) {
|
||||
const announced = new Set(["root"]);
|
||||
const early: string[] = [];
|
||||
const announcements: [string, string][] = [];
|
||||
for (const { sessionId, update } of sent) {
|
||||
if (!announced.has(sessionId)) early.push(sessionId);
|
||||
if (update.sessionUpdate === "subagent_update" && !announced.has(update.sessionId)) {
|
||||
announced.add(update.sessionId);
|
||||
announcements.push([sessionId, update.sessionId]);
|
||||
}
|
||||
}
|
||||
return { announcements, early };
|
||||
}
|
||||
|
||||
it("announces it after that parent, when the parent's task started before its launch frame", async () => {
|
||||
const { runtime, sent, deliver } = setup();
|
||||
// worker-1 is known, but its launch frame has not come: not announced.
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await deliver(launch("launch-2", { description: "Dig" }, "launch-1"));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await deliver(launch("launch-1", { description: "Fix the build" }));
|
||||
|
||||
expect(check(sent)).toEqual({
|
||||
announcements: [
|
||||
["root", "worker-1"],
|
||||
["worker-1", "worker-2"],
|
||||
],
|
||||
early: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("never announces it under the root, when its parent is not known yet", async () => {
|
||||
const { runtime, sent, deliver } = setup();
|
||||
await deliver(launch("launch-2", { description: "Dig" }, "launch-1"));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-2", toolUseId: "launch-2", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
await deliver(launch("launch-1", { description: "Fix the build" }));
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
deliver,
|
||||
);
|
||||
|
||||
expect(check(sent)).toEqual({
|
||||
announcements: [
|
||||
["root", "worker-1"],
|
||||
["worker-1", "worker-2"],
|
||||
],
|
||||
early: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the role in the title when a late frame names the child", async () => {
|
||||
const published: AcpSessionNotification[] = [];
|
||||
const runtime = new NativeSubagentRuntime(
|
||||
"rfd",
|
||||
"root",
|
||||
{},
|
||||
async (notification) => {
|
||||
published.push(notification);
|
||||
},
|
||||
{ log: () => {} },
|
||||
);
|
||||
await runtime.taskStarted(
|
||||
{ taskId: "worker-1", toolUseId: "launch-1", subagentType: "Explore" },
|
||||
async () => {},
|
||||
);
|
||||
await runtime.route(launch("launch-1", { name: "builder" }), async () => {});
|
||||
|
||||
expect(published[0]?.update).toMatchObject({ title: "builder (Explore)" });
|
||||
});
|
||||
|
||||
it("reports nothing when the parent is cancelled, until the SDK ends the child", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
|
||||
await runtime.parentCancelled(async () => {});
|
||||
expect(states(published)).toEqual([{ state: "running" }]);
|
||||
await runtime.finishTask("worker-1", "killed", async () => {});
|
||||
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "cancelled" });
|
||||
});
|
||||
|
||||
it("ends the children that still run with the stream as idle, with no reason it did not see", async () => {
|
||||
// After a cancel, a child that still runs is a background child that
|
||||
// outlived it: the stream's end is no evidence that it was cancelled.
|
||||
const cancelled = await runningWorker();
|
||||
await cancelled.runtime.finishAll("cancelled", async () => {});
|
||||
expect(states(cancelled.published).at(-1)).toEqual({ state: "idle" });
|
||||
|
||||
const failed = await runningWorker();
|
||||
await failed.runtime.finishAll("failed", async () => {});
|
||||
expect(states(failed.published).at(-1)).toEqual({ state: "idle" });
|
||||
// A later error event does not turn that end into a failure.
|
||||
await failed.runtime.finishTask("worker-1", "failed", async () => {}, undefined, {
|
||||
error: "boom",
|
||||
});
|
||||
expect(states(failed.published)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps a SendMessage result without a text from resuming a run that ended", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "launch-1");
|
||||
await runtime.taskStarted({ taskId: "worker-1", toolUseId: "send-1" }, async () => {});
|
||||
await runtime.finishTask("worker-1", "completed", async () => {}, "send-1");
|
||||
|
||||
expect(runtime.isEndedDelegation("worker-1", "send-1")).toBe(true);
|
||||
await runtime.sendMessageResumed("worker-1", async () => {}, undefined, "send-1");
|
||||
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
|
||||
});
|
||||
|
||||
it("reports a child that can no longer be observed as unknown", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
|
||||
await runtime.finishTask("worker-1", "disconnected", async () => {});
|
||||
expect(states(published).at(-1)).toEqual({ state: "unknown" });
|
||||
});
|
||||
|
||||
it("reports a child as requires_action while its requests are open", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
const first = Promise.withResolvers<string>();
|
||||
const second = Promise.withResolvers<string>();
|
||||
|
||||
const answers = Promise.all([
|
||||
runtime.awaitingUser("worker-1", () => first.promise),
|
||||
runtime.awaitingUser("worker-1", () => second.promise),
|
||||
]);
|
||||
await vi.waitFor(() => expect(states(published)).toHaveLength(2));
|
||||
first.resolve("allow");
|
||||
await first.promise;
|
||||
expect(states(published).at(-1)).toEqual({ state: "requires_action" });
|
||||
second.resolve("deny");
|
||||
await expect(answers).resolves.toEqual(["allow", "deny"]);
|
||||
|
||||
expect(states(published)).toEqual([
|
||||
{ state: "running" },
|
||||
{ state: "requires_action" },
|
||||
{ state: "running" },
|
||||
]);
|
||||
// The root session's own requests change no child.
|
||||
await runtime.awaitingUser("root", async () => "allow");
|
||||
expect(states(published)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("does not report running again for a child that ended while it waited", async () => {
|
||||
const { runtime, published } = await runningWorker();
|
||||
const answer = Promise.withResolvers<string>();
|
||||
|
||||
const request = runtime.awaitingUser("worker-1", () => answer.promise);
|
||||
await vi.waitFor(() => expect(states(published)).toHaveLength(2));
|
||||
await runtime.finishTask("worker-1", "completed", async () => {});
|
||||
answer.resolve("allow");
|
||||
await request;
|
||||
|
||||
expect(states(published).at(-1)).toEqual({ state: "idle", stopReason: "end_turn" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -64,12 +64,7 @@ export function mockSessionState(
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: {
|
||||
dispose: vi.fn(),
|
||||
getSettings: () => ({}),
|
||||
getApiKeyHelper: () => undefined,
|
||||
getCwd: () => process.cwd(),
|
||||
},
|
||||
settingsManager: { dispose: vi.fn(), getSettings: () => ({}) },
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolved = vi.hoisted(() => ({ value: undefined as unknown }));
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>()),
|
||||
resolveSettings: async () => resolved.value,
|
||||
}));
|
||||
|
||||
const { SettingsManager } = await import("../settings.js");
|
||||
|
||||
async function managerResolving(value: unknown) {
|
||||
resolved.value = value;
|
||||
const manager = new SettingsManager(process.cwd());
|
||||
await manager.initialize();
|
||||
return manager;
|
||||
}
|
||||
|
||||
describe("SettingsManager.getApiKeyHelper", () => {
|
||||
it("prefers a managed helper over the --settings override", async () => {
|
||||
const manager = await managerResolving({
|
||||
effective: { apiKeyHelper: "managed-helper" },
|
||||
provenance: { apiKeyHelper: { source: "managed" } },
|
||||
sources: [],
|
||||
});
|
||||
expect(manager.getApiKeyHelper({ apiKeyHelper: "override-helper" })).toBe("managed-helper");
|
||||
manager.dispose();
|
||||
});
|
||||
|
||||
it("prefers the --settings override over a user helper", async () => {
|
||||
const manager = await managerResolving({
|
||||
effective: { apiKeyHelper: "user-helper" },
|
||||
provenance: { apiKeyHelper: { source: "userSettings" } },
|
||||
sources: [],
|
||||
});
|
||||
expect(manager.getApiKeyHelper({ apiKeyHelper: "override-helper" })).toBe("override-helper");
|
||||
expect(manager.getApiKeyHelper()).toBe("user-helper");
|
||||
manager.dispose();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user