Compare commits

..

12 Commits

Author SHA1 Message Date
Ben Brandt
7184fd3cfe fix: keep a long Agent call's heartbeat on the session that made the call
A heartbeat of a running Agent call reports under a synthetic id, so the
adapter falls back to parent_tool_use_id, which is the Agent call itself, and
also stamped that id as the beat's parentToolUseId. For a client on the
subagents RFD, which sees the Agent call, the beat and every later update of
the call then went to the child session. Found in a live run with a 40-second
subagent.

The same run showed that Claude Code's interrupt currently stops background
subagents too, so the README and comments no longer say they keep running.
2026-10-13 14:59:44 +02:00
Ben Brandt
9382e10f2c fix: keep the cancel's tool-call cleanup for AIR, whose draft ends every child at the cancel
The previous commit stopped a cancel from clearing each tool call's state, so
that a raced tool call of an RFD client's background child stays on the root.
For AIR that let a later update of an ended child's permission tool call reach
the child's session after subagent_state_update cancelled. Only the RFD form
keeps the state now.
2026-10-13 09:52:54 +02:00
Ben Brandt
4b67682ff9 fix: never announce a nested subagent early or under the root, and keep a raced tool call where it was emitted after a cancel
Two MUSTs of the subagents RFD that rare event orders broke:

- A child is announced before any traffic that names it, and never under a
  guessed parent. An Agent call made inside a child that was known but not
  yet announced registered that child as the parent, so the nested child
  was announced under it before the child itself was; and one made inside a
  child not known yet fell back to the root. In the RFD form, such a call
  now establishes no parent: its update is buffered under the child's tool
  use, as before, and routed again once the child is announced, which
  registers the parent and announces the nested child after it.
