Skip to content

Commit 60f63a0

Browse files
feat: add agent default variant handling in TUI and desktop
- Resolve configured variants through shared helpers used by app and TUI - Respect agent-configured variants unless the user selects an override or Default - Restore and hydrate TUI variant state without stale overrides or legacy default sentinels - Add variant tests for shared helpers and agent config Closes #22065
1 parent b8142c7 commit 60f63a0

11 files changed

Lines changed: 244 additions & 42 deletions

File tree

packages/app/src/components/prompt-input/submit.test.ts

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,13 @@ const optimistic: Array<{
1313
sessionID?: string
1414
message: {
1515
agent: string
16-
model: { providerID: string; modelID: string }
17-
variant?: string
16+
model: { providerID: string; modelID: string; variant?: string }
1817
}
1918
}> = []
2019
const optimisticSeeded: boolean[] = []
2120
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
2221
const promoted: Array<{ directory: string; sessionID: string }> = []
22+
const promotedVariants: Array<string | null | undefined> = []
2323
const sentShell: string[] = []
2424
const syncedDirectories: string[] = []
2525
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
@@ -28,6 +28,7 @@ let params: { id?: string } = {}
2828
let search: { draftId?: string } = {}
2929
let selected = "/repo/worktree-a"
3030
let variant: string | undefined
31+
let selectedVariant: string | null | undefined
3132
let permissionServer = "server-a"
3233
let createSessionGate: Promise<void> | undefined
3334

@@ -106,8 +107,7 @@ beforeAll(async () => {
106107
},
107108
}))
108109

