fix: list a resumed Fable 5.1 session's model as fable (#1548)

## Problem

The Claude Code CLI gives Fable 5.1 a different option value depending
on how the session starts:

- **New session:** listed as `fable` (`resolvedModel:
"claude-fable-5-1"`).
- **Resumed session whose transcript last ran Fable 5.1:** the same row
is listed as `claude-fable-5-1`, and there is no `fable` row.

Reproduced with CLI 2.1.310. Other families don't do this: a session
resumed on `claude-opus-5-5` still lists `opus`.

Clients store the option value as the session's choice. A choice saved
in a new session (`fable`) is not among the options of a resumed one,
and the reverse. A client that checks its saved value against the
options then refuses to apply it, and the model choice keeps flipping
between the two ids.

## Fix

`aliasResumedModelRow` in `session-model.ts` gives the resumed model's
row its family alias back. It changes a row only when:

- the row's value is the model read from the resumed transcript, equals
its own `resolvedModel` and has no context hint,
- no row already has the alias, and
- no newer version of the family is listed, since the alias runs the
family's newest model.

`createSession` applies it before the allowlist and model selection, so
new, resumed and loaded sessions over ACP v1 and v2 all get the same
list.

This is a workaround for the CLI listing the row under a different
value. The CLI also lists rows this way when `ANTHROPIC_MODEL` or
`settings.model` pins `claude-fable-5-1`. Those rows keep the pinned id
on purpose: relabelling them would turn the user's pinned version into
the alias, which moves to a newer Fable when one ships.

## Tests

- `acp-agent-settings.test.ts`: a session resumed on `claude-fable-5-1`
offers `fable` as the current value and option, with no `setModel` call.
Fails without the fix.
- `model-resolution.test.ts`: unit cases for the relabel and for each
case that keeps the list as it is.
- Checked end to end with the real CLI: a Fable turn, then
`session/load`, reports `fable`.
This commit is contained in:
Ben Brandt
2026-11-09 13:47:11 +01:00
committed by GitHub
parent ef65ab31b4
commit 4cceb06b31
4 changed files with 188 additions and 18 deletions

View File

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

View File

@@ -281,6 +281,66 @@ function resolveSettingsModel(
const MODEL_FAMILY_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]);
/** The family and version of a `claude-<family>-<major>[-<minor>]` 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

View File

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

View File

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