diff --git a/src/resources/extensions/gsd/auto-start.ts b/src/resources/extensions/gsd/auto-start.ts index 3f737c638..218e47560 100644 --- a/src/resources/extensions/gsd/auto-start.ts +++ b/src/resources/extensions/gsd/auto-start.ts @@ -83,7 +83,7 @@ import { join } from "node:path"; import { sep as pathSep } from "node:path"; import { resolveProjectRootDbPath } from "./bootstrap/dynamic-tools.js"; -import { resolveDefaultSessionModel } from "./preferences-models.js"; +import { isCustomProvider, resolveDefaultSessionModel } from "./preferences-models.js"; import type { WorktreeResolver } from "./worktree-resolver.js"; export interface BootstrapDeps { @@ -270,11 +270,24 @@ export async function bootstrapAutoSession( // (#3517). The session model (ctx.model) comes from findInitialModel() which // reads defaultProvider/defaultModel from ~/.gsd/agent/settings.json. When // the user has explicit model preferences in PREFERENCES.md, those should win. + // + // Exception: when the session model is a custom provider defined in + // ~/.gsd/agent/models.json (Ollama, vLLM, OpenAI-compatible proxy, etc.), + // the session model wins over PREFERENCES.md. Custom providers can only be + // selected via `/gsd model`, which writes settings.json and therefore + // represents an explicit, recent user choice. PREFERENCES.md cannot + // reference custom providers, so honoring it here would silently start + // auto-mode against a built-in provider the user is not logged into and + // surface as "Not logged in · Please run /login" before pausing auto-mode + // and resetting to claude-code/claude-sonnet-4-6 (#4122). const preferredModel = resolveDefaultSessionModel(ctx.model?.provider); - const startModelSnapshot = preferredModel - ?? (ctx.model - ? { provider: ctx.model.provider, id: ctx.model.id } - : null); + const sessionProviderIsCustom = isCustomProvider(ctx.model?.provider); + const startModelSnapshot = sessionProviderIsCustom && ctx.model + ? { provider: ctx.model.provider, id: ctx.model.id } + : (preferredModel + ?? (ctx.model + ? { provider: ctx.model.provider, id: ctx.model.id } + : null)); try { // Validate GSD_PROJECT_ID early so the user gets immediate feedback diff --git a/src/resources/extensions/gsd/preferences-models.ts b/src/resources/extensions/gsd/preferences-models.ts index 2e4171687..0d6e7555b 100644 --- a/src/resources/extensions/gsd/preferences-models.ts +++ b/src/resources/extensions/gsd/preferences-models.ts @@ -7,6 +7,8 @@ */ import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import type { DynamicRoutingConfig } from "./model-router.js"; import { defaultRoutingConfig } from "./model-router.js"; import type { TokenProfile, InlineLevel } from "./types.js"; @@ -185,6 +187,45 @@ export function resolveDefaultSessionModel( return undefined; } +/** + * Returns true if `provider` is defined as a custom provider in the user's + * `~/.gsd/agent/models.json` (Ollama, vLLM, LM Studio, OpenAI-compatible + * proxies, etc.). + * + * Used by auto-mode bootstrap to decide whether the session model + * (set via `/gsd model`) should override `PREFERENCES.md`. Custom providers + * are never reachable from `PREFERENCES.md` (which only knows built-in + * providers), so when the user has explicitly selected one, it must take + * priority — otherwise auto-mode tries to start the built-in provider from + * PREFERENCES.md and fails with "Not logged in · Please run /login" (#4122). + * + * Reads models.json directly with a lightweight JSON parse to avoid + * pulling in the full model-registry at this call site. Falls back to + * `~/.pi/agent/models.json` for parity with `resolveModelsJsonPath()`. + * Any read or parse error yields `false` (treat as not-custom) so a + * malformed models.json never breaks the session bootstrap. + */ +export function isCustomProvider(provider: string | undefined): boolean { + if (!provider) return false; + const candidates = [ + join(homedir(), ".gsd", "agent", "models.json"), + join(homedir(), ".pi", "agent", "models.json"), + ]; + for (const path of candidates) { + if (!existsSync(path)) continue; + try { + const raw = readFileSync(path, "utf-8"); + const parsed = JSON.parse(raw) as { providers?: Record }; + if (parsed?.providers && Object.prototype.hasOwnProperty.call(parsed.providers, provider)) { + return true; + } + } catch { + // Ignore — malformed models.json must not break bootstrap. + } + } + return false; +} + /** * Determines the next fallback model to try when the current model fails. * If the current model is not in the configured list, returns the primary model. diff --git a/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts b/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts index 2ffb5bf96..1a5d5faf2 100644 --- a/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +++ b/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts @@ -9,7 +9,7 @@ const source = readFileSync(sourcePath, "utf-8"); test("bootstrapAutoSession snapshots ctx.model before guided-flow entry (#2829)", () => { // #3517 changed the snapshot to prefer GSD preferences, but the ordering // guarantee still holds: the snapshot must be built before guided-flow. - const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel"); + const snapshotIdx = source.indexOf("const startModelSnapshot = "); assert.ok(snapshotIdx > -1, "auto-start.ts should snapshot model at bootstrap start"); const firstDiscussIdx = source.indexOf('await showSmartEntry(ctx, pi, base, { step: requestedStepMode });'); @@ -38,11 +38,47 @@ test("bootstrapAutoSession prefers GSD PREFERENCES.md over settings.json for sta const withProviderIdx = source.indexOf("resolveDefaultSessionModel(ctx.model?.provider)"); assert.ok(withProviderIdx > -1, "auto-start.ts should pass ctx.model?.provider for bare ID resolution"); - const snapshotIdx = source.indexOf("const startModelSnapshot = preferredModel"); + const snapshotIdx = source.indexOf("const startModelSnapshot = "); assert.ok(snapshotIdx > -1, "startModelSnapshot should use preferredModel when available"); assert.ok( preferredIdx < snapshotIdx, "resolveDefaultSessionModel() must be called before building startModelSnapshot", ); + + // preferredModel must still appear as one of the snapshot sources so + // PREFERENCES.md continues to win over a stale settings.json default + // for built-in providers. + const snapshotBlock = source.slice(snapshotIdx, snapshotIdx + 400); + assert.ok( + snapshotBlock.includes("preferredModel"), + "startModelSnapshot must still consider preferredModel for built-in providers", + ); +}); + +test("bootstrapAutoSession prefers session model over PREFERENCES.md when provider is custom (#4122)", () => { + // Custom providers (Ollama, vLLM, OpenAI-compatible proxies) live in + // ~/.gsd/agent/models.json, not PREFERENCES.md. When the user picks one + // via /gsd model, that selection must win over any preferredModel from + // PREFERENCES.md, otherwise auto-mode tries to start a built-in provider + // the user is not logged into and pauses with "Not logged in". + const customCheckIdx = source.indexOf("isCustomProvider(ctx.model?.provider)"); + assert.ok( + customCheckIdx > -1, + "auto-start.ts should call isCustomProvider() to detect custom-model sessions", + ); + + const snapshotIdx = source.indexOf("const startModelSnapshot = "); + assert.ok(snapshotIdx > -1, "auto-start.ts should build startModelSnapshot"); + + assert.ok( + customCheckIdx < snapshotIdx, + "isCustomProvider() must be evaluated before building startModelSnapshot", + ); + + const snapshotBlock = source.slice(snapshotIdx, snapshotIdx + 400); + assert.ok( + snapshotBlock.includes("sessionProviderIsCustom"), + "startModelSnapshot must branch on sessionProviderIsCustom so custom providers win", + ); }); diff --git a/src/resources/extensions/gsd/tests/model-isolation.test.ts b/src/resources/extensions/gsd/tests/model-isolation.test.ts index 6dd107b12..3030fcf93 100644 --- a/src/resources/extensions/gsd/tests/model-isolation.test.ts +++ b/src/resources/extensions/gsd/tests/model-isolation.test.ts @@ -1,6 +1,7 @@ /** - * Tests for model config isolation between concurrent instances (#650, #1065) - * and GSD preferences override of settings.json defaults (#3517). + * Tests for model config isolation between concurrent instances (#650, #1065), + * GSD preferences override of settings.json defaults (#3517), and custom + * provider precedence over PREFERENCES.md when set via `/gsd model` (#4122). */ import { describe, it, beforeEach, afterEach } from "node:test"; @@ -229,3 +230,90 @@ describe("GSD preferences override settings.json for session model (#3517)", () "settings.json provider must NOT leak through"); }); }); + +// ─── Custom provider session model wins over PREFERENCES.md (#4122) ───────── + +describe("custom provider session model overrides PREFERENCES.md (#4122)", () => { + // Mirrors the auto-start.ts logic: + // sessionProviderIsCustom && ctx.model + // ? ctx.model + // : (preferredModel ?? ctx.model ?? null) + function selectStartModel(args: { + ctxModel: { provider: string; id: string } | null; + preferredModel: { provider: string; id: string } | undefined; + sessionProviderIsCustom: boolean; + }): { provider: string; id: string } | null { + const { ctxModel, preferredModel, sessionProviderIsCustom } = args; + if (sessionProviderIsCustom && ctxModel) { + return { provider: ctxModel.provider, id: ctxModel.id }; + } + return preferredModel + ?? (ctxModel ? { provider: ctxModel.provider, id: ctxModel.id } : null); + } + + it("custom provider from /gsd model wins over PREFERENCES.md built-in default", () => { + // User runs `/gsd model ollama/llama3.1:8b`, then `/gsd auto`. + // PREFERENCES.md still has the project-template claude-code default. + const ctxModel = { provider: "ollama", id: "llama3.1:8b" }; + const preferredModel = { provider: "claude-code", id: "claude-sonnet-4-6" }; + + const snapshot = selectStartModel({ + ctxModel, + preferredModel, + sessionProviderIsCustom: true, + }); + + assert.equal(snapshot?.provider, "ollama", + "custom-provider session model must win over PREFERENCES.md"); + assert.equal(snapshot?.id, "llama3.1:8b", + "custom-provider session model id must be preserved"); + assert.notEqual(snapshot?.provider, "claude-code", + "claude-code from PREFERENCES.md must NOT be selected when session is custom"); + }); + + it("built-in session provider still defers to PREFERENCES.md (#3517 preserved)", () => { + // ctx.model is a built-in provider (claude-code) but PREFERENCES.md has + // an explicit openai-codex preference. PREFERENCES.md should still win. + const ctxModel = { provider: "claude-code", id: "claude-sonnet-4-6" }; + const preferredModel = { provider: "openai-codex", id: "gpt-5.4" }; + + const snapshot = selectStartModel({ + ctxModel, + preferredModel, + sessionProviderIsCustom: false, + }); + + assert.equal(snapshot?.provider, "openai-codex", + "PREFERENCES.md must still win when session provider is built-in"); + assert.equal(snapshot?.id, "gpt-5.4"); + }); + + it("custom provider with no PREFERENCES.md still uses ctx.model", () => { + const ctxModel = { provider: "vllm", id: "qwen2.5-coder:32b" }; + + const snapshot = selectStartModel({ + ctxModel, + preferredModel: undefined, + sessionProviderIsCustom: true, + }); + + assert.equal(snapshot?.provider, "vllm"); + assert.equal(snapshot?.id, "qwen2.5-coder:32b"); + }); + + it("null ctx.model with custom flag falls through to preferredModel", () => { + // Defensive: sessionProviderIsCustom can only be true if ctx.model exists, + // but verify the guard works if that invariant is ever broken. + const preferredModel = { provider: "claude-code", id: "claude-sonnet-4-6" }; + + const snapshot = selectStartModel({ + ctxModel: null, + preferredModel, + sessionProviderIsCustom: true, + }); + + assert.equal(snapshot?.provider, "claude-code", + "should fall back to preferredModel when ctx.model is null"); + }); +}); +