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
1 change: 1 addition & 0 deletions .opencode/agent/triage.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ mode: primary
hidden: true
model: opencode/minimax-m2.5
color: "#44BA81"
variant: "high"
tools:
"*": false
"github-triage": true
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured: this.configured(),
})
},
effective() {
return this.current()
},
list() {
const item = current()
if (!item?.variants) return []
Expand Down
19 changes: 16 additions & 3 deletions packages/app/src/context/model-variant.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,17 @@ describe("model variant", () => {
expect(value).toBeUndefined()
})

test("cycles from configured variant to next", () => {
test("starts cycling from first variant when no explicit selection", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
configured: "high",
})

expect(value).toBe("xhigh")
expect(value).toBe("low")
})

test("wraps from configured last variant to first", () => {
test("starts from first even when configured is last", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: undefined,
Expand All @@ -83,4 +83,17 @@ describe("model variant", () => {

expect(value).toBe("low")
})

test("cycles through all variants from explicit selection", () => {
const variants = ["low", "high", "xhigh"]
const first = cycleModelVariant({ variants, selected: undefined, configured: "high" })
const second = cycleModelVariant({ variants, selected: first, configured: "high" })
const third = cycleModelVariant({ variants, selected: second, configured: "high" })
const fourth = cycleModelVariant({ variants, selected: third, configured: "high" })

expect(first).toBe("low")
expect(second).toBe("high")
expect(third).toBe("xhigh")
expect(fourth).toBeUndefined()
})
})
5 changes: 0 additions & 5 deletions packages/app/src/context/model-variant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,5 @@ export function cycleModelVariant(input: VariantInput) {
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
if (input.configured && input.variants.includes(input.configured)) {
const index = input.variants.indexOf(input.configured)
if (index === input.variants.length - 1) return input.variants[0]
return input.variants[index + 1]
}
return input.variants[0]
}
22 changes: 18 additions & 4 deletions packages/opencode/src/cli/cmd/tui/component/prompt/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { useToast } from "../../ui/toast"
import { useKV } from "../../context/kv"
import { useTextareaKeybindings } from "../textarea-keybindings"
import { DialogSkill } from "../dialog-skill"
import { getConfiguredAgentVariant } from "@tui/context/model-variant"

export type PromptProps = {
sessionID?: string
Expand Down Expand Up @@ -197,7 +198,20 @@ export function Prompt(props: PromptProps) {
if (msg.agent && isPrimaryAgent) {
local.agent.set(msg.agent)
if (msg.model) local.model.set(msg.model)
if (msg.variant) local.model.variant.set(msg.variant)
if (msg.variant) {
const info = local.agent.list().find((x) => x.name === msg.agent)
const provider = msg.model ? sync.data.provider.find((x) => x.id === msg.model.providerID) : undefined
const model = msg.model ? provider?.models[msg.model.modelID] : undefined
const configured = getConfiguredAgentVariant({
agent: { model: info?.model, variant: info?.variant },
model: msg.model
? { providerID: msg.model.providerID, modelID: msg.model.modelID, variants: model?.variants }
: undefined,
})
Comment on lines +201 to +210
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This block recomputes the configured agent variant by re-looking-up the agent info and provider model metadata. Since LocalContext now has local.model.variant.configured(), consider using that directly (after setting agent/model) to avoid duplicating the match/variant-availability logic in multiple places and risking future divergence.

Copilot uses AI. Check for mistakes.
if (msg.variant === configured) local.model.variant.set(undefined)
if (msg.variant !== configured) local.model.variant.set(msg.variant)
}
if (!msg.variant) local.model.variant.set(undefined)
Comment on lines +210 to +214
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initialization logic clears the variant with local.model.variant.set(undefined) when the last user message has no variant or when its variant matches the agent-configured default. In LocalContext, variant.set(undefined) is persisted as the explicit "default" sentinel (see setModelStore("variant", key, value ?? "default")), which will be interpreted as an explicit user override and will disable the agent’s configured variant rather than “unsetting” the override. Introduce a real “unset”/delete path for the stored selection (so agent defaults can apply), and only persist the "default" sentinel when the user explicitly chooses Default.

Copilot uses AI. Check for mistakes.
}
}
})
Expand Down Expand Up @@ -805,8 +819,8 @@ export function Prompt(props: PromptProps) {
const showVariant = createMemo(() => {
const variants = local.model.variant.list()
if (variants.length === 0) return false
const current = local.model.variant.current()
return !!current
const effective = local.model.variant.effective()
return !!effective
})

const placeholderText = createMemo(() => {
Expand Down Expand Up @@ -1073,7 +1087,7 @@ export function Prompt(props: PromptProps) {
<Show when={showVariant()}>
<text fg={theme.textMuted}>·</text>
<text>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.current()}</span>
<span style={{ fg: theme.warning, bold: true }}>{local.model.variant.effective()}</span>
</text>
</Show>
</box>
Expand Down
42 changes: 27 additions & 15 deletions packages/opencode/src/cli/cmd/tui/context/local.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
import { Filesystem } from "@/util/filesystem"
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"

export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
Expand Down Expand Up @@ -321,17 +322,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
variant: {
configured() {
const a = agent.current()
const m = currentModel()
if (!m) return undefined
const provider = sync.data.provider.find((x) => x.id === m.providerID)
const info = provider?.models[m.modelID]
return getConfiguredAgentVariant({
agent: { model: a.model, variant: a.variant },
model: { providerID: m.providerID, modelID: m.modelID, variants: info?.variants },
})
},
selected() {
const m = currentModel()
if (!m) return undefined
const key = `${m.providerID}/${m.modelID}`
return modelStore.variant[key]
},
current() {
const v = this.selected()
if (!v) return undefined
if (!this.list().includes(v)) return undefined
return v
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
},
effective() {
return this.current()
},
Comment on lines +343 to 351
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

local.model.variant.current() is now computed via resolveModelVariant(...), which collapses the persisted selection (selected) and agent default (configured) into a single “effective” value. This is risky because other call sites (e.g., prompt submission) typically need the explicit user override (including the "default" sentinel) rather than the resolved effective variant; otherwise users can’t explicitly override an agent default back to base behavior, and the client may send agent defaults as if they were user-selected. Consider splitting this into separate APIs (e.g., keep selected() for request payloads, and use effective() for display) or otherwise preserve the "default" sentinel through the value used for network requests.

Suggested change
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
},
effective() {
return this.current()
},
return this.selected()
},
effective() {
return resolveModelVariant({
variants: this.list(),
selected: this.selected(),
configured: this.configured(),
})
},

Copilot uses AI. Check for mistakes.
list() {
const m = currentModel()
Expand All @@ -351,17 +367,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
cycle() {
const variants = this.list()
if (variants.length === 0) return
const current = this.current()
if (!current) {
this.set(variants[0])
return
}
const index = variants.indexOf(current)
if (index === -1 || index === variants.length - 1) {
this.set(undefined)
return
}
this.set(variants[index + 1])
this.set(
cycleModelVariant({
variants,
selected: this.selected(),
configured: this.configured(),
}),
)
},
},
}
Expand Down
47 changes: 47 additions & 0 deletions packages/opencode/src/cli/cmd/tui/context/model-variant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
type AgentModel = {
providerID: string
modelID: string
}

type Agent = {
model?: AgentModel
variant?: string
}

type Model = AgentModel & {
variants?: Record<string, unknown>
}

type VariantInput = {
variants: string[]
selected: string | undefined
configured: string | undefined
}

export function getConfiguredAgentVariant(input: { agent: Agent | undefined; model: Model | undefined }) {
if (!input.agent?.variant) return undefined
if (!input.agent.model) return undefined
if (!input.model?.variants) return undefined
if (input.agent.model.providerID !== input.model.providerID) return undefined
if (input.agent.model.modelID !== input.model.modelID) return undefined
if (!(input.agent.variant in input.model.variants)) return undefined
return input.agent.variant
}

export function resolveModelVariant(input: VariantInput) {
if (input.selected === "default") return undefined
if (input.selected && input.variants.includes(input.selected)) return input.selected
if (input.configured && input.variants.includes(input.configured)) return input.configured
return undefined
}

export function cycleModelVariant(input: VariantInput) {
if (input.variants.length === 0) return undefined
if (input.selected === "default") return input.variants[0]
if (input.selected && input.variants.includes(input.selected)) {
const index = input.variants.indexOf(input.selected)
if (index === input.variants.length - 1) return undefined
return input.variants[index + 1]
}
return input.variants[0]
}
28 changes: 28 additions & 0 deletions packages/opencode/test/agent/agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -715,3 +715,31 @@ test("defaultAgent throws when all primary agents are disabled", async () => {
},
})
})

