mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 03:41:17 +00:00
fix: reject a set_config_option model that names no entry with -32602
resolveModelPreference matched preference tokens as substrings, so
"not-a-model" resolved to Default ("a" is inside "default") and
"son" or "foo-sonnet" to Sonnet. configOptionValue, which v1, the v2
bridge and the native v2 core share, then accepted the value. Words now
match whole, and the fuzzy tiers need every model word of the
preference; provider ids, dates, -fast and context hints are dropped
with normalizeModelId first, so aliases and provider spellings still
resolve.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string | undefined> = [];
|
||||
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();
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, { configOptions: typeof MOCK_CONFIG_OPTIONS }>;
|
||||
}
|
||||
).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(
|
||||
|
||||
Reference in New Issue
Block a user