mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-02 15:31:50 +00:00
fix: preserve live session settings across provider refreshes (#1474)
## Problem When the provider changes on a running agent (`providers/set`, `providers/disable`, or logout with a provider override), every open session gets a new Claude Code process. For example, this happens after a BYOK key edit or a proxy recovery. The new process started from the original `session/new` params only. The session lost the model, effort, mode, Fast mode, ultracode and agent the user had picked, while the client still showed the old values (IJAI-4592). ## Fix - After the new process starts, the session's previous choices are applied again through the same code paths as client requests. The client then gets a `config_option_update` with the actual configuration. - If the new provider doesn't support a choice (for example, the new key has no access to that model), the choice is logged and skipped. The client sees the real value. - Effort: only the user's current pin comes back. An effort the user reset to Default, or that a model switch dropped, stays off. - Mode, config changes and restarts run one at a time per session. A choice sent during a restart lands after the restored ones. Other sessions are not blocked. - The respawn after a sign-out goes through the same path, so it keeps the choices too. - When a provider change overlaps that respawn, it replaces the current recovered session and holds waiting prompts until the provider update finishes. Those prompts then run once on the latest route with the restored choices. - Closing a session while it restarts no longer leaves a running process behind. ## Tests - `provider-session-configuration.test.ts`: the overlapping sign-out/provider change and two waiting prompts held until the final replacement initializes. - `provider-session-configuration.test.ts`: restore order, effort edge cases, races, and close or dispose during a restart. - `session-hooks.test.ts`: real Claude Code CLI against a local fake API. After a refresh, the next request goes out with the chosen model and `effort: high`. ## Validation [CI on the published follow-up](https://github.com/agentclientprotocol/claude-agent-acp/actions/runs/40989963422) passes formatting, lint, build and tests: 3,474 passed, 32 skipped, including 37 provider, 107 authorization and five real CLI-hook tests against a local fake API. Independent review passed; 151 scoped tests also covered seven additional recovery/failure schedules. <details><summary>Local baseline comparison and scope</summary> The added overlap regression failed on the preceding draft and passes with this repair. Complete local runs used identical locked dependencies and two workers: baseline 3,431 passed / 19 failed / 30 skipped; candidate 3,433 passed / the same 19 failed / 30 skipped. All local failures are existing session-index cases; filesystem watching reported EMFILE, with two SDK path-list assertions also failing. The complete Linux CI run above passes. This follow-up is a normal appended commit preserving the existing implementation. The PR remains a draft. Released-package Air acceptance follows merge and release. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Aleksandr Slapoguzov <aleksandr.slapoguzov@jetbrains.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
2fd46c866d
commit
e01a288384
251
src/acp-agent.ts
251
src/acp-agent.ts
@@ -1226,6 +1226,8 @@ export type Session = {
|
||||
/** The currently selected main-thread agent name, or "default" for the
|
||||
* standard Claude Code agent (no `agent` flag applied). */
|
||||
currentAgent: string;
|
||||
/** Whether the client explicitly selected an agent, including Default. */
|
||||
agentSelected?: boolean;
|
||||
/** Whether Fast mode is currently enabled for this session. Tracked as the
|
||||
* user's intent so it persists across model switches; the Fast mode config
|
||||
* option is only surfaced while the selected model supports it. */
|
||||
@@ -1868,6 +1870,17 @@ export type NewSessionMeta = {
|
||||
additionalRoots?: string[];
|
||||
};
|
||||
|
||||
/** `params` without `_meta.claudeCode.options.effort`. */
|
||||
function withoutEffort(params: NewSessionRequest): NewSessionRequest {
|
||||
const meta = params._meta as NewSessionMeta | undefined;
|
||||
if (meta?.claudeCode?.options?.effort === undefined) {
|
||||
return params;
|
||||
}
|
||||
const options = { ...meta.claudeCode.options };
|
||||
delete options.effort;
|
||||
return { ...params, _meta: { ...meta, claudeCode: { ...meta.claudeCode, options } } };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extra metadata for 'gateway' authentication requests.
|
||||
*/
|
||||
@@ -2748,6 +2761,8 @@ export class ClaudeAcpAgent {
|
||||
providerConfig?: ProviderConfig;
|
||||
/** Serializes provider changes while every open query is recreated between turns. */
|
||||
private providerUpdate: Promise<void> | null = null;
|
||||
/** Per session: queue that runs mode/config changes and query restarts one at a time. */
|
||||
private readonly configurationChanges = new Map<string, Promise<void>>();
|
||||
private readonly exitPlan: ExitPlanCoordinator<Session, Turn>;
|
||||
/** Last auth identity reported to the client, connection-scoped like
|
||||
* `authenticate`/`logout`. Undefined means "not determined yet". */
|
||||
@@ -3893,7 +3908,14 @@ export class ClaudeAcpAgent {
|
||||
);
|
||||
void this.probeCliAuthStatus();
|
||||
const signOutRespawn = this.respawnSignedOutSession(params.sessionId, session);
|
||||
if (signOutRespawn) session = await signOutRespawn;
|
||||
if (signOutRespawn) {
|
||||
await signOutRespawn;
|
||||
// A provider update can queue its replacement behind the sign-out respawn.
|
||||
// Wait for that replacement too before a prompt starts on the old route.
|
||||
while (this.providerUpdate) await this.providerUpdate;
|
||||
session = this.sessions[params.sessionId];
|
||||
if (!session) throw RequestError.sessionNotFound(params.sessionId);
|
||||
}
|
||||
// The SDK query stream already terminated (see `queryClosed`); its iterator
|
||||
// can't be revived, so enqueueing here would hang on a deferred that never
|
||||
// settles. Fail clearly and let the client start a fresh session.
|
||||
@@ -4244,35 +4266,27 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
private async recreateSignedOutQuery(sessionId: string, session: Session): Promise<void> {
|
||||
const creationParams = session.creationParams;
|
||||
if (!creationParams) {
|
||||
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
||||
}
|
||||
this.logger.log(`Recreating Claude session ${sessionId} after a sign-out`);
|
||||
// Already closed by `markSessionForSignOutRespawn`; idempotent here so a
|
||||
// husk that reached this by another route still releases its resources.
|
||||
this.closeQueryStream(session);
|
||||
try {
|
||||
// The very first turn may be the one that signed out, so the CLI may
|
||||
// never have written the conversation.
|
||||
await this.recreateLiveSession(creationParams, sessionId, {
|
||||
permissionMode: session.modes.currentModeId as PermissionMode,
|
||||
});
|
||||
// The new Session has the same MCP servers; keep the ones already
|
||||
// reported so its first `system`/init doesn't repeat their notices.
|
||||
const recreated = this.sessions[sessionId];
|
||||
if (recreated && session.reportedMcpServers) {
|
||||
recreated.reportedMcpServers = new Set(session.reportedMcpServers);
|
||||
/** Queued per session, so a choice sent during the respawn lands on the new query. */
|
||||
private recreateSignedOutQuery(sessionId: string, session: Session): Promise<void> {
|
||||
return this.serializeConfigurationChange(sessionId, async () => {
|
||||
const creationParams = session.creationParams;
|
||||
if (!creationParams) {
|
||||
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
||||
}
|
||||
} catch (error) {
|
||||
// Keep the husk addressable: the client answers the refusal with its own
|
||||
// auth flow and then retries this session.
|
||||
if (!this.sessions[sessionId]) {
|
||||
this.sessions[sessionId] = session;
|
||||
// Already replaced by a provider restart, or closed.
|
||||
if (this.sessions[sessionId] !== session) return;
|
||||
this.logger.log(`Recreating Claude session ${sessionId} after a sign-out`);
|
||||
try {
|
||||
// The signed-out turn may be the first, so the conversation may not be saved yet.
|
||||
await this.replaceSessionQuery(sessionId, session, creationParams);
|
||||
} catch (error) {
|
||||
// Keep the husk so the client can sign in and retry; a closed session stays closed.
|
||||
if (!session.tornDown && !this.sessions[sessionId]) {
|
||||
this.sessions[sessionId] = session;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4291,6 +4305,7 @@ export class ClaudeAcpAgent {
|
||||
options: {
|
||||
permissionMode?: PermissionMode;
|
||||
resumedModelHint?: string | Promise<string | undefined>;
|
||||
replacingSession?: Session;
|
||||
},
|
||||
): Promise<NewSessionResponse> {
|
||||
try {
|
||||
@@ -4307,6 +4322,7 @@ export class ClaudeAcpAgent {
|
||||
return await this.createSession(params, {
|
||||
reuseSessionId: sessionId,
|
||||
permissionMode: options.permissionMode,
|
||||
replacingSession: options.replacingSession,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -9324,11 +9340,21 @@ export class ClaudeAcpAgent {
|
||||
return this.sessionIndex.deleteSession(params);
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
return this.sessionModes.setSessionMode(params);
|
||||
setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
return this.serializeConfigurationChange(params.sessionId, () =>
|
||||
this.sessionModes.setSessionMode(params),
|
||||
);
|
||||
}
|
||||
|
||||
async setSessionConfigOption(
|
||||
setSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
return this.serializeConfigurationChange(params.sessionId, () =>
|
||||
this.selectSessionConfigOption(params),
|
||||
);
|
||||
}
|
||||
|
||||
private async selectSessionConfigOption(
|
||||
params: SetSessionConfigOptionRequest,
|
||||
): Promise<SetSessionConfigOptionResponse> {
|
||||
const session = this.sessions[params.sessionId];
|
||||
@@ -11398,6 +11424,7 @@ export class ClaudeAcpAgent {
|
||||
agent: value === DEFAULT_AGENT_ID ? null : value,
|
||||
});
|
||||
session.currentAgent = value;
|
||||
session.agentSelected = true;
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
@@ -11693,6 +11720,8 @@ export class ClaudeAcpAgent {
|
||||
resumedModelHint?: string | Promise<string | undefined>;
|
||||
/** The state that a replay on the same session uses before this record exists. */
|
||||
replayState?: ReplayState;
|
||||
/** A close of this session during startup cancels registration of its replacement. */
|
||||
replacingSession?: Session;
|
||||
} = {},
|
||||
): Promise<NewSessionResponse> {
|
||||
const createStartedAt = performance.now();
|
||||
@@ -12400,6 +12429,9 @@ export class ClaudeAcpAgent {
|
||||
allowlistedModelInfo,
|
||||
);
|
||||
|
||||
if (creationOpts.replacingSession?.tornDown) {
|
||||
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
||||
}
|
||||
this.sessions[sessionId] = {
|
||||
query: q,
|
||||
input: input,
|
||||
@@ -12527,26 +12559,10 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
|
||||
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];
|
||||
try {
|
||||
await this.createSession(session.creationParams, {
|
||||
resume: sessionId,
|
||||
permissionMode: session.modes.currentModeId as PermissionMode,
|
||||
});
|
||||
} catch (error) {
|
||||
// One session that cannot come back must not abort the switch. The
|
||||
// `--hide-claude-auth` guard makes this a normal outcome of
|
||||
// `providers/disable`: the override kept a subscription account
|
||||
// usable, and creation refuses without it. The session is already
|
||||
// gone, so tell the client why and go on to the next one.
|
||||
this.reportSessionLostOnProviderUpdate(sessionId, session, error);
|
||||
}
|
||||
for (const [sessionId] of sessions) {
|
||||
await this.serializeConfigurationChange(sessionId, () =>
|
||||
this.restartSessionForProvider(sessionId),
|
||||
);
|
||||
}
|
||||
});
|
||||
// Sessions and prompts await `providerUpdate` before they run. A rejected
|
||||
@@ -12566,6 +12582,139 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
}
|
||||
|
||||
/** Starts a new query for the session under the new provider and restores its choices. */
|
||||
private async restartSessionForProvider(sessionId: string): Promise<void> {
|
||||
// A sign-out respawn ahead of this change can have replaced the snapshot.
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session?.creationParams) {
|
||||
return;
|
||||
}
|
||||
this.logger.log(`Recreating Claude session ${sessionId} for provider update`);
|
||||
try {
|
||||
await this.replaceSessionQuery(sessionId, session, session.creationParams);
|
||||
} catch (error) {
|
||||
if (this.sessions[sessionId] === session) delete this.sessions[sessionId];
|
||||
if (session.tornDown) return;
|
||||
// One session that cannot come back must not abort the switch. The
|
||||
// `--hide-claude-auth` guard makes this a normal outcome of
|
||||
// `providers/disable`: the override kept a subscription account
|
||||
// usable, and creation refuses without it. The session is already
|
||||
// gone, so tell the client why and go on to the next one.
|
||||
this.reportSessionLostOnProviderUpdate(sessionId, session, error);
|
||||
}
|
||||
}
|
||||
|
||||
/** Recreates the session's query and restores its choices and reported MCP servers, unless it was closed meanwhile. */
|
||||
private async replaceSessionQuery(
|
||||
sessionId: string,
|
||||
session: Session,
|
||||
creationParams: NewSessionRequest,
|
||||
): Promise<void> {
|
||||
this.closeQueryStream(session);
|
||||
// Keep the old record visible so closeSession and dispose can mark it torn down.
|
||||
// Without the creation-time effort: restoreSessionChoices restores the current pin instead.
|
||||
await this.recreateLiveSession(withoutEffort(creationParams), sessionId, {
|
||||
permissionMode: session.modes.currentModeId as PermissionMode,
|
||||
replacingSession: session,
|
||||
});
|
||||
const recreated = this.sessions[sessionId];
|
||||
if (!recreated || recreated.tornDown) return;
|
||||
// Same MCP servers, so don't repeat their notices.
|
||||
if (session.reportedMcpServers) {
|
||||
recreated.reportedMcpServers = new Set(session.reportedMcpServers);
|
||||
}
|
||||
await this.restoreSessionChoices(sessionId, recreated, session);
|
||||
if (recreated.tornDown) return;
|
||||
// The recreated session works without this update, so a failed send is only logged.
|
||||
await this.client
|
||||
.sessionUpdate({
|
||||
sessionId,
|
||||
update: { sessionUpdate: "config_option_update", configOptions: recreated.configOptions },
|
||||
})
|
||||
.catch((error) => {
|
||||
this.logger.error(
|
||||
`Session ${sessionId}: could not publish the configuration restored after the query restart:`,
|
||||
error,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Re-applies the choices of `previous` to the session that replaced it; a choice the new query rejects is logged. */
|
||||
private async restoreSessionChoices(
|
||||
sessionId: string,
|
||||
session: Session,
|
||||
previous: Session,
|
||||
): Promise<void> {
|
||||
const restore = async (choice: string, apply: () => Promise<unknown>) => {
|
||||
if (session.tornDown) return;
|
||||
try {
|
||||
await apply();
|
||||
} catch (error) {
|
||||
if (!session.tornDown) {
|
||||
this.logger.error(`Could not restore ${choice} after the query restart:`, error);
|
||||
}
|
||||
}
|
||||
};
|
||||
const select = (configId: string, value: string) =>
|
||||
restore(`${configId} "${value}"`, () =>
|
||||
this.selectSessionConfigOption({ sessionId, configId, value }),
|
||||
);
|
||||
|
||||
// First: the model decides which modes, effort levels, Fast mode and ultracode exist.
|
||||
if (session.models.currentModelId !== previous.models.currentModelId) {
|
||||
await select(MODEL_CONFIG_ID, previous.models.currentModelId);
|
||||
}
|
||||
// After the model: creation falls back from "auto" to acceptEdits on a model without Auto.
|
||||
if (session.modes.currentModeId !== previous.modes.currentModeId) {
|
||||
await select(MODE_CONFIG_ID, previous.modes.currentModeId);
|
||||
if (session.modes.currentModeId === previous.modes.currentModeId) {
|
||||
session.autoModeFallbackWarningPending = false;
|
||||
}
|
||||
}
|
||||
if (session.modes.currentModeId === "plan") {
|
||||
session.prePlanMode = previous.prePlanMode;
|
||||
}
|
||||
// Compare intent, not the option: the option is hidden on a model without support.
|
||||
if (session.ultracodeEnabled !== previous.ultracodeEnabled) {
|
||||
await restore("ultracode", () => this.applyUltracode(session, previous.ultracodeEnabled));
|
||||
}
|
||||
if (session.fastModeEnabled !== previous.fastModeEnabled) {
|
||||
await restore("Fast mode", () => this.applyFastMode(session, previous.fastModeEnabled));
|
||||
}
|
||||
// After ultracode, since the effort flag carries it.
|
||||
if (previous.effortPinnedLevel !== undefined) {
|
||||
await select(EFFORT_CONFIG_ID, previous.effortPinnedLevel);
|
||||
}
|
||||
// Even when unchanged: Default over a hidden creation-time agent like "Explore" must clear it.
|
||||
if (previous.agentSelected) {
|
||||
if (previous.currentAgent === DEFAULT_AGENT_ID) {
|
||||
// Default is always valid, even when discovery returned no custom agents and hid the picker.
|
||||
await restore("Default agent", () =>
|
||||
this.applyConfigOptionValue(sessionId, session, AGENT_CONFIG_ID, DEFAULT_AGENT_ID),
|
||||
);
|
||||
} else {
|
||||
await select(AGENT_CONFIG_ID, previous.currentAgent);
|
||||
}
|
||||
}
|
||||
// Keep the original params and fingerprint paired for later load/resume calls.
|
||||
session.creationParams = previous.creationParams;
|
||||
session.sessionFingerprint = previous.sessionFingerprint;
|
||||
}
|
||||
|
||||
private serializeConfigurationChange<T>(sessionId: string, change: () => Promise<T>): Promise<T> {
|
||||
const result = (this.configurationChanges.get(sessionId) ?? Promise.resolve()).then(change);
|
||||
// Drops the entry once no newer change has queued behind this one.
|
||||
const tail: Promise<void> = result
|
||||
.catch(() => {})
|
||||
.then(() => {
|
||||
if (this.configurationChanges.get(sessionId) === tail) {
|
||||
this.configurationChanges.delete(sessionId);
|
||||
}
|
||||
});
|
||||
this.configurationChanges.set(sessionId, tail);
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Tell a capable client when a non-auth error ends a session during a provider switch. */
|
||||
private reportSessionLostOnProviderUpdate(
|
||||
sessionId: string,
|
||||
|
||||
@@ -525,11 +525,12 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
return createSession(params, options);
|
||||
};
|
||||
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, (agent as any).sessions[sessionId]);
|
||||
const husk = (agent as any).sessions[sessionId];
|
||||
await (agent as any).recreateSignedOutQuery(sessionId, husk);
|
||||
|
||||
expect(creations).toEqual([
|
||||
{ resume: sessionId, permissionMode: "default" },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default" },
|
||||
{ resume: sessionId, permissionMode: "default", replacingSession: husk },
|
||||
{ reuseSessionId: sessionId, permissionMode: "default", replacingSession: husk },
|
||||
]);
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
});
|
||||
|
||||
@@ -1076,6 +1076,62 @@ describe("authorization", () => {
|
||||
expect([...respawned.sessionFailureState.active.values()]).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("restores the user's choices on the respawned query, then a choice sent meanwhile", async () => {
|
||||
const [agent, updates] = createRecordingAgent(true);
|
||||
hideClaudeAuth();
|
||||
const effortModels = ["sonnet", "opus"].map((value) => ({
|
||||
value,
|
||||
displayName: value,
|
||||
description: "",
|
||||
supportsAutoMode: true,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
}));
|
||||
const scripts = [[[signOutResult()]], [[successResult()]]];
|
||||
let releaseRespawn!: () => void;
|
||||
const respawnGate = new Promise<void>((resolve) => (releaseRespawn = resolve));
|
||||
const queries: any[] = [];
|
||||
mockQuery.mockImplementation((args: any) => {
|
||||
const query = scriptedQuery(args, SIGNED_IN_WITH_KEY, scripts[queries.length] ?? []);
|
||||
const gate = queries.length > 0 ? respawnGate : undefined;
|
||||
query.initializationResult = async () => {
|
||||
await gate;
|
||||
return { models: effortModels, account: SIGNED_IN_WITH_KEY };
|
||||
};
|
||||
query.setModel = vi.fn(async () => {});
|
||||
query.applyFlagSettings = vi.fn(async () => {});
|
||||
queries.push(query);
|
||||
return query;
|
||||
});
|
||||
const { sessionId } = await agent.newSession(newSessionParams());
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "opus" });
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "high" });
|
||||
await expect(agent.prompt(promptParams(sessionId))).rejects.toMatchObject({
|
||||
code: AUTH_REQUIRED_CODE,
|
||||
});
|
||||
|
||||
const turn = agent.prompt(promptParams(sessionId));
|
||||
await vi.waitFor(() => expect(queries).toHaveLength(2));
|
||||
const choice = agent.setSessionConfigOption({
|
||||
sessionId,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
releaseRespawn();
|
||||
await expect(turn).resolves.toMatchObject({ stopReason: "end_turn" });
|
||||
await choice;
|
||||
|
||||
expect(queries[1].setModel).toHaveBeenCalledWith("opus");
|
||||
expect(queries[1].applyFlagSettings.mock.calls).toEqual([
|
||||
[{ effortLevel: "high" }],
|
||||
[{ effortLevel: "low" }],
|
||||
]);
|
||||
expect(updates).toContainEqual({
|
||||
sessionId,
|
||||
update: expect.objectContaining({ sessionUpdate: "config_option_update" }),
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses with the plain error and no second row when still signed out", async () => {
|
||||
const [agent, updates] = createRecordingAgent(true);
|
||||
hideClaudeAuth();
|
||||
|
||||
796
src/tests/provider-session-configuration.test.ts
Normal file
796
src/tests/provider-session-configuration.test.ts
Normal file
@@ -0,0 +1,796 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ModelInfo, Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import { ClaudeAcpAgent, FAST_MODE_CONFIG_ID, type AcpClient } from "../acp-agent.js";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY } from "../air-extension.js";
|
||||
import { acpProtocolRouter } from "../serve.js";
|
||||
import { initializeClient, makeMockQuery } from "./helpers.js";
|
||||
import { successfulResultMessage, userEcho } from "./session-doubles.js";
|
||||
|
||||
const sdk = vi.hoisted(() => ({
|
||||
query: vi.fn(),
|
||||
getSessionMessages: vi.fn().mockResolvedValue([]),
|
||||
}));
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
...sdk,
|
||||
resolveSettings: async () => ({ effective: {}, sources: [], provenance: {} }),
|
||||
filterEscalatingDefaultMode: (result: any) => result.effective,
|
||||
}));
|
||||
|
||||
function deferred() {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => (resolve = done));
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
const MODELS: ModelInfo[] = ["sonnet", "opus"].map((value) => ({
|
||||
value,
|
||||
displayName: value,
|
||||
description: "",
|
||||
resolvedModel: `claude-${value}-4-6`,
|
||||
supportsAutoMode: true,
|
||||
supportsFastMode: true,
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "xhigh"] as const,
|
||||
}));
|
||||
const PROVIDER = {
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: "https://provider.example",
|
||||
};
|
||||
const AIR_CAPABILITIES = {
|
||||
_meta: {
|
||||
jetbrains: { air: { version: 1, capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY] } },
|
||||
},
|
||||
};
|
||||
|
||||
describe("provider updates preserve session configuration", { timeout: 10_000 }, () => {
|
||||
let agent: ClaudeAcpAgent;
|
||||
let cwd: string;
|
||||
let updates: any[];
|
||||
let queries: ReturnType<typeof createQuery>[];
|
||||
let initializationGate: Promise<void> | undefined;
|
||||
let modelGate: Promise<void> | undefined;
|
||||
let turnGate: Promise<void> | undefined;
|
||||
let missingResume: boolean;
|
||||
let rejectModel: boolean;
|
||||
let agentsAvailable: boolean;
|
||||
let rejectConfigUpdate: boolean;
|
||||
let models: typeof MODELS;
|
||||
let mcpServers: Array<{ name: string; status: string }>;
|
||||
|
||||
function createQuery(request: { options: Options; prompt: AsyncIterable<any> }) {
|
||||
const state = {
|
||||
model: request.options.model ?? "sonnet",
|
||||
effort: request.options.effort ?? null,
|
||||
fastMode: false,
|
||||
ultracode:
|
||||
typeof request.options.settings === "object" &&
|
||||
request.options.settings?.ultracode === true,
|
||||
agent: request.options.agent ?? "default",
|
||||
};
|
||||
const calls: any[] = [];
|
||||
const query = {
|
||||
...makeMockQuery(),
|
||||
request,
|
||||
state,
|
||||
calls,
|
||||
initializationResult: async () => {
|
||||
await initializationGate;
|
||||
if (request.options.resume && missingResume) {
|
||||
throw new Error("No conversation found with session ID");
|
||||
}
|
||||
return { models, fast_mode_state: "off", fast_mode_disabled_reason: "sdk_opt_in_required" };
|
||||
},
|
||||
setModel: vi.fn(async (value: string) => {
|
||||
await modelGate;
|
||||
if (rejectModel) throw new Error("Model switch blocked by a PreModelSwitch hook");
|
||||
state.model = value;
|
||||
}),
|
||||
setPermissionMode: vi.fn(async () => {}),
|
||||
applyFlagSettings: async (flags: any) => {
|
||||
calls.push(flags);
|
||||
if ("effortLevel" in flags) state.effort = flags.effortLevel;
|
||||
if ("fastMode" in flags) state.fastMode = flags.fastMode;
|
||||
if ("ultracode" in flags) state.ultracode = flags.ultracode;
|
||||
if ("agent" in flags) state.agent = flags.agent ?? "default";
|
||||
},
|
||||
supportedAgents: async () =>
|
||||
agentsAvailable ? [{ name: "reviewer", description: "Review" }] : [],
|
||||
getSettings: async () => ({
|
||||
applied: { ultracodeAvailable: true, ultracodeRequested: state.ultracode },
|
||||
}),
|
||||
close: vi.fn(),
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
for await (const message of request.prompt) {
|
||||
calls.push({ prompt: message.message.content, state: { ...state } });
|
||||
yield userEcho(message);
|
||||
// The CLI reports MCP server statuses in the `system`/init of every turn.
|
||||
if (mcpServers.length > 0) {
|
||||
yield { type: "system", subtype: "init", mcp_servers: mcpServers };
|
||||
}
|
||||
await turnGate;
|
||||
yield successfulResultMessage();
|
||||
yield { type: "system", subtype: "status", status: "idle" };
|
||||
}
|
||||
},
|
||||
};
|
||||
const iterator = query[Symbol.asyncIterator]();
|
||||
return Object.assign(query, { next: iterator.next.bind(iterator) });
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
cwd = await mkdtemp(path.join(tmpdir(), "provider-session-configuration-"));
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", cwd);
|
||||
vi.stubEnv("ANTHROPIC_MODEL", undefined);
|
||||
updates = [];
|
||||
queries = [];
|
||||
initializationGate = modelGate = turnGate = undefined;
|
||||
missingResume = rejectModel = false;
|
||||
agentsAvailable = true;
|
||||
rejectConfigUpdate = false;
|
||||
models = MODELS;
|
||||
mcpServers = [];
|
||||
sdk.query.mockImplementation((request) => {
|
||||
const query = createQuery(request);
|
||||
queries.push(query);
|
||||
return query;
|
||||
});
|
||||
sdk.getSessionMessages.mockResolvedValue([]);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () =>
|
||||
Response.json({ data: MODELS.map((model) => ({ id: model.resolvedModel })) }),
|
||||
),
|
||||
);
|
||||
agent = new ClaudeAcpAgent(
|
||||
{
|
||||
sessionUpdate: async (update: any) => {
|
||||
if (rejectConfigUpdate && update.update.sessionUpdate === "config_option_update") {
|
||||
throw new Error("Connection closed");
|
||||
}
|
||||
updates.push(update);
|
||||
},
|
||||
} as AcpClient,
|
||||
{ log() {}, error() {}, warn() {} },
|
||||
);
|
||||
await initializeClient(agent, AIR_CAPABILITIES);
|
||||
await refresh();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await agent.dispose();
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
vi.clearAllMocks();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function refresh() {
|
||||
return agent.unstable_setProvider(PROVIDER);
|
||||
}
|
||||
|
||||
function newSession(meta?: any) {
|
||||
return agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
}
|
||||
|
||||
function select(sessionId: string, configId: string, value: string) {
|
||||
return agent.setSessionConfigOption({ sessionId, configId, value });
|
||||
}
|
||||
|
||||
function setMode(sessionId: string, modeId: string) {
|
||||
return agent.setSessionMode({ sessionId, modeId });
|
||||
}
|
||||
|
||||
function prompt(sessionId: string, text = "hi") {
|
||||
return agent.prompt({ sessionId, prompt: [{ type: "text", text }] });
|
||||
}
|
||||
|
||||
function current(sessionId: string, id: string) {
|
||||
return agent.sessions[sessionId].configOptions.find((option) => option.id === id)?.currentValue;
|
||||
}
|
||||
|
||||
function lastQuery() {
|
||||
return queries.at(-1)!;
|
||||
}
|
||||
|
||||
it("keeps the model and each explicit effort before the first prompt", async () => {
|
||||
const sessions = [];
|
||||
for (const effort of ["high", "low"]) {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", effort);
|
||||
sessions.push(sessionId);
|
||||
}
|
||||
updates.length = 0;
|
||||
await refresh();
|
||||
|
||||
expect(queries.slice(0, 2).every((query) => query.close.mock.calls.length === 1)).toBe(true);
|
||||
expect(queries.slice(2).map((query) => query.request.options.resume)).toEqual(sessions);
|
||||
expect(queries.slice(2).map((query) => [query.state.model, query.state.effort])).toEqual([
|
||||
["opus", "high"],
|
||||
["opus", "low"],
|
||||
]);
|
||||
for (const id of sessions) {
|
||||
expect(updates).toContainEqual({
|
||||
sessionId: id,
|
||||
update: {
|
||||
sessionUpdate: "config_option_update",
|
||||
configOptions: agent.sessions[id].configOptions,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a later model choice instead of the model in the transcript", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await prompt(sessionId);
|
||||
sdk.getSessionMessages.mockResolvedValue([
|
||||
{ type: "assistant", message: { model: "claude-sonnet-4-6" } },
|
||||
]);
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "high");
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(lastQuery().state).toMatchObject({ model: "opus", effort: "high" });
|
||||
expect(current(sessionId, "model")).toBe("opus");
|
||||
});
|
||||
|
||||
it("keeps every choice through the missing-history fallback", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "low");
|
||||
await select(sessionId, "ultracode", "on");
|
||||
await select(sessionId, FAST_MODE_CONFIG_ID, "on");
|
||||
await select(sessionId, "agent", "reviewer");
|
||||
missingResume = true;
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(lastQuery().request.options.sessionId).toBe(sessionId);
|
||||
expect(lastQuery().request.options.resume).toBeUndefined();
|
||||
expect(lastQuery().state).toEqual({
|
||||
model: "opus",
|
||||
effort: "low",
|
||||
ultracode: true,
|
||||
fastMode: true,
|
||||
agent: "reviewer",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps automatic effort unpinned across a later model switch", async () => {
|
||||
const { sessionId } = await newSession({
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: { modelSettings: { "claude-opus-4-6": { effortLevel: "high" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
await refresh();
|
||||
expect(lastQuery().state.effort).toBe("medium");
|
||||
|
||||
await select(sessionId, "model", "opus");
|
||||
|
||||
expect(lastQuery().state.effort).toBe("high");
|
||||
});
|
||||
|
||||
it("keeps legacy pins and does not restore an explicit effort cleared by Default", async () => {
|
||||
agent.clientCapabilities = {};
|
||||
const { sessionId } = await newSession({ claudeCode: { options: { effort: "high" } } });
|
||||
await select(sessionId, "effort", "low");
|
||||
await refresh();
|
||||
expect(lastQuery().state.effort).toBe("low");
|
||||
await select(sessionId, "effort", "default");
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(lastQuery().state.effort).toBeNull();
|
||||
expect(lastQuery().request.options.effort).toBeUndefined();
|
||||
expect(current(sessionId, "effort")).toBe("default");
|
||||
});
|
||||
|
||||
it("drops a creation-time pin that a model switch removed", async () => {
|
||||
models = MODELS.map((model) =>
|
||||
model.value === "opus" ? { ...model, supportedEffortLevels: ["low", "medium"] } : model,
|
||||
);
|
||||
const { sessionId } = await newSession({ claudeCode: { options: { effort: "high" } } });
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "model", "sonnet");
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(current(sessionId, "effort")).toBe("medium");
|
||||
expect(lastQuery().state.effort).toBe("medium");
|
||||
});
|
||||
|
||||
it("reports a supported effort after the catalog removes a pin", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "high");
|
||||
models = MODELS.map((model) => ({ ...model, supportedEffortLevels: ["low", "medium"] }));
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(current(sessionId, "effort")).toBe("medium");
|
||||
expect(lastQuery().state.effort).toBe("medium");
|
||||
});
|
||||
|
||||
it("reports the actual fallback when a model disappears or its switch is refused", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
models = [MODELS[0]];
|
||||
await refresh();
|
||||
expect(current(sessionId, "model")).toBe("sonnet");
|
||||
models = MODELS;
|
||||
await refresh();
|
||||
await select(sessionId, "model", "opus");
|
||||
rejectModel = true;
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(current(sessionId, "model")).toBe("sonnet");
|
||||
expect(lastQuery().state.model).toBe("sonnet");
|
||||
expect(updates.at(-1).update.configOptions).toEqual(agent.sessions[sessionId].configOptions);
|
||||
});
|
||||
|
||||
it("restores Auto once the restored model supports it", async () => {
|
||||
models = MODELS.map((model) =>
|
||||
model.value === "sonnet" ? { ...model, supportsAutoMode: false } : model,
|
||||
);
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await setMode(sessionId, "auto");
|
||||
|
||||
await refresh();
|
||||
await prompt(sessionId);
|
||||
|
||||
expect(agent.sessions[sessionId].modes.currentModeId).toBe("auto");
|
||||
expect(lastQuery().setPermissionMode).toHaveBeenLastCalledWith("auto");
|
||||
expect(JSON.stringify(updates)).not.toContain("Auto mode unavailable");
|
||||
});
|
||||
|
||||
it("keeps the plan return mode and the creation options", async () => {
|
||||
const { sessionId } = await newSession({
|
||||
claudeCode: { options: { maxTurns: 3, promptSuggestions: false } },
|
||||
});
|
||||
await setMode(sessionId, "acceptEdits");
|
||||
await setMode(sessionId, "plan");
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(lastQuery().request.options).toMatchObject({
|
||||
maxTurns: 3,
|
||||
promptSuggestions: false,
|
||||
permissionMode: "plan",
|
||||
});
|
||||
expect(agent.sessions[sessionId].prePlanMode).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it.each(["resumeSession", "loadSession"] as const)(
|
||||
"%s reuses a refreshed session when its creation parameters are unchanged",
|
||||
async (method) => {
|
||||
const meta = { claudeCode: { options: { effort: "high" } } };
|
||||
const { sessionId } = await newSession(meta);
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "low");
|
||||
await refresh();
|
||||
const recreated = agent.sessions[sessionId];
|
||||
const queryCount = queries.length;
|
||||
|
||||
await agent[method]({ sessionId, cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
expect(queries).toHaveLength(queryCount);
|
||||
expect(agent.sessions[sessionId]).toBe(recreated);
|
||||
expect(lastQuery().state).toMatchObject({ model: "opus", effort: "low" });
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps Fast mode on while the current model hides the toggle", async () => {
|
||||
models = [MODELS[0], { ...MODELS[1], supportsFastMode: false }];
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, FAST_MODE_CONFIG_ID, "on");
|
||||
await select(sessionId, "model", "opus");
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(lastQuery().state).toMatchObject({ model: "opus", fastMode: true });
|
||||
await select(sessionId, "model", "sonnet");
|
||||
expect(current(sessionId, FAST_MODE_CONFIG_ID)).toBe("on");
|
||||
});
|
||||
|
||||
it.each([true, false])(
|
||||
"keeps live ultracode %s over opposite initial settings",
|
||||
async (enabled) => {
|
||||
const { sessionId } = await newSession({
|
||||
claudeCode: { options: { settings: { ultracode: !enabled } } },
|
||||
});
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "ultracode", enabled ? "on" : "off");
|
||||
await select(sessionId, "effort", "high");
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(lastQuery().state).toMatchObject({
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
ultracode: enabled,
|
||||
});
|
||||
expect(current(sessionId, "ultracode")).toBe(enabled ? "on" : "off");
|
||||
// An effort change without `ultracode: true` turns ultracode off since CLI 2.1.284.
|
||||
expect(lastQuery().calls.findLast((flags) => "effortLevel" in flags)).toEqual(
|
||||
enabled ? { effortLevel: "high", ultracode: true } : { effortLevel: "high" },
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps a creation-time agent that is absent from the picker", async () => {
|
||||
const { sessionId } = await newSession({ claudeCode: { options: { agent: "Explore" } } });
|
||||
|
||||
await refresh();
|
||||
|
||||
expect(lastQuery().state.agent).toBe("Explore");
|
||||
expect(current(sessionId, "agent")).toBe("default");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ created: "reviewer", picked: [], pickerAvailable: true },
|
||||
{ created: "Explore", picked: [], pickerAvailable: true },
|
||||
{ created: "Explore", picked: ["reviewer"], pickerAvailable: true },
|
||||
{ created: "Explore", picked: [], pickerAvailable: false },
|
||||
])(
|
||||
"keeps Default after clearing $created, picked first: $picked, picker available: $pickerAvailable",
|
||||
async (agents) => {
|
||||
const { sessionId } = await newSession({
|
||||
claudeCode: { options: { agent: agents.created } },
|
||||
});
|
||||
for (const picked of [...agents.picked, "default"]) await select(sessionId, "agent", picked);
|
||||
agentsAvailable = agents.pickerAvailable;
|
||||
// The second refresh starts from the session the first one restored.
|
||||
for (let refreshes = 0; refreshes < 2; refreshes++) {
|
||||
await refresh();
|
||||
expect(lastQuery().state.agent).toBe("default");
|
||||
expect(current(sessionId, "agent")).toBe(agents.pickerAvailable ? "default" : undefined);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("applies a choice sent during the restart after the old choices", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
const held = deferred();
|
||||
initializationGate = held.promise;
|
||||
const update = refresh();
|
||||
await vi.waitFor(() => expect(queries).toHaveLength(2));
|
||||
const choices = [
|
||||
select(sessionId, "model", "opus"),
|
||||
select(sessionId, "effort", "high"),
|
||||
select(sessionId, "ultracode", "on"),
|
||||
];
|
||||
held.resolve();
|
||||
await Promise.all([update, ...choices]);
|
||||
|
||||
expect(queries[1].state).toMatchObject({ model: "opus", effort: "high", ultracode: true });
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ action: "close", phase: "initialization", missingHistory: false },
|
||||
{ action: "dispose", phase: "initialization", missingHistory: false },
|
||||
{ action: "close", phase: "initialization", missingHistory: true },
|
||||
{ action: "close", phase: "restoration", missingHistory: false },
|
||||
{ action: "dispose", phase: "restoration", missingHistory: false },
|
||||
])(
|
||||
"honors $action during provider $phase (missing history: $missingHistory)",
|
||||
async ({ action, phase, missingHistory }) => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "low");
|
||||
const held = deferred();
|
||||
missingResume = missingHistory;
|
||||
if (phase === "initialization") initializationGate = held.promise;
|
||||
else modelGate = held.promise;
|
||||
const update = refresh();
|
||||
try {
|
||||
await vi.waitFor(() => {
|
||||
if (phase === "initialization") expect(queries).toHaveLength(2);
|
||||
else expect(queries[1]?.setModel).toHaveBeenCalledWith("opus");
|
||||
});
|
||||
const closing = action === "close" ? agent.closeSession({ sessionId }) : agent.dispose();
|
||||
held.resolve();
|
||||
await closing;
|
||||
const updateCount = updates.length;
|
||||
await update;
|
||||
|
||||
expect(Object.hasOwn(agent.sessions, sessionId)).toBe(false);
|
||||
expect(queries.every((query) => query.close.mock.calls.length === 1)).toBe(true);
|
||||
expect(updates).toHaveLength(updateCount);
|
||||
} finally {
|
||||
held.resolve();
|
||||
await update;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("removes the old session when provider recreation fails", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
const held = deferred();
|
||||
initializationGate = held.promise.then(() => {
|
||||
throw new Error("Initialization failed");
|
||||
});
|
||||
const update = refresh();
|
||||
try {
|
||||
await vi.waitFor(() => expect(queries).toHaveLength(2));
|
||||
} finally {
|
||||
held.resolve();
|
||||
await update;
|
||||
}
|
||||
|
||||
expect(Object.hasOwn(agent.sessions, sessionId)).toBe(false);
|
||||
expect(queries.every((query) => query.close.mock.calls.length === 1)).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a recreated session when the client misses its restored configuration", async () => {
|
||||
await initializeClient(agent, {
|
||||
_meta: {
|
||||
jetbrains: {
|
||||
air: {
|
||||
version: 1,
|
||||
capabilities: [AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, "sessionFailure"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
rejectConfigUpdate = true;
|
||||
|
||||
await refresh();
|
||||
// The session failure is published without being awaited.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(agent.sessions[sessionId].queryClosed).toBeFalsy();
|
||||
expect(lastQuery().state.model).toBe("opus");
|
||||
expect(updates.filter((update) => update.update._meta?.jetbrains?.air?.sessionFailure)).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
it("restarts a session only after a choice still running on its old query", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
const heldModel = deferred();
|
||||
modelGate = heldModel.promise;
|
||||
const firstChoice = select(sessionId, "model", "opus");
|
||||
await vi.waitFor(() => expect(queries[0].setModel).toHaveBeenCalledWith("opus"));
|
||||
const update = refresh();
|
||||
const lastChoice = select(sessionId, "effort", "low");
|
||||
expect(queries[0].close).not.toHaveBeenCalled();
|
||||
heldModel.resolve();
|
||||
await Promise.all([firstChoice, update, lastChoice]);
|
||||
|
||||
expect(queries[1].state).toMatchObject({ model: "opus", effort: "low" });
|
||||
});
|
||||
|
||||
it("does not hold another session behind a model switch", async () => {
|
||||
const first = await newSession();
|
||||
const second = await newSession();
|
||||
const held = deferred();
|
||||
modelGate = held.promise;
|
||||
const choice = select(first.sessionId, "model", "opus");
|
||||
await vi.waitFor(() => expect(queries[0].setModel).toHaveBeenCalledWith("opus"));
|
||||
try {
|
||||
await setMode(second.sessionId, "acceptEdits");
|
||||
await prompt(second.sessionId);
|
||||
} finally {
|
||||
held.resolve();
|
||||
await choice;
|
||||
}
|
||||
});
|
||||
|
||||
it("allows mode changes while a provider update waits for another session's turn", async () => {
|
||||
const first = await newSession();
|
||||
const second = await newSession();
|
||||
const held = deferred();
|
||||
turnGate = held.promise;
|
||||
const turn = prompt(first.sessionId);
|
||||
await vi.waitFor(() => expect(queries[0].calls.some((call) => call.prompt)).toBe(true));
|
||||
const update = refresh();
|
||||
try {
|
||||
await Promise.all([
|
||||
setMode(second.sessionId, "acceptEdits"),
|
||||
setMode(first.sessionId, "acceptEdits"),
|
||||
]);
|
||||
expect(queries).toHaveLength(2);
|
||||
} finally {
|
||||
held.resolve();
|
||||
await Promise.all([turn, update]);
|
||||
}
|
||||
expect(queries.slice(2).map((query) => query.request.options.permissionMode)).toEqual([
|
||||
"acceptEdits",
|
||||
"acceptEdits",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finishes active and queued turns before the update and holds a later prompt", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "high");
|
||||
const held = deferred();
|
||||
turnGate = held.promise;
|
||||
const first = prompt(sessionId, "first");
|
||||
const second = prompt(sessionId, "queued");
|
||||
await vi.waitFor(() => expect(agent.sessions[sessionId].turnQueue).toHaveLength(2));
|
||||
const update = refresh();
|
||||
const next = prompt(sessionId, "after update");
|
||||
await Promise.resolve();
|
||||
expect(queries[0].close).not.toHaveBeenCalled();
|
||||
held.resolve();
|
||||
await Promise.all([first, second, update, next]);
|
||||
|
||||
expect(queries[0].calls.filter((call) => call.prompt)).toHaveLength(2);
|
||||
expect(queries[1].calls.filter((call) => call.prompt)).toEqual([
|
||||
expect.objectContaining({
|
||||
state: expect.objectContaining({ model: "opus", effort: "high" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a failed client MCP server once across a provider refresh", async () => {
|
||||
mcpServers = [{ name: "client-fs", status: "failed" }];
|
||||
const { sessionId } = await agent.newSession({
|
||||
cwd,
|
||||
mcpServers: [{ name: "client-fs", command: "fs-server", args: [], env: [] }],
|
||||
});
|
||||
await prompt(sessionId);
|
||||
await refresh();
|
||||
|
||||
await prompt(sessionId);
|
||||
|
||||
const notices = updates.filter((update) =>
|
||||
JSON.stringify(update.update).includes('MCP server \\"client-fs\\" failed to start'),
|
||||
);
|
||||
expect(notices).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("forgets the queue of a session once its changes settle", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await expect(select("unknown", "model", "opus")).rejects.toThrow();
|
||||
await refresh();
|
||||
|
||||
await vi.waitFor(() => expect((agent as any).configurationChanges.size).toBe(0));
|
||||
});
|
||||
|
||||
it("reroutes a sign-out replacement already initializing when the provider changes", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "high");
|
||||
agent.sessions[sessionId].needsSignOutRespawn = true;
|
||||
const held = deferred();
|
||||
initializationGate = held.promise;
|
||||
const turn = prompt(sessionId);
|
||||
let update: ReturnType<ClaudeAcpAgent["unstable_setProvider"]> | undefined;
|
||||
try {
|
||||
await vi.waitFor(() => expect(queries).toHaveLength(2));
|
||||
const baseUrl = "https://recovered.example";
|
||||
update = agent.unstable_setProvider({ ...PROVIDER, baseUrl });
|
||||
await vi.waitFor(() => expect(agent.providerConfig?.baseUrl).toBe(baseUrl));
|
||||
held.resolve();
|
||||
await Promise.all([turn, update]);
|
||||
|
||||
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe(baseUrl);
|
||||
expect(lastQuery().state).toMatchObject({ model: "opus", effort: "high" });
|
||||
} finally {
|
||||
held.resolve();
|
||||
await Promise.allSettled([turn, ...(update ? [update] : [])]);
|
||||
}
|
||||
});
|
||||
|
||||
it("holds prompts waiting for sign-out recovery until the provider replacement is ready", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "high");
|
||||
agent.sessions[sessionId].needsSignOutRespawn = true;
|
||||
const signOutHeld = deferred();
|
||||
const providerHeld = deferred();
|
||||
initializationGate = signOutHeld.promise;
|
||||
const first = prompt(sessionId, "first");
|
||||
const second = prompt(sessionId, "queued");
|
||||
let update: ReturnType<ClaudeAcpAgent["unstable_setProvider"]> | undefined;
|
||||
try {
|
||||
await vi.waitFor(() => expect(queries).toHaveLength(2));
|
||||
initializationGate = providerHeld.promise;
|
||||
const baseUrl = "https://recovered.example";
|
||||
update = agent.unstable_setProvider({ ...PROVIDER, baseUrl });
|
||||
await vi.waitFor(() => expect(agent.providerConfig?.baseUrl).toBe(baseUrl));
|
||||
signOutHeld.resolve();
|
||||
await vi.waitFor(() => expect(queries).toHaveLength(3));
|
||||
expect(queries[1].calls.filter((call) => call.prompt)).toEqual([]);
|
||||
expect(queries[2].calls.filter((call) => call.prompt)).toEqual([]);
|
||||
providerHeld.resolve();
|
||||
await Promise.all([first, second, update]);
|
||||
|
||||
expect(agent.sessions[sessionId].query).toBe(lastQuery());
|
||||
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe(baseUrl);
|
||||
expect(lastQuery().calls.filter((call) => call.prompt)).toEqual([
|
||||
{
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
state: expect.objectContaining({ model: "opus", effort: "high" }),
|
||||
},
|
||||
{
|
||||
prompt: [{ type: "text", text: "queued" }],
|
||||
state: expect.objectContaining({ model: "opus", effort: "high" }),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
signOutHeld.resolve();
|
||||
providerHeld.resolve();
|
||||
await Promise.allSettled([first, second, ...(update ? [update] : [])]);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps choices after a failed listing and a later proxy recovery", async () => {
|
||||
const { sessionId } = await newSession();
|
||||
await select(sessionId, "model", "opus");
|
||||
await select(sessionId, "effort", "high");
|
||||
const recovered = { ...PROVIDER, baseUrl: "https://recovered.example" };
|
||||
vi.mocked(fetch).mockResolvedValueOnce(new Response("unavailable", { status: 503 }));
|
||||
await expect(agent.unstable_setProvider(recovered)).rejects.toThrow();
|
||||
expect(queries[0].close).not.toHaveBeenCalled();
|
||||
|
||||
await agent.unstable_setProvider(recovered);
|
||||
|
||||
expect(lastQuery().state).toMatchObject({ model: "opus", effort: "high" });
|
||||
expect(lastQuery().request.options.env?.ANTHROPIC_BASE_URL).toBe("https://recovered.example");
|
||||
});
|
||||
|
||||
it("publishes the restored configuration through the v2 protocol router", async () => {
|
||||
await agent.dispose();
|
||||
const toAgent = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
const toClient = new TransformStream<v2.AnyWireMessage, v2.AnyWireMessage>();
|
||||
acpProtocolRouter({ log() {}, error() {} }, (created) => (agent = created)).connect({
|
||||
readable: toAgent.readable,
|
||||
writable: toClient.writable,
|
||||
});
|
||||
const received: v2.UpdateSessionNotification[] = [];
|
||||
await v2
|
||||
.client({ name: "provider-test" })
|
||||
.onNotification(v2.methods.client.session.update, ({ params }) => void received.push(params))
|
||||
.connectWith({ readable: toClient.readable, writable: toAgent.writable }, async (client) => {
|
||||
await client.request(v2.methods.agent.initialize, {
|
||||
protocolVersion: v2.PROTOCOL_VERSION,
|
||||
info: { name: "provider-test", version: "1" },
|
||||
capabilities: AIR_CAPABILITIES,
|
||||
});
|
||||
await client.request(v2.methods.agent.providers.set, PROVIDER);
|
||||
const { sessionId } = await client.request(v2.methods.agent.session.new, { cwd });
|
||||
for (const [configId, value] of [
|
||||
["model", "opus"],
|
||||
["effort", "high"],
|
||||
]) {
|
||||
await client.request(v2.methods.agent.session.setConfigOption, {
|
||||
sessionId,
|
||||
configId,
|
||||
value,
|
||||
type: "id",
|
||||
});
|
||||
}
|
||||
|
||||
await client.request(v2.methods.agent.providers.set, PROVIDER);
|
||||
|
||||
const config = received.findLast(
|
||||
({ update }) => update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(config?.update).toMatchObject({
|
||||
configOptions: expect.arrayContaining([
|
||||
expect.objectContaining({ configId: "model", currentValue: "opus" }),
|
||||
expect.objectContaining({ configId: "effort", currentValue: "high" }),
|
||||
]),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ describe("session hooks with the Claude Code binary", () => {
|
||||
let agent: ClaudeAcpAgent;
|
||||
let env: Record<string, string | undefined>;
|
||||
let settingsFiles: Record<string, string>;
|
||||
const requests: Array<{ model: string; output_config?: { effort?: string } }> = [];
|
||||
let logCursor = 0;
|
||||
|
||||
/** A SessionStart hook that writes `tag` to the log. */
|
||||
@@ -111,12 +112,14 @@ describe("session hooks with the Claude Code binary", () => {
|
||||
vi.stubEnv("CLAUDE_CONFIG_DIR", path.join(home, ".claude"));
|
||||
|
||||
server = http.createServer((req, res) => {
|
||||
req.resume();
|
||||
const chunks: Buffer[] = [];
|
||||
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
req.on("end", () => {
|
||||
if (req.url?.startsWith("/v1/messages/count_tokens")) {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ input_tokens: 1 }));
|
||||
} else if (req.url?.startsWith("/v1/messages")) {
|
||||
requests.push(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
res.end(REPLY);
|
||||
} else {
|
||||
@@ -150,6 +153,65 @@ describe("session hooks with the Claude Code binary", () => {
|
||||
if (root) await rm(root, { recursive: true, force: true, maxRetries: 10 });
|
||||
});
|
||||
|
||||
it("preserves current settings and hooks after a provider refresh", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response("not found", { status: 404 })),
|
||||
);
|
||||
const sessions: string[] = [];
|
||||
const provider = {
|
||||
providerId: "main",
|
||||
apiType: "anthropic",
|
||||
baseUrl: env!.ANTHROPIC_BASE_URL!,
|
||||
};
|
||||
try {
|
||||
await agent.unstable_setProvider(provider);
|
||||
for (const persisted of [true, false]) {
|
||||
const { sessionId } = await agent.newSession({ cwd, mcpServers: [], _meta: meta("ide") });
|
||||
sessions.push(sessionId);
|
||||
if (persisted) {
|
||||
await finishTurn(sessionId);
|
||||
await hooksSinceLastCheck();
|
||||
}
|
||||
const session = agent.sessions[sessionId];
|
||||
const target = session.modelInfos.find(
|
||||
(model) => model.value !== session.models.currentModelId && model.supportsEffort,
|
||||
);
|
||||
expect(target).toBeDefined();
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "model", value: target!.value });
|
||||
await agent.setSessionConfigOption({ sessionId, configId: "effort", value: "high" });
|
||||
}
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
expect((await readFile(log, "utf8")).split("\n").filter(Boolean)).toHaveLength(6);
|
||||
});
|
||||
expect(await hooksSinceLastCheck()).toEqual(["ide", "project", "user"]);
|
||||
await agent.unstable_setProvider(provider);
|
||||
|
||||
for (const sessionId of sessions) {
|
||||
const session = agent.sessions[sessionId];
|
||||
const model = session.modelInfos.find(
|
||||
(info) => info.value === session.models.currentModelId,
|
||||
)!;
|
||||
await finishTurn(sessionId);
|
||||
expect(requests.at(-1)?.model).toBe(model.resolvedModel ?? model.value);
|
||||
expect(requests.at(-1)?.output_config?.effort).toBe("high");
|
||||
}
|
||||
expect(await hooksSinceLastCheck()).toEqual([
|
||||
"ide",
|
||||
"ide",
|
||||
"project",
|
||||
"project",
|
||||
"user",
|
||||
"user",
|
||||
]);
|
||||
} finally {
|
||||
await Promise.all(sessions.map((sessionId) => agent.closeSession({ sessionId })));
|
||||
await agent.unstable_disableProvider({ providerId: "main" });
|
||||
vi.unstubAllGlobals();
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
// With CLAUDE_MODEL_CONFIG, the adapter passes the merged settings inline.
|
||||
for (const modelConfig of [
|
||||
undefined,
|
||||
|
||||
Reference in New Issue
Block a user