Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions packages/opencode/src/session/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
120 changes: 120 additions & 0 deletions packages/opencode/test/session/prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Loading