109-
mock.module("@opencode-ai/ui/toast", () => ({
110-
Toast: { Region: () => null },
110+
mock.module("@/utils/toast", () => ({
111111
showToast: () => 0,
112112
}))
113113

@@ -119,14 +119,15 @@ beforeAll(async () => {
119119
useLocal: () => ({
120120
model: {
121121
current: () => ({ id: "model", provider: { id: "provider" } }),
122-
variant: { current: () => variant },
122+
variant: { current: () => variant, selected: () => selectedVariant },
123123
},
124124
agent: {
125125
current: () => ({ name: "agent" }),
126126
},
127127
session: {
128-
promote(directory: string, sessionID: string) {
128+
promote(directory: string, sessionID: string, state?: { variant?: string | null }) {
129129
promoted.push({ directory, sessionID })
130+
promotedVariants.push(state?.variant)
130131
},
131132
},
132133
}),
@@ -255,13 +256,15 @@ beforeEach(() => {
255256
optimistic.length = 0
256257
optimisticSeeded.length = 0
257258
promoted.length = 0
259+
promotedVariants.length = 0
258260
promotedDrafts.length = 0
259261
params = {}
260262
search = {}
261263
sentShell.length = 0
262264
syncedDirectories.length = 0
263265
selected = "/repo/worktree-a"
264266
variant = undefined
267+
selectedVariant = undefined
265268
permissionServer = "server-a"
266269
createSessionGate = undefined
267270
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
@@ -433,7 +436,7 @@ describe("prompt submit worktree selection", () => {
433436
params = { id: "session-1" }
434437
const model = {
435438
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
436-
variant: { current: () => "draft-variant" },
439+
variant: { current: () => "draft-variant", selected: () => undefined },
437440
} as unknown as ModelSelection
438441
const submit = createPromptSubmit({
439442
prompt,
@@ -462,6 +465,68 @@ describe("prompt submit worktree selection", () => {
462465
})
463466
})
464467

468+
test("sends explicit default variant on optimistic prompts", async () => {
469+
params = { id: "session-1" }
470+
selectedVariant = null
471+
472+
const submit = createPromptSubmit({
473+
prompt,
474+
info: () => ({ id: "session-1" }),
475+
imageAttachments: () => [],
476+
commentCount: () => 0,
477+
autoAccept: () => false,
478+
mode: () => "normal",
479+
working: () => false,
480+
editor: () => undefined,
481+
queueScroll: () => undefined,
482+
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
483+
addToHistory: () => undefined,
484+
resetHistoryNavigation: () => undefined,
485+
setMode: () => undefined,
486+
setPopover: () => undefined,
487+
onSubmit: () => undefined,
488+
})
489+
490+
const event = { preventDefault: () => undefined } as unknown as Event
491+
492+
await submit.handleSubmit(event)
493+
494+
expect(optimistic).toHaveLength(1)
495+
expect(optimistic[0]).toMatchObject({
496+
message: {
497+
agent: "agent",
498+
model: { providerID: "provider", modelID: "model", variant: "default" },
499+
},
500+
})
501+
})
502+
503+
test("preserves explicit default when promoting a new session", async () => {
504+
selectedVariant = null
505+
506+
const submit = createPromptSubmit({
507+
prompt,
508+
info: () => undefined,
509+
imageAttachments: () => [],
510+
commentCount: () => 0,
511+
autoAccept: () => false,
512+
mode: () => "normal",
513+
working: () => false,
514+
editor: () => undefined,
515+
queueScroll: () => undefined,
516+
promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
517+
addToHistory: () => undefined,
518+
resetHistoryNavigation: () => undefined,
519+
setMode: () => undefined,
520+
setPopover: () => undefined,
521+
onSubmit: () => undefined,
522+
})
523+
524+
await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
525+
526+
expect(promotedVariants).toEqual([null])
527+
expect(optimistic[0]?.message.model.variant).toBe("default")
528+
})
529+
465530
test("seeds new sessions before optimistic prompts are added", async () => {
466531
const submit = createPromptSubmit({
467532
prompt,

packages/app/src/components/prompt-input/submit.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { buildRequestParts } from "./build-request-parts"
1919
import { setCursorPosition } from "./editor-dom"
2020
import { formatServerError } from "@/utils/server-errors"
2121
import { ScopedKey } from "@/utils/server-scope"
22+
import { resolveModelVariantForRequest } from "@opencode-ai/core/util/model-variant"
2223
import { createPromptSubmissionState } from "./submission-state"
2324

2425
type PendingPrompt = {
@@ -302,7 +303,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
302303
const modelSelection = input.model ?? local.model
303304
const currentModel = modelSelection.current()
304305
const currentAgent = local.agent.current()
305-
const variant = modelSelection.variant.current()
306+
const selectedVariant = modelSelection.variant.selected()
307+
const variant = resolveModelVariantForRequest({
308+
selected: selectedVariant,
309+
current: modelSelection.variant.current(),
310+
})
306311
if (!currentModel || !currentAgent) {
307312
showToast({
308313
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -383,7 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
383388
local.session.promote(sessionDirectory, session.id, {
384389
agent: currentAgent.name,
385390
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
386-
variant: variant ?? null,
391+
variant: selectedVariant,
387392
})
388393
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
389394
const draftID = search.draftId

packages/app/src/context/local.tsx

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import { useSettings } from "@/context/settings"
88
import { useProviders } from "@/hooks/use-providers"
99
import { Persist, persisted } from "@/utils/persist"
1010
import { hasCustomAgent, resolveAgent } from "./local-agent"
11-
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
11+
import {
12+
cycleModelVariant,
13+
getConfiguredAgentVariant,
14+
resolveModelVariant,
15+
resolveModelVariantFromMessage,
16+
} from "@opencode-ai/core/util/model-variant"
1217
import { useSDK } from "./sdk"
1318
import { useSync } from "./sync"
1419
import { useServerSDK } from "./server-sdk"
@@ -193,19 +198,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
193198
return
194199
}
195200

201+
const prev = scope()
196202
batch(() => {
197203
setStore("current", item.name)
198204
setStore("last", {
199205
type: "agent",
200206
agent: item.name,
201207
model: item.model,
202-
variant: item.variant ?? null,
208+
variant: prev?.variant,
203209
})
204-
const prev = scope()
205210
const next = {
206211
agent: item.name,
207212
model: item.model ?? prev?.model,
208-
variant: item.variant ?? prev?.variant,
213+
variant: prev?.variant,
209214
} satisfies State
210215
const session = id()
211216
if (session) {
@@ -326,12 +331,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
326331
configured,
327332
selected,
328333
current() {
334+
const selected = this.selected()
329335
const resolved = resolveModelVariant({
330336
variants: this.list(),
331-
selected: this.selected(),
337+
selected,
332338
configured: this.configured(),
333339
})
334340
if (resolved) return resolved
341+
if (selected === null) return
335342
const model = current()
336343
if (!model) return
337344
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
@@ -405,7 +412,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
405412
setSaved("session", session, {
406413
agent: msg.agent,
407414
model: msg.model,
408-
variant: msg.model?.variant ?? null,
415+
variant: resolveModelVariantFromMessage(msg.model?.variant),
409416
})
410417
},
411418
},

packages/app/src/pages/session/composer/prompt-model-selection.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { batch, createMemo, startTransition } from "solid-js"
2+
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@opencode-ai/core/util/model-variant"
23
import { useModels } from "@/context/models"
34
import type { ModelKey, ModelSelection } from "@/context/local"
4-
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
55
import { usePrompt } from "@/context/prompt"
66
import { useSDK } from "@/context/sdk"
77
import { useSync } from "@/context/sync"
@@ -91,12 +91,14 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
9191
return prompt.model.current()?.variant
9292
},
9393
current() {
94+
const selected = this.selected()
9495
const resolved = resolveModelVariant({
9596
variants: this.list(),
96-
selected: this.selected(),
97+
selected,
9798
configured: this.configured(),
9899
})
99100
if (resolved) return resolved
101+
if (selected === null) return
100102
const model = current()
101103
if (!model) return
102104
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ type Model = AgentModel & {
1212
variants?: Record<string, unknown>
1313
}
1414

15+
// selected: string = user-chosen variant name
16+
// selected: null = user explicitly chose "default" (clears any agent-configured variant)
17+
// selected: undefined = no user choice yet (fall back to agent-configured variant)
1518
type VariantInput = {
1619
variants: string[]
1720
selected: string | null | undefined
@@ -35,6 +38,19 @@ export function resolveModelVariant(input: VariantInput) {
3538
return undefined
3639
}
3740

41+
export function resolveModelVariantForRequest(input: {
42+
selected: string | null | undefined
43+
current: string | undefined
44+
}) {
45+
if (input.selected === null) return "default"
46+
return input.current
47+
}
48+
49+
export function resolveModelVariantFromMessage(variant: string | undefined) {
50+
if (variant === "default") return null
51+
return variant
52+
}
53+
3854
export function cycleModelVariant(input: VariantInput) {
3955
if (input.variants.length === 0) return undefined
4056
if (input.selected === null) return input.variants[0]
@@ -43,6 +59,7 @@ export function cycleModelVariant(input: VariantInput) {
4359
if (index === input.variants.length - 1) return undefined
4460
return input.variants[index + 1]
4561
}
62+
// No explicit selection: start cycling from the agent-configured variant.
4663
if (input.configured && input.variants.includes(input.configured)) {
4764
const index = input.variants.indexOf(input.configured)
4865
if (index === input.variants.length - 1) return input.variants[0]

packages/app/src/context/model-variant.test.ts renamed to packages/core/test/util/model-variant.test.ts

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, expect, test } from "bun:test"
2-
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
2+
import {
3+
cycleModelVariant,
4+
getConfiguredAgentVariant,
5+
resolveModelVariant,
6+
resolveModelVariantForRequest,
7+
resolveModelVariantFromMessage,
8+
} from "../../src/util/model-variant"
39

410
describe("model variant", () => {
511
test("resolves configured agent variant when model matches", () => {
@@ -83,4 +89,41 @@ describe("model variant", () => {
8389

8490
expect(value).toBe("low")
8591
})
92+
93+
test("cycles through all variants from explicit selection", () => {
94+
const variants = ["low", "high", "xhigh"]
95+
const first = cycleModelVariant({ variants, selected: undefined, configured: undefined })
96+
const second = cycleModelVariant({ variants, selected: first, configured: undefined })
97+
const third = cycleModelVariant({ variants, selected: second, configured: undefined })
98+
const fourth = cycleModelVariant({ variants, selected: third, configured: undefined })
99+
100+
expect(first).toBe("low")
101+
expect(second).toBe("high")
102+
expect(third).toBe("xhigh")
103+
expect(fourth).toBeUndefined()
104+
})
105+
106+
test("sends explicit default as request sentinel", () => {
107+
const value = resolveModelVariantForRequest({ selected: null, current: undefined })
108+
109+
expect(value).toBe("default")
110+
})
111+
112+
test("keeps current variant for requests without explicit default", () => {
113+
const value = resolveModelVariantForRequest({ selected: undefined, current: "xhigh" })
114+
115+
expect(value).toBe("xhigh")
116+
})
117+
118+
test("restores explicit default from message sentinel", () => {
119+
const value = resolveModelVariantFromMessage("default")
120+
121+
expect(value).toBeNull()
122+
})
123+
124+
test("keeps absent message variant as inherit", () => {
125+
const value = resolveModelVariantFromMessage(undefined)
126+
127+
expect(value).toBeUndefined()
128+
})
86129
})

packages/opencode/test/agent/agent.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -753,3 +753,26 @@ it.instance(
753753
},
754754
},
755755
)
756+
757+
it.instance(
758+
"agent variant can be set from config",
759+
() =>
760+
Effect.gen(function* () {
761+
const build = yield* load((svc) => svc.get("build"))
762+
expect(build?.variant).toBe("high")
763+
}),
764+
{
765+
config: {
766+
agent: {
767+
build: { variant: "high" },
768+
},
769+
},
770+
},
771+
)
772+
773+
it.instance("agent variant defaults to undefined when not set", () =>
774+
Effect.gen(function* () {
775+
const build = yield* load((svc) => svc.get("build"))
776+
expect(build?.variant).toBeUndefined()
777+
}),
778+
)

packages/tui/src/component/dialog-model.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,13 @@ export function DialogModel(props: { providerID?: string }) {
142142
function onSelect(providerID: string, modelID: string) {
143143
local.model.set({ providerID, modelID }, { recent: true })
144144
const list = local.model.variant.list()
145-
const cur = local.model.variant.selected()
146-
if (cur === "default" || (cur && list.includes(cur))) {
145+
const selected = local.model.variant.selected()
146+
const current = local.model.variant.current()
147+
if (
148+
selected === null ||
149+
(selected && list.includes(selected)) ||
150+
(!selected && current && list.includes(current))
151+
) {
147152
dialog.clear()
148153
return
149154
}

0 commit comments

Comments
 (0)