From bf3eaefa61ae213a711d7f3cc326fa46602a7285 Mon Sep 17 00:00:00 2001 From: Qiiks Date: Sun, 16 Aug 2026 18:55:03 +0530 Subject: [PATCH] fix(session): don't clobber user's model swap on model-less prompts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A model-less prompt (no input.model — e.g. plugin completion-reminders via promptAsync) resolves from the agent fallback (ag.model) for the turn. The existing design (PR #26765) intentionally uses the agent's model when no explicit model is given. However, setAgentModel then overwrites the session row to the resolved model, permanently destroying a model the user explicitly stored via a prior swap. The TUI is unaffected (always sends draft.model explicitly), but opencode serve / mobile clients lose the swap on the next plugin reminder. Guard the setAgentModel write: skip it when the prompt carried no explicit model AND the session already has a stored model that differs from the agent's resolved model. Fresh sessions (no stored model) and explicit-model prompts still persist normally. --- packages/opencode/src/session/prompt.ts | 16 ++- packages/opencode/test/session/prompt.test.ts | 120 ++++++++++++++++++ 2 files changed, 130 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 22b1d7d99a2a..7efe91000610 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -670,12 +670,16 @@ const layer = Layer.effect( } const current = yield* sessions.get(input.sessionID).pipe(Effect.orDie) - if ( - current.agent !== info.agent || - current.model?.providerID !== info.model.providerID || - current.model?.id !== info.model.modelID || - (current.model?.variant === "default" ? undefined : current.model?.variant) !== info.model.variant - ) { + const hasNoStoredModel = current.model == null + const storedModelDiffers = + current.model != null && + (current.model.providerID !== info.model.providerID || + current.model.id !== info.model.modelID || + (current.model.variant === "default" ? undefined : current.model.variant) !== info.model.variant) + // A model-less prompt (no input.model) resolves from the agent fallback; + // never let that overwrite a model the user explicitly stored on the session. + const wouldClobberStoredModel = input.model == null && storedModelDiffers + if ((current.agent !== info.agent || storedModelDiffers || hasNoStoredModel) && !wouldClobberStoredModel) { yield* sessions.setAgentModel({ sessionID: input.sessionID, agent: info.agent, diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 5a0176abc9b0..b2ddbc25d3ea 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -2440,3 +2440,123 @@ noLLMServer.instance( }), 30_000, ) + + +// Model-swap survival: a model-less prompt (e.g. plugin completion-reminder) +// must not overwrite a model the user explicitly stored on the session. + +noLLMServer.instance( + "model-less prompt does not clobber user's stored model swap", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + // User explicitly swaps to kimi — the session row should persist it. + yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + model: { providerID: ProviderV2.ID.make("opencode"), modelID: ModelV2.ID.make("kimi-k2.5-free") }, + noReply: true, + parts: [{ type: "text", text: "hello" }], + }) + const afterSwap = yield* sessions.get(session.id) + expect(afterSwap.model?.id).toBe(ModelV2.ID.make("kimi-k2.5-free")) + + // A model-less prompt (as OMO completion-reminders send) resolves the + // agent's configured model for the turn — that design is preserved (see + // "applies agent variant only when using agent model" above). But it + // must NOT overwrite the user's swap in the session row. + const match = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "reminder" }], + }) + if (match.info.role !== "user") throw new Error("expected user message") + // The turn uses the agent's model (existing design, unchanged). + expect(match.info.model.modelID).toBe(ModelV2.ID.make("test-model")) + // The session row keeps the user's swap — the fix. + const afterReminder = yield* sessions.get(session.id) + expect(afterReminder.model?.id).toBe(ModelV2.ID.make("kimi-k2.5-free")) + + yield* sessions.remove(session.id) + }), + { + config: { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + models: { + "test-model": { + ...cfg.provider.test.models["test-model"], + variants: { xhigh: {}, high: {} }, + }, + }, + }, + }, + agent: { + build: { + model: "test/test-model", + variant: "xhigh", + }, + }, + }, + }, + 90_000, +) + +// Fresh-session initialization: a model-less first prompt persists the agent's +// model to the session row (no existing user swap to protect). + +noLLMServer.instance( + "model-less first prompt on fresh session persists agent model to row", + () => + Effect.gen(function* () { + const prompt = yield* SessionPrompt.Service + const sessions = yield* Session.Service + const session = yield* sessions.create({}) + + const match = yield* prompt.prompt({ + sessionID: session.id, + agent: "build", + noReply: true, + parts: [{ type: "text", text: "fresh start" }], + }) + if (match.info.role !== "user") throw new Error("expected user message") + expect(match.info.model.modelID).toBe(ModelV2.ID.make("test-model")) + + // The session row should now carry the agent's model. + const row = yield* sessions.get(session.id) + expect(row.model?.id).toBe(ModelV2.ID.make("test-model")) + + yield* sessions.remove(session.id) + }), + { + config: { + ...cfg, + provider: { + ...cfg.provider, + test: { + ...cfg.provider.test, + models: { + "test-model": { + ...cfg.provider.test.models["test-model"], + variants: { xhigh: {}, high: {} }, + }, + }, + }, + }, + agent: { + build: { + model: "test/test-model", + variant: "xhigh", + }, + }, + }, + }, + 90_000, +)