mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: switch providers for loaded Claude sessions (#1002)
### Problem Provider routing is captured when the Claude SDK `Query` is created. As a result, `providers/set` and `providers/disable` updated newly created sessions but an already loaded ACP session continued using its previous backend. User and project Claude settings could also override the ACP-selected URL or credentials after the subprocess environment was assembled, making the configured proxy ineffective. ### Solution - Serialize provider changes and wait for submitted turns to settle. - Close and recreate every loaded SDK query with the same Claude session ID, cwd, MCP configuration, and ACP creation parameters. - Block new/load/resume/prompt requests only while a provider update is in progress. - Apply the active ACP route in both the subprocess environment and programmatic settings tier. - Clear competing Anthropic, Bedrock, Vertex, OAuth, API-key, and `apiKeyHelper` routing while an ACP override is active; preserve unrelated settings. - Restore native/environment routing when the provider is disabled. - Add opt-in routing diagnostics through `CLAUDE_AGENT_LOGS` without logging request content or secret headers. ### Tests Added coverage for: - native behavior when Providers API is unused; - live recreation of loaded sessions; - waiting for active turns; - multiple loaded sessions across native → proxy A → proxy B → native; - user settings precedence and preservation of unrelated settings; - Anthropic, Bedrock, Vertex, legacy gateway auth, and logout behavior. Verification: - `npm test`: 789 passed, 20 skipped - `npm run build` - `npm run check` - `git diff --check` --------- Co-authored-by: Junie <junie@jetbrains.com>
This commit is contained in:
150
src/acp-agent.ts
150
src/acp-agent.ts
@@ -434,6 +434,8 @@ type Turn = {
|
||||
steeredSettle?: PromptResponse;
|
||||
resolve: (response: PromptResponse) => void;
|
||||
reject: (error: unknown) => void;
|
||||
/** Settles after the ACP prompt request completes, regardless of outcome. */
|
||||
completion?: Promise<void>;
|
||||
};
|
||||
|
||||
type Session = {
|
||||
@@ -531,6 +533,8 @@ type Session = {
|
||||
/** Serialized snapshot of session-defining params (cwd, mcpServers) used to
|
||||
* detect when loadSession/resumeSession is called with changed values. */
|
||||
sessionFingerprint: string;
|
||||
/** Original ACP parameters used to recreate this query with a new provider. */
|
||||
creationParams?: NewSessionRequest;
|
||||
settingsManager: SettingsManager;
|
||||
accumulatedUsage: AccumulatedUsage;
|
||||
modes: SessionModeState;
|
||||
@@ -1505,6 +1509,8 @@ export class ClaudeAcpAgent {
|
||||
gatewayAuthRequest?: GatewayAuthRequest;
|
||||
/** Set while ACP overrides the agent's native provider configuration. */
|
||||
providerConfig?: ProviderConfig;
|
||||
/** Serializes provider changes while every open query is recreated between turns. */
|
||||
private providerUpdate: Promise<void> | null = null;
|
||||
/** Grace period before a `session/cancel` forces a wedged prompt loop to
|
||||
* return "cancelled". See {@link DEFAULT_FORCE_CANCEL_GRACE_MS}. Mutable so
|
||||
* tests can shrink it. */
|
||||
@@ -1686,6 +1692,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async newSession(params: NewSessionRequest): Promise<NewSessionResponse> {
|
||||
if (this.providerUpdate) await this.providerUpdate;
|
||||
const response = await this.createSession(params, {
|
||||
// Revisit these meta values once we support resume
|
||||
resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume,
|
||||
@@ -1698,6 +1705,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
||||
if (this.providerUpdate) await this.providerUpdate;
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
@@ -1718,6 +1726,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
if (this.providerUpdate) await this.providerUpdate;
|
||||
const result = await this.getOrCreateSession(params);
|
||||
|
||||
// Needs to happen after we return the session
|
||||
@@ -1728,6 +1737,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
if (this.providerUpdate) await this.providerUpdate;
|
||||
const result = await this.getOrCreateSession(params);
|
||||
|
||||
await this.replaySessionHistory(params.sessionId);
|
||||
@@ -1802,6 +1812,9 @@ export class ClaudeAcpAgent {
|
||||
|
||||
async unstable_listProviders(_params: ListProvidersRequest): Promise<ListProvidersResponse> {
|
||||
const config = this.providerConfig ?? this.defaultProviderConfig();
|
||||
this.logger.log(
|
||||
`[providers/list] apiType=${config.apiType} baseUrl=${config.baseUrl} overridden=${this.providerConfig !== undefined}`,
|
||||
);
|
||||
const provider: ProviderInfo = {
|
||||
providerId: PROVIDER_ID,
|
||||
supported: SUPPORTED_PROTOCOLS,
|
||||
@@ -1862,7 +1875,10 @@ export class ClaudeAcpAgent {
|
||||
config.vertex = { projectId: vertex.projectId, region: vertex.region };
|
||||
}
|
||||
|
||||
this.providerConfig = config;
|
||||
this.logger.log(
|
||||
`[providers/set] apiType=${config.apiType} baseUrl=${config.baseUrl} sessions=${Object.keys(this.sessions).length}`,
|
||||
);
|
||||
await this.enqueueProviderUpdate(config);
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -1872,7 +1888,8 @@ export class ClaudeAcpAgent {
|
||||
*/
|
||||
async unstable_disableProvider(params: DisableProviderRequest): Promise<DisableProviderResponse> {
|
||||
if (params.providerId === PROVIDER_ID) {
|
||||
this.providerConfig = undefined;
|
||||
this.logger.log(`[providers/disable] sessions=${Object.keys(this.sessions).length}`);
|
||||
await this.enqueueProviderUpdate(undefined);
|
||||
}
|
||||
// Unknown provider: idempotent success.
|
||||
return {};
|
||||
@@ -1941,6 +1958,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
async prompt(params: PromptRequest): Promise<PromptResponse> {
|
||||
if (this.providerUpdate) await this.providerUpdate;
|
||||
const session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
@@ -1990,9 +2008,19 @@ export class ClaudeAcpAgent {
|
||||
resolve: () => {},
|
||||
reject: () => {},
|
||||
};
|
||||
let completeTurn!: () => void;
|
||||
turn.completion = new Promise<void>((resolve) => {
|
||||
completeTurn = resolve;
|
||||
});
|
||||
const response = new Promise<PromptResponse>((resolve, reject) => {
|
||||
turn.resolve = resolve;
|
||||
turn.reject = reject;
|
||||
turn.resolve = (result) => {
|
||||
resolve(result);
|
||||
completeTurn();
|
||||
};
|
||||
turn.reject = (error) => {
|
||||
reject(error);
|
||||
completeTurn();
|
||||
};
|
||||
});
|
||||
|
||||
session.turnQueue ??= [];
|
||||
@@ -6308,13 +6336,40 @@ export class ClaudeAcpAgent {
|
||||
// config concurrently, so re-resolving it after any of the awaits between
|
||||
// here and the session registration could disagree with the env baked
|
||||
// into the query.
|
||||
const resolvedProvider = this.resolveProviderConfig();
|
||||
const providerEnv = createEnvForProvider(resolvedProvider);
|
||||
const configuredSettings =
|
||||
userProvidedOptions?.settings ??
|
||||
(modelConfig
|
||||
? {
|
||||
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
||||
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
||||
}
|
||||
: undefined);
|
||||
// Claude Code applies env from settings.json after the subprocess env. Put
|
||||
// an active ACP route in the programmatic settings tier too so user/project
|
||||
// settings cannot silently restore a different ANTHROPIC_BASE_URL.
|
||||
let settings = configuredSettings;
|
||||
if (resolvedProvider) {
|
||||
const baseSettings =
|
||||
typeof configuredSettings === "string"
|
||||
? (JSON.parse(
|
||||
await fs.readFile(path.resolve(params.cwd, configuredSettings), "utf8"),
|
||||
) as Exclude<Options["settings"], string | undefined>)
|
||||
: configuredSettings;
|
||||
settings = {
|
||||
...baseSettings,
|
||||
apiKeyHelper: "",
|
||||
env: { ...baseSettings?.env, ...providerEnv },
|
||||
};
|
||||
}
|
||||
const env = {
|
||||
...process.env,
|
||||
...userProvidedOptions?.env,
|
||||
// Client-managed LLM routing: `providers/set` config wins, else the
|
||||
// legacy gateway auth request. Baked into the query at creation, so it
|
||||
// only affects sessions started after the change (matching the RFD).
|
||||
...createEnvForProvider(this.resolveProviderConfig()),
|
||||
// legacy gateway auth request. Routing is baked into the query at
|
||||
// creation; provider updates recreate loaded queries between turns.
|
||||
...providerEnv,
|
||||
// Opt-in to session state events like when the agent is idle
|
||||
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
||||
};
|
||||
@@ -6323,23 +6378,16 @@ export class ClaudeAcpAgent {
|
||||
// SDK, so per-session `_meta` env routing and ambient process-env routing
|
||||
// are distinguished exactly as the CLI will see them.
|
||||
const providerCacheKey = providerCacheKeyFor(env);
|
||||
this.logger.log(
|
||||
`[session/query] sessionId=${sessionId} resume=${creationOpts?.resume ?? "none"} apiType=${resolvedProvider?.apiType ?? "native"} baseUrl=${resolvedProvider?.baseUrl ?? "native"}`,
|
||||
);
|
||||
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
...(thinking !== undefined && { thinking }),
|
||||
...userProvidedOptions,
|
||||
// CLAUDE_MODEL_CONFIG env var is a fallback for model
|
||||
// configuration (e.g. Bedrock model ID overrides). When the caller
|
||||
// provides settings via _meta, we intentionally ignore the env var —
|
||||
// the caller is assumed to have full control over model configuration.
|
||||
...(!userProvidedOptions?.settings &&
|
||||
modelConfig && {
|
||||
settings: {
|
||||
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
||||
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
||||
},
|
||||
}),
|
||||
...(settings && { settings }),
|
||||
env,
|
||||
// Override certain fields that must be controlled by ACP
|
||||
cwd: params.cwd,
|
||||
@@ -6682,6 +6730,7 @@ export class ClaudeAcpAgent {
|
||||
cancelled: false,
|
||||
cwd: params.cwd,
|
||||
sessionFingerprint: computeSessionFingerprint(params),
|
||||
creationParams: params,
|
||||
settingsManager,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -6721,6 +6770,46 @@ export class ClaudeAcpAgent {
|
||||
configOptions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider routing is baked into the environment of each SDK Query. Wait for
|
||||
* all submitted turns to settle, close every query, then resume each Claude
|
||||
* session with the same ID so subsequent turns inherit the new environment.
|
||||
*/
|
||||
private async enqueueProviderUpdate(config: ProviderConfig | undefined): Promise<void> {
|
||||
const previous = this.providerUpdate?.catch(() => undefined) ?? Promise.resolve();
|
||||
const update = previous.then(async () => {
|
||||
const sessions = Object.entries(this.sessions);
|
||||
const activeTurns = sessions.flatMap(([, session]) =>
|
||||
(session.turnQueue ?? []).flatMap((turn) => (turn.completion ? [turn.completion] : [])),
|
||||
);
|
||||
if (activeTurns.length > 0) {
|
||||
this.logger.log(
|
||||
`Waiting for ${activeTurns.length} active Claude turn(s) before provider update`,
|
||||
);
|
||||
await Promise.all(activeTurns);
|
||||
}
|
||||
|
||||
this.providerConfig = config;
|
||||
for (const [sessionId, session] of sessions) {
|
||||
if (this.sessions[sessionId] !== session || !session.creationParams) {
|
||||
continue;
|
||||
}
|
||||
this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
|
||||
this.closeQueryStream(session);
|
||||
delete this.sessions[sessionId];
|
||||
await this.createSession(session.creationParams, { resume: sessionId });
|
||||
}
|
||||
});
|
||||
this.providerUpdate = update;
|
||||
try {
|
||||
await update;
|
||||
} finally {
|
||||
if (this.providerUpdate === update) {
|
||||
this.providerUpdate = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shouldEmitRawMessage(
|
||||
@@ -6834,14 +6923,28 @@ function createEnvForProvider(config: ProviderConfig | null): Record<string, str
|
||||
if (!config) {
|
||||
return {};
|
||||
}
|
||||
const resetRouting = {
|
||||
ANTHROPIC_BASE_URL: "",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "",
|
||||
ANTHROPIC_VERTEX_BASE_URL: "",
|
||||
CLAUDE_CODE_USE_BEDROCK: "0",
|
||||
CLAUDE_CODE_USE_VERTEX: "0",
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: "",
|
||||
CLOUD_ML_REGION: "",
|
||||
AWS_REGION: "",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "",
|
||||
};
|
||||
const customHeaders = Object.entries(config.headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
|
||||
if (config.apiType === "bedrock") {
|
||||
return {
|
||||
...resetRouting,
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
|
||||
AWS_BEARER_TOKEN_BEDROCK: "acp-proxy", // Bypass local AWS credential checks
|
||||
ANTHROPIC_BEDROCK_BASE_URL: config.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
};
|
||||
@@ -6851,6 +6954,7 @@ function createEnvForProvider(config: ProviderConfig | null): Record<string, str
|
||||
// `config.vertex` is guaranteed present for vertex by `unstable_setProvider`
|
||||
// validation; fall back to empty strings defensively.
|
||||
return {
|
||||
...resetRouting,
|
||||
CLAUDE_CODE_USE_VERTEX: "1",
|
||||
ANTHROPIC_VERTEX_BASE_URL: config.baseUrl,
|
||||
ANTHROPIC_VERTEX_PROJECT_ID: config.vertex?.projectId ?? "",
|
||||
@@ -6860,9 +6964,10 @@ function createEnvForProvider(config: ProviderConfig | null): Record<string, str
|
||||
}
|
||||
|
||||
return {
|
||||
...resetRouting,
|
||||
ANTHROPIC_BASE_URL: config.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
|
||||
ANTHROPIC_AUTH_TOKEN: "acp-proxy", // Bypass local Claude login checks
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8675,7 +8780,7 @@ export async function runPromptWithCancellation(
|
||||
}
|
||||
}
|
||||
|
||||
export function runAcp() {
|
||||
export function runAcp(logger?: Logger) {
|
||||
const input = nodeToWebWritable(process.stdout);
|
||||
const output = nodeToWebReadable(process.stdin);
|
||||
|
||||
@@ -8721,7 +8826,7 @@ export function runAcp() {
|
||||
)
|
||||
.connect(stream);
|
||||
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client));
|
||||
agent = new ClaudeAcpAgent(new ClientConnection(connection.client), logger);
|
||||
return { connection, agent };
|
||||
}
|
||||
|
||||
@@ -8819,6 +8924,7 @@ const PROVIDER_ROUTING_ENV_VARS = [
|
||||
"ANTHROPIC_CUSTOM_HEADERS",
|
||||
"ANTHROPIC_API_KEY",
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
"CLAUDE_CODE_OAUTH_TOKEN",
|
||||
] as const;
|
||||
|
||||
/** Stable identifier for the LLM backend a session's query is created against,
|
||||
|
||||
26
src/index.ts
26
src/index.ts
@@ -3,6 +3,8 @@
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { appendFileSync, mkdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
// `--cli` is checked first so that `--version`/`-v` (and any other flags) are
|
||||
// forwarded to the wrapped native CLI rather than swallowed by our own version
|
||||
@@ -61,7 +63,29 @@ if (process.argv.includes("--cli")) {
|
||||
console.error("Unhandled Rejection at:", promise, "reason:", reason);
|
||||
});
|
||||
|
||||
const { connection, agent } = runAcp();
|
||||
const logDirectory = process.env.CLAUDE_AGENT_LOGS;
|
||||
const logger = logDirectory
|
||||
? (() => {
|
||||
mkdirSync(logDirectory, { recursive: true });
|
||||
const logFile = join(logDirectory, "agent.log");
|
||||
const writeLog = (...args: unknown[]) => {
|
||||
const rendered = args
|
||||
.map((arg) => (arg instanceof Error ? (arg.stack ?? arg.message) : String(arg)))
|
||||
.join(" ");
|
||||
appendFileSync(logFile, `${new Date().toISOString()} pid=${process.pid} ${rendered}\n`);
|
||||
};
|
||||
return {
|
||||
log: writeLog,
|
||||
error: (...args: unknown[]) => {
|
||||
console.error(...args);
|
||||
writeLog(...args);
|
||||
},
|
||||
};
|
||||
})()
|
||||
: undefined;
|
||||
logger?.log("Claude ACP started");
|
||||
|
||||
const { connection, agent } = runAcp(logger);
|
||||
|
||||
async function shutdown() {
|
||||
await agent.dispose().catch((err) => {
|
||||
|
||||
@@ -125,7 +125,7 @@ describe("authorization", () => {
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: " ",
|
||||
ANTHROPIC_AUTH_TOKEN: "acp-proxy",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
userEnv: "userEnv",
|
||||
@@ -162,7 +162,7 @@ describe("authorization", () => {
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: " ",
|
||||
AWS_BEARER_TOKEN_BEDROCK: "acp-proxy",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
|
||||
}),
|
||||
|
||||
@@ -740,6 +740,7 @@ describe("createSession options merging", () => {
|
||||
headers: {},
|
||||
});
|
||||
const plain = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const plainProviderCacheKey = sessionFor(plain.sessionId).providerCacheKey;
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
@@ -749,9 +750,7 @@ describe("createSession options merging", () => {
|
||||
});
|
||||
const beta = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(sessionFor(beta.sessionId).providerCacheKey).not.toBe(
|
||||
sessionFor(plain.sessionId).providerCacheKey,
|
||||
);
|
||||
expect(sessionFor(beta.sessionId).providerCacheKey).not.toBe(plainProviderCacheKey);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -28,6 +28,8 @@ export function makeMockQuery(overrides: Record<string, unknown> = {}) {
|
||||
setPermissionMode: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
|
||||
close: () => {},
|
||||
interrupt: async () => undefined,
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -11,6 +11,10 @@ const mockQuery = vi.hoisted(() =>
|
||||
setModel: vi.fn(),
|
||||
setPermissionMode: vi.fn(),
|
||||
supportedCommands: vi.fn().mockResolvedValue([]),
|
||||
close: vi.fn(),
|
||||
interrupt: vi.fn().mockResolvedValue(undefined),
|
||||
getContextUsage: vi.fn().mockResolvedValue({ totalTokens: 0, rawMaxTokens: 200000 }),
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
})),
|
||||
);
|
||||
|
||||
@@ -190,7 +194,7 @@ describe("providers", () => {
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: " ",
|
||||
ANTHROPIC_AUTH_TOKEN: "acp-proxy",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
}),
|
||||
@@ -199,6 +203,171 @@ describe("providers", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps native session creation unchanged when the providers API is unused", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
|
||||
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledOnce();
|
||||
expect(mockQuery.mock.calls[0][0].options.resume).toBeUndefined();
|
||||
expect(mockQuery.mock.results[0].value.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("recreates loaded sessions with the new provider between turns", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
const first = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const originalQuery = mockQuery.mock.results[0].value;
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
expect(originalQuery.close).toHaveBeenCalledOnce();
|
||||
expect(mockQuery).toHaveBeenCalledTimes(2);
|
||||
expect(mockQuery.mock.calls[1][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
resume: first.sessionId,
|
||||
env: expect.objectContaining({ ANTHROPIC_BASE_URL: "https://gateway.example" }),
|
||||
settings: expect.objectContaining({
|
||||
apiKeyHelper: "",
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_AUTH_TOKEN: "acp-proxy",
|
||||
CLAUDE_CODE_OAUTH_TOKEN: "",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await agent.unstable_disableProvider({ providerId: "main" });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(3);
|
||||
expect(mockQuery.mock.calls[2][0]).toEqual(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({ resume: first.sessionId }),
|
||||
}),
|
||||
);
|
||||
expect(mockQuery.mock.calls[2][0].options.env.ANTHROPIC_BASE_URL).toBe(
|
||||
process.env.ANTHROPIC_BASE_URL,
|
||||
);
|
||||
});
|
||||
|
||||
it("switches every loaded session through proxy replacements and back to native routing", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
const first = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const second = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://first-gateway.example",
|
||||
});
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://second-gateway.example",
|
||||
});
|
||||
await agent.unstable_disableProvider({ providerId: "main" });
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledTimes(8);
|
||||
const resumedCalls = mockQuery.mock.calls.slice(2);
|
||||
for (let update = 0; update < 3; update += 1) {
|
||||
expect(
|
||||
resumedCalls.slice(update * 2, update * 2 + 2).map(([request]) => request.options.resume),
|
||||
).toEqual([first.sessionId, second.sessionId]);
|
||||
}
|
||||
expect(
|
||||
resumedCalls.slice(0, 2).map(([request]) => request.options.env.ANTHROPIC_BASE_URL),
|
||||
).toEqual(["https://first-gateway.example", "https://first-gateway.example"]);
|
||||
expect(
|
||||
resumedCalls.slice(2, 4).map(([request]) => request.options.env.ANTHROPIC_BASE_URL),
|
||||
).toEqual(["https://second-gateway.example", "https://second-gateway.example"]);
|
||||
expect(
|
||||
resumedCalls.slice(4).map(([request]) => request.options.env.ANTHROPIC_BASE_URL),
|
||||
).toEqual([process.env.ANTHROPIC_BASE_URL, process.env.ANTHROPIC_BASE_URL]);
|
||||
for (const result of mockQuery.mock.results.slice(0, 6)) {
|
||||
expect(result.value.close).toHaveBeenCalledOnce();
|
||||
}
|
||||
for (const result of mockQuery.mock.results.slice(6)) {
|
||||
expect(result.value.close).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("overrides user routing settings while preserving unrelated settings", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
await agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: {
|
||||
apiKeyHelper: "unsafe-helper",
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://user-gateway.example",
|
||||
ANTHROPIC_API_KEY: "user-secret",
|
||||
UNRELATED_SETTING: "preserved",
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(mockQuery.mock.calls[0][0].options.settings).toEqual(
|
||||
expect.objectContaining({
|
||||
apiKeyHelper: "",
|
||||
model: "claude-sonnet-4-6",
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_API_KEY: "",
|
||||
ANTHROPIC_AUTH_TOKEN: "acp-proxy",
|
||||
UNRELATED_SETTING: "preserved",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for an active turn before recreating its session", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
const created = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
|
||||
const originalQuery = mockQuery.mock.results[0].value;
|
||||
let finishTurn!: () => void;
|
||||
const completion = new Promise<void>((resolve) => {
|
||||
finishTurn = resolve;
|
||||
});
|
||||
(agent.sessions[created.sessionId] as any).turnQueue = [{ completion }];
|
||||
|
||||
const update = agent.unstable_setProvider({
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://gateway.example",
|
||||
});
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
expect(originalQuery.close).not.toHaveBeenCalled();
|
||||
finishTurn();
|
||||
await update;
|
||||
expect(originalQuery.close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("routes bedrock provider config into session env", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
|
||||
@@ -216,7 +385,7 @@ describe("providers", () => {
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: " ",
|
||||
AWS_BEARER_TOKEN_BEDROCK: "acp-proxy",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user