From a333414be7750aedd6fe641fd91ed560f2d334c6 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Fri, 31 Jul 2026 14:58:47 +0700 Subject: [PATCH] fix(configure): seed the chosen model into the agents' saved selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the top-level `model` pin (#221) relied on the chosen model leading the models map, but map order never carried the choice: with no pin and no saved selection the OpenCode-family TUI falls back to the first provider in its list — upstream's hosted Zen provider, which self-registers ahead of config providers — so a fresh install started on "Big Pickle · OpenCode Zen" instead of the gateway (and, within a provider, the server's own sort picks the model, not the map). Write the install-time choice where the TUI actually reads it: the state dir's model.json `recent` list, its highest-priority startup source after a config pin. Seed only when the recents list is empty or absent — this writer also runs on every gateway-key auto-refresh and `codevhub model`, and a non-empty list is a selection the user already made that must keep winning. Favorites/variants are carried over, a corrupt state file is replaced, and any filesystem failure is swallowed (the seed is a nicety; the config write already succeeded). Counterpart to codev-code#144, which fixes the TUI-side fallback to skip the Zen registration; either alone fixes the visible default, this one additionally makes the wizard's chosen model stick. Co-Authored-By: Claude Fable 5 --- src/lib/configure.ts | 81 ++++++++++++++++++++++++--- tests/lib/configure.test.ts | 109 ++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 8 deletions(-) diff --git a/src/lib/configure.ts b/src/lib/configure.ts index 5ef47cd..e1f8914 100644 --- a/src/lib/configure.ts +++ b/src/lib/configure.ts @@ -145,6 +145,9 @@ const OPENCODE_K = { baseURL: atob("YmFzZVVSTA=="), apiKey: atob("YXBpS2V5"), models: atob("bW9kZWxz"), + recent: atob("cmVjZW50"), + providerID: atob("cHJvdmlkZXJJRA=="), + modelID: atob("bW9kZWxJRA=="), attachment: atob("YXR0YWNobWVudA=="), modalities: atob("bW9kYWxpdGllcw=="), input: atob("aW5wdXQ="), @@ -923,10 +926,10 @@ 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. 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. + // fetched list) still produce a valid one-entry map. The chosen model + // leads the map for readability, but ordering does not carry the choice — + // the first-launch default is seeded into the agent's saved selection + // instead (see seedOpenCodeRecentModel). const allModels = [ ...new Set([ defaultModel, @@ -970,10 +973,15 @@ function configureOpenCodeKind( // 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. + // and only hand-editing the config undid it. The chosen model is + // seeded into that saved selection instead (seedOpenCodeRecentModel, + // below), which decides the first launch and yields to any later + // switch. The models-map order alone can't carry the choice: with no + // pin and no saved selection the TUI falls back to the first provider + // in its list — upstream's hosted Zen provider, which self-registers + // ahead of config providers — and, within a provider, to the server's + // own model sort, neither of which reads this map's order. This is why + // `codevhub model` steers only Claude Code and Codex directly. // 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. @@ -994,5 +1002,62 @@ function configureOpenCodeKind( }, }); + seedOpenCodeRecentModel(kind, provider.id, defaultModel); + return [{ kind, sourcePath, backupPath, created }]; } + +// OpenCode-family agents persist the TUI's model selection in the XDG state +// dir (`state/model.json`, `recent` list); only the app segment differs +// between OpenCode and the codev-code fork, mirroring dbPath in +// providers/opencode.ts. +function openCodeStateModelPath( + kind: "opencode-config" | "codev-code-config", +): string { + const app = kind === "codev-code-config" ? "codev" : "opencode"; + const xdg = process.env.XDG_STATE_HOME; + if (xdg) return join(xdg, app, "model.json"); + return join(homedir(), ".local", "state", app, "model.json"); +} + +// Seed the install-time model choice as the agent's saved selection — +// `recent[0]` in the state dir is the TUI's highest-priority startup source +// once the config carries no `model` pin, and a later in-CLI switch simply +// displaces it. Seeds ONLY when the recents list is empty or absent: this +// writer also runs on every gateway-key auto-refresh (refresh.ts) and +// `codevhub model`, and a non-empty list is a selection the user already +// made that must keep winning. Best-effort by design, like the config +// writer: a corrupt state file is replaced (the agent ignores one anyway), +// other fields (favorites, variants) are carried over, and a filesystem +// failure never fails the configure step. +function seedOpenCodeRecentModel( + kind: "opencode-config" | "codev-code-config", + providerId: string, + modelId: string, +): void { + try { + const path = openCodeStateModelPath(kind); + let existing: Record = {}; + if (existsSync(path)) { + try { + const raw = JSON.parse(readFileSync(path, "utf-8")); + if (raw && typeof raw === "object" && !Array.isArray(raw)) { + existing = raw as Record; + } + } catch { + // Corrupt state file: replaced below. + } + } + const recent = existing[OPENCODE_K.recent]; + if (Array.isArray(recent) && recent.length > 0) return; + mkdirSync(dirname(path), { recursive: true }); + writeJson(path, { + ...existing, + [OPENCODE_K.recent]: [ + { [OPENCODE_K.providerID]: providerId, [OPENCODE_K.modelID]: modelId }, + ], + }); + } catch { + // The seed is a nicety; the config write above already succeeded. + } +} diff --git a/tests/lib/configure.test.ts b/tests/lib/configure.test.ts index 57c1175..5449d12 100644 --- a/tests/lib/configure.test.ts +++ b/tests/lib/configure.test.ts @@ -19,6 +19,10 @@ beforeEach(() => { // homedir() reads USERPROFILE on Windows, HOME on POSIX. Stub both so tests // hit the temp home on every platform. vi.stubEnv("USERPROFILE", tempDir); + // The model-seeding writer resolves the agents' state dir via + // XDG_STATE_HOME before falling back to $HOME/.local/state; clear it so a + // host that exports it can't leak test writes into a real state dir. + vi.stubEnv("XDG_STATE_HOME", ""); // The configure* functions fall back to AI_GATEWAY_URL()/AI_GATEWAY_OPENAI_URL() // whenever creds carry no baseUrl (the SSO-key path), and those accessors read // gateway_url out of ~/.codev-hub/auth.json. Seed it so the fallback resolves. @@ -711,6 +715,111 @@ describe("configureCodevCode", () => { }); }); +describe("first-launch model seeding (state/model.json recents)", () => { + function statePath(app: "opencode" | "codev") { + return join(tempDir, ".local", "state", app, "model.json"); + } + + test("configureCodevCode seeds the chosen model as the saved selection", async () => { + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ apiKey: "sk-xyz", model: "chosen-model" }); + + const state = JSON.parse(readFileSync(statePath("codev"), "utf-8")); + expect(state.recent).toEqual([ + { providerID: "netgate", modelID: "chosen-model" }, + ]); + }); + + test("configureOpenCode seeds its own state dir, not the fork's", async () => { + const { configureOpenCode } = await import("@/lib/configure.js"); + configureOpenCode({ apiKey: "sk-xyz", model: "chosen-model" }); + + const state = JSON.parse(readFileSync(statePath("opencode"), "utf-8")); + expect(state.recent).toEqual([ + { providerID: "netgate", modelID: "chosen-model" }, + ]); + expect(existsSync(statePath("codev"))).toBe(false); + }); + + test("honors XDG_STATE_HOME", async () => { + vi.stubEnv("XDG_STATE_HOME", join(tempDir, "xdg-state")); + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ apiKey: "sk-xyz", model: "m" }); + + const state = JSON.parse( + readFileSync(join(tempDir, "xdg-state", "codev", "model.json"), "utf-8"), + ); + expect(state.recent[0]?.modelID).toBe("m"); + }); + + test("never overrides a non-empty saved selection (key refresh, codevhub model)", async () => { + const dir = join(tempDir, ".local", "state", "codev"); + mkdirSync(dir, { recursive: true }); + const original = { + recent: [{ providerID: "netgate", modelID: "user-switched" }], + favorite: [], + variant: {}, + }; + writeFileSync(join(dir, "model.json"), JSON.stringify(original)); + + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ apiKey: "sk-xyz", model: "install-choice" }); + + const state = JSON.parse(readFileSync(statePath("codev"), "utf-8")); + expect(state).toEqual(original); + }); + + test("seeds an empty recents list and carries the other fields over", async () => { + const dir = join(tempDir, ".local", "state", "codev"); + mkdirSync(dir, { recursive: true }); + writeFileSync( + join(dir, "model.json"), + JSON.stringify({ + recent: [], + favorite: [{ providerID: "netgate", modelID: "fav" }], + variant: { "netgate/fav": "default" }, + }), + ); + + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ apiKey: "sk-xyz", model: "chosen-model" }); + + const state = JSON.parse(readFileSync(statePath("codev"), "utf-8")); + expect(state.recent).toEqual([ + { providerID: "netgate", modelID: "chosen-model" }, + ]); + expect(state.favorite).toEqual([{ providerID: "netgate", modelID: "fav" }]); + expect(state.variant).toEqual({ "netgate/fav": "default" }); + }); + + test("replaces a corrupt state file with a fresh seed", async () => { + const dir = join(tempDir, ".local", "state", "codev"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "model.json"), "{not json"); + + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ apiKey: "sk-xyz", model: "chosen-model" }); + + const state = JSON.parse(readFileSync(statePath("codev"), "utf-8")); + expect(state.recent).toEqual([ + { providerID: "netgate", modelID: "chosen-model" }, + ]); + }); + + test("uses the manual-path provider id in the seed", async () => { + const { configureCodevCode } = await import("@/lib/configure.js"); + configureCodevCode({ + apiKey: "sk-xyz", + model: "m", + providerId: "myprov", + providerName: "My Provider", + }); + + const state = JSON.parse(readFileSync(statePath("codev"), "utf-8")); + expect(state.recent).toEqual([{ providerID: "myprov", modelID: "m" }]); + }); +}); + describe("configureCodex", () => { function readCodexToml() { return TOML.parse(