From e1e1c69029ef08579c841e6fbdffb32a5b94df06 Mon Sep 17 00:00:00 2001 From: Ben Brandt Date: Thu, 21 May 2026 22:49:29 +0200 Subject: [PATCH] fix: Avoid cross-version model alias matches (#702) Partially addresses #639 Deduping may reintroduce issues from #620 so going to hold off on that for now. --- src/acp-agent.ts | 23 +++++++++++ src/tests/acp-agent-settings.test.ts | 62 ++++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/acp-agent.ts b/src/acp-agent.ts index c9d15fe4..53c9a311 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -2413,6 +2413,27 @@ function buildConfigOptions( // but the SDK model list uses IDs like "claude-opus-4-6-1m". const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i; +// Captures a model family version such as `4-6` or `4.7` so we can keep +// `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that +// alias currently resolves to a different family version (e.g. Opus 4.7). +const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/; + +function extractModelFamilyVersion(s: string): string | null { + const match = s.match(MODEL_FAMILY_VERSION_PATTERN); + return match ? `${match[1]}.${match[2]}` : null; +} + +function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean { + const preferred = extractModelFamilyVersion(preference); + if (!preferred) return true; + const candidateVersion = + extractModelFamilyVersion(candidate.value) ?? + extractModelFamilyVersion(candidate.displayName) ?? + extractModelFamilyVersion(candidate.description); + if (!candidateVersion) return true; + return preferred === candidateVersion; +} + function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } { const lower = model.trim().toLowerCase(); const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase(); @@ -2459,6 +2480,7 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI // Substring match const includesMatch = models.find((model) => { + if (!modelVersionsCompatible(trimmed, model)) return false; const value = model.value.toLowerCase(); const display = model.displayName.toLowerCase(); return value.includes(lower) || display.includes(lower) || lower.includes(value); @@ -2472,6 +2494,7 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI 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 (0 < score && (!bestMatch || bestScore < score)) { bestMatch = model; diff --git a/src/tests/acp-agent-settings.test.ts b/src/tests/acp-agent-settings.test.ts index bef9d7d1..9d357783 100644 --- a/src/tests/acp-agent-settings.test.ts +++ b/src/tests/acp-agent-settings.test.ts @@ -578,6 +578,68 @@ describe("ClaudeAcpAgent settings", () => { expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5"); expect(response.models.currentModelId).toBe("claude-haiku-4-5"); }); + + it("does not inherit display info across mismatched model family versions", async () => { + // https://github.com/agentclientprotocol/claude-agent-acp/issues/639: + // when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry + // of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match + // `opus` and inherit the "Opus 4.7" display info. With version-aware + // matching, these entries fall back to showing their literal ID rather + // than a misleading newer name. + await fs.promises.writeFile( + path.join(tempDir, "settings.json"), + JSON.stringify({ + availableModels: [ + "claude-opus-4-6", + "claude-opus-4-6[1m]", + "claude-opus-4-7", + "claude-opus-4-7[1m]", + ], + }), + ); + + const projectDir = path.join(tempDir, "project"); + await fs.promises.mkdir(projectDir, { recursive: true }); + + mockQueryWithModels([ + { value: "default", displayName: "Default", description: "Default model" }, + { + value: "opus", + displayName: "Opus 4.7", + description: "Claude Opus 4.7 — complex tasks, higher cost", + }, + { + value: "opus[1m]", + displayName: "Opus 4.7 (1M context)", + description: "Opus 4.7 with 1M context", + }, + ]); + + const { ClaudeAcpAgent } = await import("../acp-agent.js"); + const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient()); + + const response = await (agent as any).createSession({ + cwd: projectDir, + mcpServers: [], + _meta: { disableBuiltInTools: true }, + }); + + const modelOption = response.configOptions.find((o: any) => o.id === "model"); + const byValue: Record = {}; + for (const opt of modelOption.options) { + byValue[opt.value] = { name: opt.name, description: opt.description }; + } + + // 4-6 entries must NOT inherit the 4.7 SDK alias display info. + expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6"); + expect(byValue["claude-opus-4-6"].description).toBe(""); + expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]"); + expect(byValue["claude-opus-4-6[1m]"].description).toBe(""); + + // 4-7 entries continue to inherit display info from a 4.7 SDK alias. + expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7"); + expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/); + }); }); it("resolves model aliases like opus[1m] to the correct model", async () => {