Skip to content

Commit d662ec9

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 ef3b673 commit d662ec9

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
@@ -12,13 +12,13 @@ const optimistic: Array<{
1212
sessionID?: string
1313
message: {
1414
agent: string
15-
model: { providerID: string; modelID: string }
16-
variant?: string
15+
model: { providerID: string; modelID: string; variant?: string }
1716
}
1817
}> = []
1918
const optimisticSeeded: boolean[] = []
2019
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
2120
const promoted: Array<{ directory: string; sessionID: string }> = []
21+
const promotedVariants: Array<string | null | undefined> = []
2222
const sentShell: string[] = []
2323
const syncedDirectories: string[] = []
2424
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
@@ -27,6 +27,7 @@ let params: { id?: string } = {}
2727
let search: { draftId?: string } = {}
2828
let selected = "/repo/worktree-a"
2929
let variant: string | undefined
30+
let selectedVariant: string | null | undefined
3031
let permissionServer = "server-a"
3132
let createSessionGate: Promise<void> | undefined
3233

@@ -99,8 +100,7 @@ beforeAll(async () => {
99100
},
100101
}))
101102

102-
mock.module("@opencode-ai/ui/toast", () => ({
103-
Toast: { Region: () => null },
103+
mock.module("@/utils/toast", () => ({
104104
showToast: () => 0,
105105
}))
106106

@@ -112,14 +112,15 @@ beforeAll(async () => {
112112
useLocal: () => ({
113113
model: {
114114
current: () => ({ id: "model", provider: { id: "provider" } }),
115-
variant: { current: () => variant },
115+
variant: { current: () => variant, selected: () => selectedVariant },
116116
},
117117
agent: {
118118
current: () => ({ name: "agent" }),
119119
},
120120
session: {
121-
promote(directory: string, sessionID: string) {
121+
promote(directory: string, sessionID: string, state?: { variant?: string | null }) {
122122
promoted.push({ directory, sessionID })
123+
promotedVariants.push(state?.variant)
123124
},
124125
},
125126
}),
@@ -248,13 +249,15 @@ beforeEach(() => {
248249
optimistic.length = 0
249250
optimisticSeeded.length = 0
250251
promoted.length = 0
252+
promotedVariants.length = 0
251253
promotedDrafts.length = 0
252254
params = {}
253255
search = {}
254256
sentShell.length = 0
255257
syncedDirectories.length = 0
256258
selected = "/repo/worktree-a"
257259
variant = undefined
260+
selectedVariant = undefined
258261
permissionServer = "server-a"
259262
createSessionGate = undefined
260263
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
@@ -426,7 +429,7 @@ describe("prompt submit worktree selection", () => {
426429
params = { id: "session-1" }
427430
const model = {
428431
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
429-
variant: { current: () => "draft-variant" },
432+
variant: { current: () => "draft-variant", selected: () => undefined },
430433
} as unknown as ModelSelection
431434
const submit = createPromptSubmit({
432435
prompt,
@@ -455,6 +458,68 @@ describe("prompt submit worktree selection", () => {
455458
})
456459
})
457460

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