mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-09 19:02:05 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
Reference in New Issue
Block a user