diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 264b201c..54d71338 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -362,6 +362,7 @@ import { SessionModeManager, } from "./session-mode.js"; import { + aliasResumedModelRow, applyAvailableModelsAllowlist, buildModelConfigOption, getAvailableModels, @@ -12519,14 +12520,21 @@ export class ClaudeAcpAgent { // configured. Managed `deniedModels` drops entries the CLI would refuse. const settingsAvailableModels = settingsManager.getSettings().availableModels; const settingsModelOverrides = settingsManager.getSettings().modelOverrides; + const resumedModel = await resumedModelHint; + // A resumed Fable 5.1 session lists Fable 5.1 as `fable`, as a new + // session does (see `aliasResumedModelRow`). + const sdkModels = aliasResumedModelRow( + initializationResult.models, + creationOpts.resume !== undefined ? resumedModel : undefined, + ); const settingsAllowedModels = Array.isArray(settingsAvailableModels) ? applyAvailableModelsAllowlist( - initializationResult.models, + sdkModels, settingsAvailableModels, settingsModelOverrides, settingsManager.getManagedDeniedModels(), ) - : initializationResult.models; + : sdkModels; // Then keep only the models the `providers/set` gateway listed. const allowedModels = resolvedProvider?.listedModels ? narrowToGatewayModels( @@ -12544,7 +12552,7 @@ export class ClaudeAcpAgent { this.logger, creationOpts.resume !== undefined, sessionId, - await resumedModelHint, + resumedModel, resolvedProvider?.listedModels !== undefined, ); timing.phase("models"); @@ -12560,7 +12568,7 @@ export class ClaudeAcpAgent { const allowlistedModelInfo = allowedModels.find((m) => m.value === models.currentModelId); const fallbackModelInfo = allowlistedModelInfo ? undefined - : (resolveModelPreference(initializationResult.models, models.currentModelId) ?? undefined); + : (resolveModelPreference(sdkModels, models.currentModelId) ?? undefined); const currentModelInfo = allowlistedModelInfo ?? fallbackModelInfo; // Register the fallback-resolved capabilities under the verbatim live id // so every modelInfos consumer (buildConfigOptions' effort lookup, later diff --git a/src/session-model.ts b/src/session-model.ts index 5da5ecce..8b860062 100644 --- a/src/session-model.ts +++ b/src/session-model.ts @@ -281,6 +281,66 @@ function resolveSettingsModel( const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]); +/** The family and version of a `claude--[-]` id: + * `claude-fable-5-1` is fable 5.1, `claude-haiku-4-5-20251001` haiku 4.5. */ +function familyVersionOf(id: string): { family: string; major: number; minor: number } | null { + const match = id.toLowerCase().match(/^claude-([a-z]+)-(\d+)(?:-(\d{1,2}))?(?![\d.])/); + if (!match) return null; + return { family: match[1], major: Number(match[2]), minor: Number(match[3] ?? 0) }; +} + +/** + * Give back the family alias value to the row the CLI lists under a resumed + * session's model id. + * + * The CLI lists Fable 5.1 as `fable`, but when it resumes a session that ran + * Fable 5.1 it lists the same row as `claude-fable-5-1` and has no `fable` + * row. Clients store the option value as the session's choice, so a value + * from a new session would not be offered by a resumed one, and the reverse. + * + * A row gets its family's alias as its value when its `value` is + * `resumedModel`, equals its own `resolvedModel` and has no context hint, no + * row has that alias, and no row of the family has a newer version (an alias + * runs its family's newest model). Every other row is returned as it is. + * + * The CLI lists a row the same way when `ANTHROPIC_MODEL` or `settings.model` + * pins `claude-fable-5-1`. Those rows keep the pinned id, so a client's choice + * stays on that version after a newer Fable becomes `fable`. + */ +export function aliasResumedModelRow( + models: ModelInfo[], + resumedModel: string | undefined, +): ModelInfo[] { + if (resumedModel === undefined) return models; + const resumed = canonicalizeModelId(resumedModel); + const values = new Set(models.map((model) => model.value)); + const versions = models.flatMap((model) => { + const version = familyVersionOf(model.resolvedModel ?? model.value); + return version ? [version] : []; + }); + return models.map((model) => { + const version = familyVersionOf(model.value); + if ( + version === null || + !MODEL_FAMILY_ALIASES.has(version.family) || + values.has(version.family) || + model.resolvedModel !== model.value || + contextHintOf(model.value) !== null || + canonicalizeModelId(model.value) !== resumed || + versions.some( + (other) => + other.family === version.family && + (other.major > version.major || + (other.major === version.major && other.minor > version.minor)), + ) + ) { + return model; + } + values.add(version.family); + return { ...model, value: version.family }; + }); +} + /** A model id without the spellings that name the same model: provider * prefixes, dates, version suffixes, `:variant` suffixes, dotted versions, * `-fast` and context hints. `deniedModels` and gateway listings both match on diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index 0a4f446b..cf62d4f6 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -30,22 +30,22 @@ describe("ClaudeAcpAgent settings", () => { } as unknown as AcpClient; } - function mockQuery() { + function mockQuery( + models: unknown[] = [ + { + value: "claude-sonnet-4-6", + displayName: "Claude Sonnet 4.5", + description: "Default", + supportsAutoMode: true, + }, + ], + ) { let capturedOptions: any; const setModelSpy = vi.fn(); querySpy.mockImplementation(({ options }: any) => { capturedOptions = options; return makeMockQuery({ - initializationResult: async () => ({ - models: [ - { - value: "claude-sonnet-4-6", - displayName: "Claude Sonnet 4.5", - description: "Default", - supportsAutoMode: true, - }, - ], - }), + initializationResult: async () => ({ models }), setModel: setModelSpy, }) as any; }); @@ -432,7 +432,7 @@ describe("ClaudeAcpAgent settings", () => { describe("on resume", () => { const sessionId = "resumed-mode-session"; - async function setup(records: unknown[]) { + async function setup(records: unknown[], models?: unknown[]) { await fs.promises.writeFile( path.join(tempDir, "settings.json"), JSON.stringify({ permissions: { defaultMode: "plan" } }), @@ -446,11 +446,11 @@ describe("ClaudeAcpAgent settings", () => { records.map((record) => JSON.stringify(record)).join("\n") + "\n", ); - const { getCapturedOptions } = mockQuery(); + const { getCapturedOptions, setModelSpy } = mockQuery(models); const { ClaudeAcpAgent } = await import("../acp-agent.js"); const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); (agent as any).logger = { log: () => {}, error: () => {} }; - return { agent, projectDir, getCapturedOptions }; + return { agent, projectDir, getCapturedOptions, setModelSpy }; } async function resume(records: unknown[], meta: object = {}) { @@ -479,6 +479,26 @@ describe("ClaudeAcpAgent settings", () => { previousUuid = uuid; return record; }; + const assistant = (model: string) => { + const uuid = crypto.randomUUID(); + const record = { + type: "assistant", + uuid, + parentUuid: previousUuid, + sessionId, + timestamp: new Date().toISOString(), + isSidechain: false, + message: { + id: `msg_${uuid}`, + type: "message", + role: "assistant", + model, + content: [{ type: "text", text: "ok" }], + }, + }; + previousUuid = uuid; + return record; + }; it("continues in the last permission mode of the transcript", async () => { const { permissionMode, response } = await resume([user("plan"), user("acceptEdits")]); @@ -513,6 +533,41 @@ describe("ClaudeAcpAgent settings", () => { expect(response.modes.currentModeId).toBe("plan"); }); + it("offers Fable 5.1 as `fable` when the CLI resumed on it", async () => { + // The CLI names the Fable 5.1 row after its startup model, which on + // resume is the transcript's model; a fresh session lists it as `fable`. + const fable = (value: string, resolvedModel: string, displayName: string) => ({ + value, + resolvedModel, + displayName, + description: "", + }); + const { agent, projectDir, setModelSpy } = await setup( + [user(), assistant("claude-fable-5-1")], + [ + fable("default", "claude-opus-5-5", "Default (recommended)"), + fable("claude-fable-5-1", "claude-fable-5-1", "Fable 5.1"), + fable("claude-fable-5", "claude-fable-5", "Fable 5"), + ], + ); + + const response = await (agent as any).createSession( + { cwd: projectDir, mcpServers: [], _meta: { disableBuiltInTools: true } }, + { resume: sessionId }, + ); + + const model = response.configOptions.find((option: any) => option.id === "model"); + expect(model).toMatchObject({ + currentValue: "fable", + options: [ + { value: "default" }, + { value: "fable", name: "Fable 5.1" }, + { value: "claude-fable-5", name: "Fable 5" }, + ], + }); + expect(setModelSpy).not.toHaveBeenCalled(); + }); + it("continues in the last permission mode on session/load", async () => { const { agent, projectDir, getCapturedOptions } = await setup([ user("plan"), diff --git a/src/tests/model-resolution.test.ts b/src/tests/model-resolution.test.ts index 95556df2..2476d9e8 100644 --- a/src/tests/model-resolution.test.ts +++ b/src/tests/model-resolution.test.ts @@ -4,6 +4,7 @@ import { query } from "@anthropic-ai/claude-agent-sdk"; import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable"; import { + aliasResumedModelRow, resolveModelPreference, applyAvailableModelsAllowlist, isDeniedModel, @@ -196,6 +197,52 @@ describe("matchResumedModel", () => { }); }); +describe("aliasResumedModelRow", () => { + const row = (value: string, resolvedModel = value): ModelInfo => ({ + value, + resolvedModel, + displayName: value, + description: "", + }); + // What the CLI lists after resuming a session on `claude-fable-5-1`. + const STARTED_ON_FABLE = [ + row("default", "claude-opus-5-5"), + row("claude-fable-5-1"), + row("claude-fable-5"), + ]; + + it("lists the resumed model's row under its family alias", () => { + expect(aliasResumedModelRow(STARTED_ON_FABLE, "claude-fable-5-1").map((m) => m.value)).toEqual([ + "default", + "fable", + "claude-fable-5", + ]); + }); + + it.each([ + [ + "the alias already has a row", + [row("fable", "claude-fable-5-1"), row("claude-fable-5")], + "claude-fable-5", + ], + ["the row is not the resumed model", STARTED_ON_FABLE, "claude-opus-5-5"], + ["the session is not resumed", STARTED_ON_FABLE, undefined], + [ + "the family has a newer row", + [row("claude-fable-5-1"), row("claude-fable-5")], + "claude-fable-5", + ], + [ + "the row carries a context hint", + [row("claude-fable-5-1[1m]", "claude-fable-5-1")], + "claude-fable-5-1[1m]", + ], + ["the row is not a family model", [row("claude-gpt-9")], "claude-gpt-9"], + ])("keeps every value when %s", (_case, models, resumedModel) => { + expect(aliasResumedModelRow(models, resumedModel)).toEqual(models); + }); +}); + describe("applyAvailableModelsAllowlist - resolvedModel matching", () => { it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => { const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);