mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-05 17:01:44 +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 |
@@ -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.
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"}}}
|
||||
|
||||
@@ -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}}}
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -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