- An interaction already issued never moves to another session. A cancel
  cleared the session of every eagerly emitted tool call, a relic of when a
  cancel ended every subagent. A background child keeps running after a
  cancel (agent-client-protocol#2308), so a tool call of its that a
  permission request emitted on the root before the child was announced
  then moved into the child, for example with its next progress beat. A
  cancel now keeps the per-tool-call state (the session, the fields the
  client holds, the hook callbacks); each tool call drops it when it ends,
  and the cancelled turn's own tool calls end through their results or
  closeUnfinished.
2026-10-13 08:28:34 +02:00
Ben Brandt
529718af76 fix: report a failed subagent with the error stop reason
The subagents RFD reports a failure that ends a child's foreground work as
`idle` with `stopReason: "error"` and, when available, the failure as a
JSON-RPC `error` (agent-client-protocol#2281). SDK 1.8.0's v1
`IdleStateUpdate` has that variant, so the stand-in
`_meta.claudeCode.error` goes: a failed child is now idle with the `error`
stop reason, and with the SDK's error text as an internal error, the code a
failed turn uses on the v2 surface.
2026-10-13 08:11:40 +02:00
Ben Brandt
69cd49afeb Merge remote-tracking branch 'origin/main' into feat/subagent-update
# Conflicts:
#	src/acp-agent.ts
2026-10-13 08:02:17 +02:00
Ben Brandt
764c5abdf1 fix: report no cancellation for a subagent that only outlived a cancel
From a third review of the RFD form, against the subagents RFD as
revised by agentclientprotocol/agent-client-protocol#2308 (a cancel stops
the descendant work the cancelled work waits on; the rest is up to the
Agent; the Client cleans up a descendant's requests once it reports a
cancelled idle):

- At the end of the stream, or a conversation reset, a child that still
  runs is idle with no stop reason. After a cancel it is a background
  child that outlived it, so reporting `cancelled` invented a
  cancellation, which under #2308 also tells the client to clean up its
  requests. Foreground children report their cancellation from the SDK's
  task events, before the cancelled prompt resolves.
- A SendMessage result whose call has no message text still names the
  call, so a late result does not resume a run that already ended.
- An agent-level test pins #2308's validation scenario: at a root
  cancel, the foreground child's request is withdrawn and it reports a
  cancelled idle before the prompt response; the background child keeps
  running with its request open, and ends idle with no reason.
- README says what `session/cancel` does to children.

AIR's path and golden files are unchanged.
2026-10-05 23:06:55 +02:00
Ben Brandt
5588538fe2 docs: note why a subagent's agent id is unique enough as its session id
The RFD needs a child session id 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, which the SDK's
forkSession cannot do today because it copies no subagents/ history.
2026-10-05 19:29:55 +02:00
Ben Brandt
fd41b69e05 Merge remote-tracking branch 'origin/main' into feat/subagent-update
# Conflicts:
#	src/native-subagents.ts
2026-10-05 17:02:56 +02:00
Ben Brandt
bce759a258 fix: order a resumed subagent's delegations by the call that made each one
Fixes from a second review of the RFD form, for resume and end events
that arrive out of their usual order:

- Each delegation is the call the SDK runs it under. A run's end records
  its call; a SendMessage result for a call whose run already ended
  resumes nothing (`isEndedDelegation`), so a late result no longer sets
  an idle child running for good or re-arms the turn's hold.
- The end of an earlier delegation ends nothing (`isStaleEnd`), now also
  in the agent: a stale task_notification no longer settles the resumed
  run's live task, which permission attribution and the turn hold use.
- A run under an id the child used before becomes the current delegation
  again: the check compares with the current call, not the map.
- A child that was never announced does not take a resume's call, so a
  late frame cannot announce a withdrawn child.
- A running task_updated patch reports only that the child runs: its
  prompt is a guess, and the task_started or SendMessage result carries
  the delegation (`sendMessageResumed`). `taskResumed` keeps its
  signature from main.
- A resume while a request of the child is open reports requires_action.
- The end-to-end stale-end test now checks that the child stays running
  until the real end.

AIR's path and golden files are unchanged.
2026-10-05 17:00:24 +02:00
Ben Brandt
f826b6025e Merge remote-tracking branch 'origin/main' into feat/subagent-update 2026-10-05 16:29:22 +02:00
Ben Brandt
020dab8dce fix: follow a resumed subagent under the call that resumed it, and other RFD review fixes
Fixes from a close review of the RFD form, against the subagents RFD and
the bundled Claude Code CLI:

- The CLI runs a resumed agent under the id of the call that resumed it
  (`toolUseContext.toolUseId`, the SendMessage call). The child now takes
  over that id, so the resumed run's output and its task_notification
  reach the child instead of being buffered and dropped. A task_started
  of a known child resumes it even without a subagent type.
- A prompt message's id is the id of the call that delegated, and a
  prompt whose id is already in the child's transcript is not sent
  again, so a repeated resume signal does not overwrite or repeat it.
- The end of an earlier delegation no longer ends the current one.
- A resume's `senderSessionId` is the session that made the SendMessage
  call, and is left out when unknown, instead of always the parent.
- A failure reported first without the SDK's error text gets it when a
  later event of the same failure carries it.
- A task that a worker restart orphaned is idle with no stop reason, not
  cancelled: the RFD forbids inventing a cancellation.
- A child that ends before its parent is known stays unexposed, rather
  than being announced under the root: the RFD forbids guessing parentage.
- A late frame that names a child keeps its role in the title.
- `announceNativeSubagent` takes its form explicitly.
- README, docs/air-extensions.md and doc comments say what the code does.

AIR's path and golden files are unchanged.
2026-10-05 16:29:21 +02:00
Ben Brandt
e34c447e96 feat: report subagents as the subagents RFD defines, for clients that are not AIR
A client that declares `clientCapabilities.subagents` and is not AIR now
gets native subagent sessions in the form of the revised subagents RFD
(agentclientprotocol/agent-client-protocol#1992, docs/rfds/subagents.mdx),
which SDK 1.7.0 types. AIR keeps the earlier draft it implements
(`subagent_spawned`, `subagent_state_update`), whichever signal it
declares; its golden files do not change.

In the RFD form (`SubagentForm`, chosen by `subagentForm`):

- The child's session id is the SDK agent id, for every delegation to
  it: a SendMessage resume no longer opens a new generation.
- `subagent_update` announces the child on its immediate parent before
  any child traffic, with `title` (the agent's name, with its role after
  it, else the role), `description` (the Agent input's short
  description, never the prompt), and the `running` state.
- The prompt of each delegation is a `session_message` in the child's
  transcript, from the parent, with the id of the tool use that sent it.
  The Agent, Task, or SendMessage tool call stays a tool call of the
  session that made it.
- The work state follows the SDK: `requires_action` while a permission
  or question request of the child is open, `idle` with `end_turn`,
  `cancelled` when the SDK stopped or killed the task, and a failure as
  `idle` with the SDK's error in `_meta.claudeCode.error`. (The RFD's
  `error` stop reason is newer than SDK 1.7.0's closed v1 StopReason.)
  `session/cancel` no longer ends the children: a background subagent
  outlives the interrupt, and the RFD reports a cancellation only from
  evidence. At the end of the stream a running child gets `idle`,
  `cancelled` after a cancel.
- An idle child is reusable, so its later updates still reach it.
- No per-child capabilities, so a client may not change a child.
- Replay does not expose children yet, until it keeps their live ids.

The agent-side `sessionCapabilities.subagents` goes to AIR alone: the RFD
has only the client capability. compare.ts allows it against the
origin/main baseline.

Tests: the end-to-end tests of the earlier draft now run with an AIR
client; new runtime and end-to-end tests cover the RFD form, and check
every update against the v1 schema.

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-10-05 13:27:21 +02:00
11 changed files with 43 additions and 617 deletions

View File

@@ -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.

View File

@@ -63,7 +63,6 @@ import {
CanUseTool,
FastModeDisabledReason,
FastModeState,
filterEscalatingDefaultMode,
getSessionMessages,
getSubagentMessages,
HookCallback,
@@ -80,7 +79,6 @@ import {
PreToolUseHookInput,
Query,
query,
resolveSettings,
SDKAssistantMessageError,
SDKActiveGoalMessage,
SDKMessage,
@@ -2562,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. */
@@ -3164,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;
@@ -3201,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) {
@@ -3235,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
@@ -3248,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;
@@ -3280,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;
@@ -3589,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;
@@ -3846,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),
});
}
@@ -7062,14 +7007,11 @@ export class ClaudeAcpAgent {
}
}
}
// The stream sees a tool_use first, so the tool's PostToolUse hook
// is registered here, and publishes through this client: a
// subagent's hook update must reach the child session too.
for (const notification of streamEventToAcpNotifications(
message,
params.sessionId,
session.toolUseCache,
routedNotificationClient,
this.client,
this.logger,
{
clientCapabilities: this.clientCapabilities,
@@ -10455,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
@@ -10482,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 = {};
@@ -10730,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");

View File

@@ -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) {

View File

@@ -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.

View File

@@ -23,7 +23,6 @@ import type { AcpSessionNotification } from "../acp-subagents.js";
import { NativeSubagentRuntime } from "../native-subagents.js";
import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
import {
createPostToolUseHook,
markdownEscape,
toolInfoFromToolUse,
toDisplayPath,
@@ -55,8 +54,7 @@ import {
import { SessionTitles } from "../session-titles.js";
import { formatUsageResponse, isUsageCommandText, parseUsageResponse } from "../usage-markdown.js";
import { Pushable } from "../utils.js";
import { initializeClient, makeMockQuery } from "./helpers.js";
import * as agentSdk from "@anthropic-ai/claude-agent-sdk";
import { initializeClient } from "./helpers.js";
import {
deleteSession,
forkSession,
@@ -802,114 +800,6 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
}, 60000);
});
describe("Central auth settings integration", () => {
let fixture: string;
let originalConfigDir: string | undefined;
let agent: ClaudeAcpAgent;
let notification: ReturnType<typeof vi.fn>;
let querySpy: ReturnType<typeof vi.spyOn>;
beforeEach(async () => {
fixture = await mkdtemp(path.join(os.tmpdir(), "central-auth-integration-"));
originalConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = path.join(fixture, "user");
await mkdir(process.env.CLAUDE_CONFIG_DIR);
await mkdir(path.join(fixture, ".claude"));
await writeFile(
path.join(process.env.CLAUDE_CONFIG_DIR, "settings.json"),
JSON.stringify({ apiKeyHelper: "central proxy start" }),
);
notification = vi.fn(async () => {});
agent = new ClaudeAcpAgent(
{ sessionUpdate: async () => {}, extNotification: notification } as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
querySpy = vi.spyOn(agentSdk, "query").mockImplementation(
() =>
makeMockQuery({
initializationResult: async () => ({
models: [{ value: "id", displayName: "name", description: "description" }],
account: { apiProvider: "firstParty", apiKeySource: "apiKeyHelper" },
}),
}) as unknown as agentSdk.Query,
);
});
afterEach(async () => {
for (const sessionId of Object.keys(agent.sessions)) await agent.closeSession({ sessionId });
querySpy.mockRestore();
if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR;
else process.env.CLAUDE_CONFIG_DIR = originalConfigDir;
await rm(fixture, { recursive: true, force: true });
});
it("uses the local project override instead of an unused user Central helper", async () => {
await writeFile(
path.join(fixture, ".claude", "settings.json"),
JSON.stringify({ apiKeyHelper: "central proxy start" }),
);
await writeFile(
path.join(fixture, ".claude", "settings.local.json"),
JSON.stringify({ apiKeyHelper: "ordinary-helper" }),
);
await agent.newSession({ cwd: fixture, mcpServers: [] });
expect(notification).toHaveBeenLastCalledWith("_auth/status_update", {
authStatus: { kind: "api_key", label: "Anthropic API key", detail: "API key helper" },
});
});
it.each(["central proxy start", "", "ordinary-helper"])(
"honors the programmatic helper override %j",
async (helper) => {
await writeFile(
path.join(fixture, ".claude", "settings.json"),
JSON.stringify({ apiKeyHelper: "decentralized proxy start" }),
);
await agent.newSession({
cwd: fixture,
mcpServers: [],
_meta: { claudeCode: { options: { settings: { apiKeyHelper: helper } } } },
});
expect(notification).toHaveBeenLastCalledWith("_auth/status_update", {
authStatus:
helper === "central proxy start"
? { kind: "gateway", label: "JetBrains Air Gateway" }
: { kind: "api_key", label: "Anthropic API key", detail: "API key helper" },
});
},
);
it("does not let a late SDK result restore the previous helper name", async () => {
let finishOld!: (value: any) => void;
const oldAccount = new Promise<any>((resolve) => {
finishOld = resolve;
});
querySpy.mockImplementationOnce(() =>
makeMockQuery({ initializationResult: () => oldAccount }),
);
const oldSession = agent.newSession({
cwd: fixture,
mcpServers: [],
_meta: { claudeCode: { options: { settings: { apiKeyHelper: "ordinary-helper" } } } },
});
await vi.waitFor(() => expect(querySpy).toHaveBeenCalledOnce());
await agent.newSession({ cwd: fixture, mcpServers: [] });
finishOld({
models: [{ value: "id", displayName: "name", description: "description" }],
account: { apiKeySource: "apiKeyHelper" },
});
await oldSession;
expect(notification.mock.calls).toEqual([
[
"_auth/status_update",
{
authStatus: { kind: "gateway", label: "JetBrains Air Gateway" },
},
],
]);
});
});
describe("tool conversions", () => {
it("should handle Bash nicely", () => {
const tool_use = {
@@ -4189,11 +4079,7 @@ describe("permission request cancellation", () => {
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: {
dispose: vi.fn(),
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
} as any,
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
@@ -5584,111 +5470,6 @@ describe("subagent permission attribution (issue #851)", () => {
expect(requests[0].sessionId).toBe("agent-42");
});
it("sends the PostToolUse hook update of a child's Edit to the child session", async () => {
const updates: AcpSessionNotification[] = [];
const agent = new ClaudeAcpAgent(
{
sessionUpdate: async (notification: AcpSessionNotification) => {
updates.push(notification);
},
} as unknown as AcpClient,
{ log: () => {}, error: () => {} },
);
await initializeClient(agent, { subagents: {} } as ClientCapabilities);
const input = { file_path: "/repo/x.ts", old_string: "a", new_string: "b" };
const ranEdit = Promise.withResolvers<void>();
const hooked = Promise.withResolvers<void>();
injectGeneratorSession(agent, (prompts) =>
(async function* () {
const { value: prompt } = await prompts[Symbol.asyncIterator]().next();
yield userEcho(prompt);
yield rootToolUse("toolu_parent", "Agent", { subagent_type: "Explore", prompt: "Fix x" });
yield { ...taskStarted("agent-42", "toolu_parent"), subagent_type: "Explore" };
// Claude Code streams the child's tool_use before its complete message.
yield {
type: "stream_event",
parent_tool_use_id: "toolu_parent",
uuid: randomUUID(),
session_id: "test-session",
event: {
type: "content_block_start",
index: 0,
content_block: { type: "tool_use", id: "toolu_sub_edit", name: "Edit", input: {} },
},
};
yield {
...rootToolUse("toolu_sub_edit", "Edit", input),
parent_tool_use_id: "toolu_parent",
};
yield {
type: "user",
uuid: randomUUID(),
session_id: "test-session",
parent_tool_use_id: "toolu_parent",
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "toolu_sub_edit",
content: "The file was edited.",
},
],
},
};
ranEdit.resolve();
await hooked.promise;
yield successResult();
})(),
);
const turn = agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "go" }],
});
await ranEdit.promise;
await vi.waitFor(() =>
expect(
updates.some(
({ update }) =>
update.sessionUpdate === "tool_call_update" &&
update.toolCallId === "toolu_sub_edit" &&
update.status === "completed",
),
).toBe(true),
);
await createPostToolUseHook()(
{
hook_event_name: "PostToolUse",
tool_name: "Edit",
tool_input: input,
tool_response: {
filePath: "/repo/x.ts",
oldString: "a",
newString: "b",
originalFile: "a\n",
structuredPatch: [
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ["-a", "+b"] },
],
userModified: false,
replaceAll: false,
},
} as any,
"toolu_sub_edit",
{ signal: new AbortController().signal },
);
hooked.resolve();
await turn;
const editReports = updates.filter(
({ update }) =>
(update.sessionUpdate === "tool_call" || update.sessionUpdate === "tool_call_update") &&
update.toolCallId === "toolu_sub_edit",
);
// The hook's report carries the final diff.
expect(editReports.some(({ update }) => "content" in update && update.content)).toBe(true);
expect(new Set(editReports.map(({ sessionId }) => sessionId))).toEqual(new Set(["agent-42"]));
});
it("keeps a raced permission on the root until the child is announced", async () => {
const { agent, updates, requests, session } = setup();
await agent.initialize({
@@ -8905,17 +8686,13 @@ describe("stop reason propagation", () => {
},
},
});
// The turn settles at its `result`, and the cleared goal arrives after it,
// so prompt() can resolve before the consumer has published `goal: null`.
await vi.waitFor(() =>
expect(updates).toContainEqual({
sessionId: "test-session",
update: {
sessionUpdate: "session_info_update",
_meta: { jetbrains: { air: { version: 1, goal: null } } },
},
}),
);
expect(updates).toContainEqual({
sessionId: "test-session",
update: {
sessionUpdate: "session_info_update",
_meta: { jetbrains: { air: { version: 1, goal: null } } },
},
});
expect(updates.some((notification) => notification.update?._meta?.claudeCode?.goal)).toBe(
false,
);
@@ -11828,11 +11605,7 @@ describe("session/close", () => {
availableModels: [],
},
modelInfos: [],
settingsManager: {
dispose: vi.fn(),
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
} as any,
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
@@ -11928,11 +11701,7 @@ describe("session/delete", () => {
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: {
dispose: vi.fn(),
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
} as any,
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
@@ -12054,11 +11823,7 @@ describe("getOrCreateSession param change detection", () => {
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: {
dispose: vi.fn(),
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
} as any,
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
@@ -16322,11 +16087,7 @@ describe("post-error recovery", () => {
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: {
dispose: vi.fn(),
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
} as any,
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
@@ -16697,8 +16458,6 @@ describe("post-error recovery", () => {
},
{
settingsManager: {
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
dispose: vi.fn(() => {
throw new Error("dispose boom");
}),
@@ -23355,11 +23114,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: {
dispose: vi.fn(),
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
} as any,
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
@@ -25262,11 +25017,7 @@ describe("agent selection config option", () => {
modes: { currentModeId: "default", availableModes: [] },
models: { currentModelId: "default", availableModels: [] },
modelInfos: [],
settingsManager: {
dispose: vi.fn(),
getApiKeyHelper: () => undefined,
getCwd: () => "/test",
} as any,
settingsManager: { dispose: vi.fn() } as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,

View File

@@ -1,6 +1,7 @@
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"availableCommands":[{"description":"Show the MCP servers and their status, or reconnect, enable, or disable a server","input":{"hint":"[reconnect|enable|disable [<server>|all]]"},"name":"mcp"}],"sessionUpdate":"available_commands_update"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"sessionUpdate":"usage_update","size":200000,"used":3}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Agent","toolResponse":{"isAsync":true,"status":"async_launched"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_agent"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"capabilities":{},"name":"Background review","prompt":"Review the diff","sessionUpdate":"subagent_spawned","subagentSessionId":"a2","task":"Review the diff"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"Reviewi","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"a2","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_agent"}},"content":{"text":"ng now.","type":"text"},"messageId":"msg_async_sub","sessionUpdate":"agent_message_chunk"}}}

View File

@@ -18,9 +18,10 @@
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"content":[{"newText":"b","oldText":"a","path":"<cwd>/x.ts","type":"diff"}],"rawInput":{"file_path":"<cwd>/x.ts"},"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"requestPermission","payload":{"_meta":{"jetbrains":{"air":{"permission":{"title":"Edit x.ts","version":1},"version":1}}},"options":[{"kind":"allow_once","name":"Yes","optionId":"allow-once"},{"kind":"reject_once","name":"No","optionId":"reject"}],"sessionId":"agent_1","toolCall":{"rawInput":{"file_path":"<cwd>/x.ts"},"title":"Edit x.ts","toolCallId":"toolu_sub_edit"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"rawOutput":"The file has been updated.","sessionUpdate":"tool_call_update","status":"completed","toolCallId":"toolu_sub_edit"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"locations":[{"line":1,"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"locations":[{"line":1,"path":"<cwd>/x.ts"}],"sessionUpdate":"tool_call_update","toolCallId":"toolu_sub_edit"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"Foun","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"agent_1","update":{"_meta":{"claudeCode":{"parentToolUseId":"toolu_task"}},"content":{"text":"d it.","type":"text"},"messageId":"msg_sub_2","sessionUpdate":"agent_message_chunk"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"claudeCode":{"toolName":"Task","toolResponse":{"status":"completed"}}},"sessionUpdate":"tool_call_update","toolCallId":"toolu_task"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"sessionUpdate":"subagent_state_update","state":"completed","subagentSessionId":"agent_1"}}}
{"kind":"sessionUpdate","payload":{"sessionId":"<session>","update":{"_meta":{"_claude/model":"claude-sonnet-4-6"},"cost":{"amount":0,"currency":"USD"},"sessionUpdate":"usage_update","size":200000,"used":2}}}
{"kind":"promptResponse","payload":{"_meta":{"quota":{"model_usage":[],"token_count":{"cachedInputTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"reasoningOutputTokens":0,"totalTokens":0}}},"stopReason":"end_turn","usage":{"cachedReadTokens":0,"cachedWriteTokens":0,"inputTokens":0,"outputTokens":0,"totalTokens":0}}}

View File

@@ -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",
});
});

View File

@@ -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" },
]);
});

View File

@@ -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,

View File

@@ -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();
});
});