diff --git a/docs/v2-native-core.md b/docs/v2-native-core.md index 1585f56c..c74373eb 100644 --- a/docs/v2-native-core.md +++ b/docs/v2-native-core.md @@ -201,7 +201,11 @@ the history cannot place stays unexposed, as the RFD forbids guessing a parent. that does not support it. A model switch that the adapter did not make (`PostModelSwitch`, such as a typed `/model`, or a refusal fallback) updates the options. Claude Code's `fast_mode_state` (on `init` and on results of prompts) updates the Fast mode option, with a - notice when it turns off a Fast mode the user had on. + notice when it turns off a Fast mode the user had on. A value that names no entry is rejected + with -32602 ("Invalid value for config option model: not-a-model") and changes nothing, as on + the bridge and v1, which share `configOptionValue`. A model alias names an entry only by whole + words (`resolveModelPreference`): `opus`, `opusplan`, `claude-sonnet-5[1m]` and provider ids + such as `us.anthropic.claude-sonnet-5-v1:0` do, `not-a-model`, `son` and `foo-sonnet` do not. - `src/v2-native/clear-context.ts`: ExitPlanMode's "Yes, clear context" option, offered to the root's plan as on the bridge. The answer interrupts the cycle, and the turn stays open: the session sends Claude Code `/clear`, then sets the chosen mode and sends "Implement the following diff --git a/src/session-model.ts b/src/session-model.ts index f338afad..b2fdbf5d 100644 --- a/src/session-model.ts +++ b/src/session-model.ts @@ -84,35 +84,54 @@ function versionedModelDisplayName(model: ModelInfo): string { return `${withoutContext} ${version}${contextSuffix[0]}`; } -function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } { - const lower = model.trim().toLowerCase(); - const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase(); - const tokens = lower - .replace(MODEL_CONTEXT_HINT_PATTERN, " $1 ") +function modelWords(value: string): string[] { + return value + .toLowerCase() .split(/[^a-z0-9]+/) - .filter(Boolean) - .map((token) => { - if (token === "opusplan") return "opus"; - if (token === "best" || token === "default") return ""; - return token; - }) - .filter((token) => token && token !== "claude") - .filter((token) => /[a-z]/.test(token) || token.endsWith("m")); - return { tokens, contextHint }; + .filter(Boolean); } -function scoreModelMatch(model: ModelInfo, tokens: string[], contextHint?: string): number { - const haystack = `${model.value} ${model.displayName}`.toLowerCase(); - let score = 0; - let nonHintMatched = false; - for (const token of tokens) { - if (!haystack.includes(token)) continue; - if (token !== contextHint) nonHintMatched = true; - score += token === contextHint ? 3 : 1; +/** Whether `needle`'s words appear in `haystack`'s words, adjacent and in + * order: "opus" in "claude-opus-4-5", but not "son" in "sonnet" or "a" in + * "Default (recommended)". */ +function containsWords(haystack: string, needle: string): boolean { + const words = modelWords(haystack); + const wanted = modelWords(needle); + if (wanted.length === 0) return false; + for (let start = 0; start + wanted.length <= words.length; start++) { + if (wanted.every((word, offset) => words[start + offset] === word)) return true; } - return contextHint && !nonHintMatched ? 0 : score; + return false; } +/** The words of `preference` that name a model: without what + * `normalizeModelId` drops (provider prefixes, dates, `-v1:0`, `-fast`, + * context hints), version numbers, "claude", and "best"/"default". + * "us.anthropic.claude-sonnet-4-5-v1:0" gives ["sonnet"], "opusplan" gives + * ["opus"]. */ +function modelPreferenceTokens(preference: string): string[] { + return modelWords(normalizeModelId(preference)) + .map((token) => (token === "opusplan" ? "opus" : token)) + .filter((token) => /[a-z]/.test(token) && !["claude", "best", "default"].includes(token)); +} + +/** Whether every one of `tokens` is a word of `model`'s value or display name. */ +function modelCoversTokens(model: ModelInfo, tokens: string[]): boolean { + const words = new Set(modelWords(`${model.value} ${model.displayName}`)); + return tokens.length > 0 && tokens.every((token) => words.has(token)); +} + +/** + * The entry of `models` that `preference` names, or null when it names none. + * In order: an entry whose value, display name or `resolvedModel` equals it + * (in either context-hint spelling); an entry whose value or display name + * contains its words ("opus" for "claude-opus-4-5"), or whose value it + * contains while every model word of the preference is the entry's + * ("sonnet" for "us.anthropic.claude-sonnet-5-v1:0"); then any entry with all + * of its model words, preferring one with the same context hint ("sonnet[1m]" + * for "claude-sonnet-5-1m"). The fuzzy tiers need a compatible version, and + * words match whole: "not-a-model", "son" and "foo-sonnet" name no entry. + */ export function resolveModelPreference(models: ModelInfo[], preference: string): ModelInfo | null { const trimmed = preference.trim(); if (!trimmed) return null; @@ -134,28 +153,26 @@ export function resolveModelPreference(models: ModelInfo[], preference: string): if (resolvedMatch) return resolvedMatch; const preferenceHint = contextHintOf(trimmed); + const tokens = modelPreferenceTokens(trimmed); const includesMatch = models.find((model) => { if (!modelVersionsCompatible(trimmed, model)) return false; if (contextHintOf(model.value) !== preferenceHint) return false; - const value = model.value.toLowerCase(); - const display = model.displayName.toLowerCase(); - return value.includes(lower) || display.includes(lower) || lower.includes(value); + return ( + containsWords(model.value, trimmed) || + containsWords(model.displayName, trimmed) || + (containsWords(trimmed, model.value) && modelCoversTokens(model, tokens)) + ); }); if (includesMatch) return includesMatch; - const { tokens, contextHint } = tokenizeModelPreference(trimmed); - if (tokens.length === 0) return null; - let bestMatch: ModelInfo | null = null; - let bestScore = 0; - for (const model of models) { - if (!modelVersionsCompatible(trimmed, model)) continue; - const score = scoreModelMatch(model, tokens, contextHint); - if (score > 0 && (!bestMatch || score > bestScore)) { - bestMatch = model; - bestScore = score; - } - } - return bestMatch; + const candidates = models.filter( + (model) => modelVersionsCompatible(trimmed, model) && modelCoversTokens(model, tokens), + ); + return ( + candidates.find((model) => contextHintOf(model.value) === preferenceHint) ?? + candidates[0] ?? + null + ); } export function matchResumedModel(models: ModelInfo[], liveModel: string): ModelInfo { diff --git a/src/tests/acp-v2.test.ts b/src/tests/acp-v2.test.ts index d8f4278c..15c73243 100644 --- a/src/tests/acp-v2.test.ts +++ b/src/tests/acp-v2.test.ts @@ -963,21 +963,67 @@ describe("ACP v2 sessions", () => { }); it.each([ - ["a select value for a boolean option", { configId: "fast", type: "id", value: "on" }], - ["a boolean for a select option", { configId: "effort", type: "boolean", value: true }], - ["a level the model does not offer", { configId: "effort", type: "id", value: "max" }], - ] as const)("rejects %s", async (_case, request) => { - const client = v2Client(); - await client.app.connectWith(connectRouter(), async (agent) => { - await initializeV2(agent); - const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd }); - await expect( - agent.request(v2.methods.agent.session.setConfigOption, { sessionId, ...request }), - ).rejects.toMatchObject({ code: -32602 }); - await client.authUpdate(1); - }); - expect(sdk.flagSettings).toEqual([]); - }); + [ + "a select value for a boolean option", + { configId: "fast", type: "id", value: "on" }, + 'Config option fast takes a boolean value, but received: "on"', + ], + [ + "a boolean for a select option", + { configId: "effort", type: "boolean", value: true }, + "Config option effort takes a string value, but received: true", + ], + [ + "a level the model does not offer", + { configId: "effort", type: "id", value: "max" }, + "Invalid value for config option effort: max", + ], + // "a" is a substring of "claude": a substring match would pick Opus. + [ + "a model the session does not offer", + { configId: "model", type: "id", value: "not-a-model" }, + "Invalid value for config option model: not-a-model", + ], + [ + "a mode the session does not offer", + { configId: "mode", type: "id", value: "not-a-mode" }, + "Invalid value for config option mode: not-a-mode", + ], + [ + "an agent the session does not offer", + { configId: "agent", type: "id", value: "not-an-agent" }, + "Invalid value for config option agent: not-an-agent", + ], + ] as const)( + "rejects %s and leaves the options as they were", + async (_case, request, message) => { + const models: Array = []; + sdk.setModel = async (model) => { + models.push(model); + }; + const client = v2Client(); + const { created, listed } = await client.app.connectWith(connectRouter(), async (agent) => { + await initializeV2(agent); + const created = await agent.request(v2.methods.agent.session.new, { cwd }); + const { sessionId } = created; + await expect( + agent.request(v2.methods.agent.session.setConfigOption, { sessionId, ...request }), + ).rejects.toMatchObject({ code: -32602, message: expect.stringContaining(message) }); + // Setting a valid value again returns every option's current value. + const listed = await agent.request(v2.methods.agent.session.setConfigOption, { + sessionId, + configId: "agent", + type: "id", + value: "default", + }); + await client.authUpdate(1); + return { created, listed }; + }); + expect(sdk.flagSettings).toEqual([{ agent: null }]); + expect(models).toEqual([]); + expect(values(listed.configOptions)).toEqual(values(created.configOptions)); + }, + ); it("rebuilds the options for a model switch, and clears an effort the new model lacks", async () => { const client = v2Client(); diff --git a/src/tests/model-resolution.test.ts b/src/tests/model-resolution.test.ts index 95556df2..088585f6 100644 --- a/src/tests/model-resolution.test.ts +++ b/src/tests/model-resolution.test.ts @@ -73,8 +73,26 @@ describe("resolveModelPreference - resolvedModel matching", () => { expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]"); }); - it("returns null for a preference that matches nothing", () => { - expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull(); + // Words match whole: "a" is inside "Default", "son" inside "sonnet". + it.each(["claude-gpt-99", "not-a-model", "a", "son", "foo-sonnet", "not-default-model", "best"])( + "returns null for %s, which names no model", + (preference) => { + expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)).toBeNull(); + }, + ); + + it.each([ + ["us.anthropic.claude-sonnet-5-v1:0", "sonnet"], + ["global.anthropic.claude-haiku-4-5-20251001-v1:0", "haiku"], + ["anthropic/claude-sonnet-5:batch", "sonnet"], + ["claude-opus-4-8@20260101", "opus[1m]"], + ["claude-sonnet-5-fast", "sonnet"], + ["claude-haiku-4-5", "haiku"], + ["opusplan", "opus[1m]"], + ["opus", "opus[1m]"], + ["Sonnet 5", "sonnet"], + ])("resolves the spelling %s to %s", (preference, value) => { + expect(resolveModelPreference(LIVE_SHAPED_MODELS, preference)?.value).toBe(value); }); it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => { diff --git a/src/tests/session-config-options.test.ts b/src/tests/session-config-options.test.ts index 71795041..9c48623e 100644 --- a/src/tests/session-config-options.test.ts +++ b/src/tests/session-config-options.test.ts @@ -330,18 +330,28 @@ describe("session config options", () => { expect(modelOption?.currentValue).toBe("claude-sonnet-4-6"); }); - it("throws for completely invalid model value", async () => { - await expect( - agent.setSessionConfigOption({ - sessionId: SESSION_ID, - configId: "model", - value: "gpt-4", - }), - ).rejects.toMatchObject({ - code: -32602, - message: expect.stringContaining("Invalid value for config option model: gpt-4"), - }); - }); + // "a" is a substring of "Claude", "son" of "sonnet": only whole words match. + it.each(["gpt-4", "not-a-model", "son", "foo-sonnet"])( + "rejects the model %s and leaves the model as it was", + async (value) => { + await expect( + agent.setSessionConfigOption({ sessionId: SESSION_ID, configId: "model", value }), + ).rejects.toMatchObject({ + code: -32602, + message: expect.stringContaining(`Invalid value for config option model: ${value}`), + }); + + expect(setModelSpy).not.toHaveBeenCalled(); + const session = ( + agent as unknown as { + sessions: Record; + } + ).sessions[SESSION_ID]; + expect(session.configOptions.find((o) => o.id === "model")?.currentValue).toBe( + "claude-opus-4-5", + ); + }, + ); it("rejects a non-string value for a select option with invalid params", async () => { await expect(