diff --git a/src/ModelApp.tsx b/src/ModelApp.tsx index ff6f8ea..6938538 100644 --- a/src/ModelApp.tsx +++ b/src/ModelApp.tsx @@ -347,13 +347,29 @@ export function ModelApp() { )} {phase === "done" && chosenModel && ( - - {"Default model updated to "} - {chosenModel} - {" for "} - {formatToolList(tools.map((t) => TOOL_LABEL[t]))} - {"."} - + + + {"Default model updated to "} + {chosenModel} + {" for "} + {formatToolList(tools.map((t) => TOOL_LABEL[t]))} + {"."} + + {/* Their configs carry no model pin (see configureOpenCodeKind), + so a model already selected in-CLI keeps winning there — + say so rather than let the line above imply otherwise. */} + {tools.some((t) => t === "opencode" || t === "codev-code") && ( + + {"In "} + {formatToolList( + tools + .filter((t) => t === "opencode" || t === "codev-code") + .map((t) => TOOL_LABEL[t]), + )} + {", switch models anytime with /models."} + + )} + )} )} diff --git a/src/lib/configure.ts b/src/lib/configure.ts index 33513b5..2e28b8a 100644 --- a/src/lib/configure.ts +++ b/src/lib/configure.ts @@ -924,9 +924,16 @@ function configureOpenCodeKind( const provider = resolveProvider(creds); // Fall back to [defaultModel] when `models` is unset so callers that don't // know about the list (e.g. older fixtures, the fallback path with no - // fetched list) still produce a valid one-entry map. - const allModels = - creds.models && creds.models.length > 0 ? creds.models : [defaultModel]; + // fetched list) still produce a valid one-entry map. The chosen model leads + // the map: with no top-level pin (see below) a first launch with no saved + // selection falls through to the provider's first model, so ordering is + // what carries the choice. + const allModels = [ + ...new Set([ + defaultModel, + ...(creds.models && creds.models.length > 0 ? creds.models : []), + ]), + ]; // A custom-provider model with no `limit` defaults to context 0, which both // mis-sizes the window and disables OpenCode's auto-compaction entirely. @@ -959,9 +966,15 @@ function configureOpenCodeKind( writeJson(sourcePath, { [OPENCODE_K.schema]: OPENCODE_SCHEMA_URL, ...(mcp !== undefined ? { [OPENCODE_K.mcp]: mcp } : {}), - // Top-level `model` pins the initial active model OpenCode uses on - // launch. Format is `/` per OpenCode's schema. - [OPENCODE_K.model]: `${provider.id}/${defaultModel}`, + // No top-level `model`. The install-time model choice exists for Claude + // Code and Codex, which can only run one model at a time; OpenCode and + // CoDev Code switch freely in-CLI (docs/hub/installation). A pin there + // outranks their saved selection on every launch — it beats the recent + // models in their state dir — so each in-CLI switch reverted on restart + // and only hand-editing the config undid it. The chosen model leads the + // models map instead, which decides the first launch and yields to any + // later selection. This is why `codevhub model` steers only Claude Code + // and Codex. // OpenCode has no percentage trigger; it compacts at `context − reserved`. // Reserve the headroom that lands the trigger at ~85% of the window, to // match Claude Code and Codex. diff --git a/tests/ModelApp.test.tsx b/tests/ModelApp.test.tsx index cf03c8c..79c97ca 100644 --- a/tests/ModelApp.test.tsx +++ b/tests/ModelApp.test.tsx @@ -126,6 +126,11 @@ describe("ModelApp", () => { expect(history).toContain( "Default model updated to new-alpha for Claude Code and OpenCode.", ); + // OpenCode's config carries no model pin (it switches in-CLI), so the + // line above must not read as authoritative for it. + expect(history).toContain( + "In OpenCode, switch models anytime with /models.", + ); expect(configureClaude).toHaveBeenCalledWith({ apiKey: "sk-existing", baseUrl: undefined, diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts index d2a0174..57c1175 100644 --- a/tests/lib/configure.test.ts +++ b/tests/lib/configure.test.ts @@ -398,8 +398,9 @@ describe("configureOpenCode", () => { expect(config.provider.netgate.models["chosen-model"].name).toBe( "chosen-model", ); - // Top-level `model` pins the active default in / form. - expect(config.model).toBe("netgate/chosen-model"); + // No top-level `model`: OpenCode and CoDev Code switch models in-CLI, and + // a pin would outrank that selection on every launch. + expect(config.model).toBeUndefined(); // Declares the gateway window so OpenCode sizes context correctly and its // auto-compaction fires (a model with no `limit` defaults to context 0, // which disables compaction). `output` is required alongside `context`. @@ -443,8 +444,10 @@ describe("configureOpenCode", () => { output: ["text"], }); } - // Top-level default still points at the chosen one. - expect(config.model).toBe("netgate/model-a"); + // With no pin, the map order carries the choice: a launch with nothing + // selected yet takes the provider's first model. + expect(config.model).toBeUndefined(); + expect(Object.keys(map)[0]).toBe("model-a"); }); test("falls back to [model] when `models` is absent (older call sites)", async () => { @@ -596,8 +599,9 @@ describe("configureCodevCode", () => { expect(config.provider.netgate.models["chosen-model"].name).toBe( "chosen-model", ); - // Top-level `model` pins the active default in / form. - expect(config.model).toBe("netgate/chosen-model"); + // No top-level `model`: OpenCode and CoDev Code switch models in-CLI, and + // a pin would outrank that selection on every launch. + expect(config.model).toBeUndefined(); // The fork shares OpenCode's window/compaction handling, so the same // limit + compaction blocks must land in its config. expect(config.provider.netgate.models["chosen-model"].limit).toEqual({ @@ -1652,7 +1656,7 @@ describe("custom provider identity", () => { expect(config.model_providers.netgate).toBeUndefined(); }); - test("opencode writes the custom id in both the provider map and `model`", async () => { + test("opencode writes the custom id in the provider map", async () => { const { configureOpenCode } = await import("@/lib/configure.js"); configureOpenCode(custom); @@ -1662,7 +1666,7 @@ describe("custom provider identity", () => { "utf-8", ), ); - expect(config.model).toBe("acme-ai/m"); + expect(config.provider["acme-ai"].models.m).toBeDefined(); expect(config.provider["acme-ai"].name).toBe("Acme AI"); expect(config.provider["acme-ai"].options.apiKey).toBe("sk-user"); expect(config.provider.netgate).toBeUndefined(); @@ -1675,7 +1679,7 @@ describe("custom provider identity", () => { const config = JSON.parse( readFileSync(join(tempDir, ".config", "codev", "codev.json"), "utf-8"), ); - expect(config.model).toBe("acme-ai/m"); + expect(config.provider["acme-ai"].models.m).toBeDefined(); expect(config.provider["acme-ai"].name).toBe("Acme AI"); });