test("agent variant can be set from config", async () => {
await using tmp = await tmpdir({
config: {
agent: {
build: { variant: "high" },
},
},
})
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.variant).toBe("high")
},
})
})

test("agent variant defaults to undefined when not set", async () => {
await using tmp = await tmpdir()
await Instance.provide({
directory: tmp.path,
fn: async () => {
const build = await Agent.get("build")
expect(build?.variant).toBeUndefined()
},
})
})
69 changes: 69 additions & 0 deletions packages/opencode/test/cli/tui/model-variant.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, test } from "bun:test"
import {
cycleModelVariant,
getConfiguredAgentVariant,
resolveModelVariant,
} from "../../../src/cli/cmd/tui/context/model-variant"

describe("tui model variant", () => {
test("resolves configured variant when active model matches", () => {
const value = getConfiguredAgentVariant({
agent: { model: { providerID: "openai", modelID: "gpt-5.2" }, variant: "high" },
model: { providerID: "openai", modelID: "gpt-5.2", variants: { low: {}, high: {} } },
})

expect(value).toBe("high")
})

test("ignores configured variant when active model does not match", () => {
const value = getConfiguredAgentVariant({
agent: { model: { providerID: "openai", modelID: "gpt-5.2" }, variant: "high" },
model: { providerID: "anthropic", modelID: "claude-sonnet-4", variants: { low: {}, high: {} } },
})

expect(value).toBeUndefined()
})

test("prefers selected variant over configured variant", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: "xhigh",
configured: "high",
})

expect(value).toBe("xhigh")
})

test("treats default sentinel as explicit default", () => {
const value = resolveModelVariant({
variants: ["low", "high", "xhigh"],
selected: "default",
configured: "high",
})

expect(value).toBeUndefined()
})

test("cycles from default sentinel to first variant", () => {
const value = cycleModelVariant({
variants: ["low", "high", "xhigh"],
selected: "default",
configured: "high",
})

expect(value).toBe("low")
})

test("cycles through all variants from explicit selection", () => {
const variants = ["low", "high", "xhigh"]
const first = cycleModelVariant({ variants, selected: undefined, configured: "high" })
const second = cycleModelVariant({ variants, selected: first, configured: "high" })
const third = cycleModelVariant({ variants, selected: second, configured: "high" })
const fourth = cycleModelVariant({ variants, selected: third, configured: "high" })

expect(first).toBe("low")
expect(second).toBe("high")
expect(third).toBe("xhigh")
expect(fourth).toBeUndefined()
})
})
Loading