diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx
index 481b7922cf36d..843f8652e68e6 100644
--- a/packages/app/src/components/prompt-input-v2.tsx
+++ b/packages/app/src/components/prompt-input-v2.tsx
@@ -53,6 +53,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
+ ready: true,
paid: true,
loading: false,
},
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx
index 41cf7d30ca344..2a8b4def2cd22 100644
--- a/packages/app/src/components/prompt-input.tsx
+++ b/packages/app/src/components/prompt-input.tsx
@@ -284,6 +284,7 @@ export const PromptInput: Component = (props) => {
return text.trim().length === 0 && imageAttachments().length === 0 && commentCount() === 0
})
const stopping = createMemo(() => working() && blank())
+ const submitDisabled = createMemo(() => !stopping() && (!props.controls.model.ready || blank()))
const tip = () => {
if (stopping()) {
return (
@@ -1378,6 +1379,7 @@ export const PromptInput: Component = (props) => {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault()
if (event.repeat) return
+ if (!props.controls.model.ready) return
if (
working() &&
prompt
@@ -1518,7 +1520,7 @@ export const PromptInput: Component = (props) => {
role="textbox"
aria-multiline="true"
aria-label={placeholder()}
- contenteditable="true"
+ contenteditable={props.controls.model.ready}
autocapitalize={store.mode === "normal" ? "sentences" : "off"}
autocorrect={store.mode === "normal" ? "on" : "off"}
spellcheck={store.mode === "normal"}
@@ -1575,11 +1577,11 @@ export const PromptInput: Component = (props) => {
/>
-
+
["model"]
+ ready: boolean
paid: boolean
loading: boolean
}
diff --git a/packages/app/src/components/prompt-input/selection-transport.test.ts b/packages/app/src/components/prompt-input/selection-transport.test.ts
new file mode 100644
index 0000000000000..a3a1cf34f5d02
--- /dev/null
+++ b/packages/app/src/components/prompt-input/selection-transport.test.ts
@@ -0,0 +1,119 @@
+import { describe, expect, test } from "bun:test"
+import { ClientError } from "@opencode-ai/client/promise"
+import { createApiForServer } from "@/utils/server"
+import { createCompatibleApi } from "@/utils/server-compat"
+
+function setup(protocol: "v1" | "v2", promptResponse?: () => Response) {
+ const requests: Request[] = []
+ const legacyInputs: unknown[] = []
+ const fetcher = Object.assign(
+ async (input: string | URL | Request, init?: RequestInit) => {
+ const request = new Request(input, init)
+ requests.push(request)
+ if (request.url.endsWith("/prompt_async")) return new Response(undefined, { status: 204 })
+ if (request.url.endsWith("/prompt")) {
+ if (promptResponse) return promptResponse()
+ return Response.json({
+ data: {
+ admittedSeq: 1,
+ id: "msg_1",
+ sessionID: "ses_1",
+ timeCreated: 1,
+ prompt: { text: "hello" },
+ delivery: "steer",
+ },
+ })
+ }
+ return new Response(undefined, { status: 204 })
+ },
+ { preconnect: globalThis.fetch.preconnect },
+ )
+ const server = { url: "http://localhost:4096" }
+ const api = createCompatibleApi({
+ protocol: Promise.resolve(protocol),
+ current: createApiForServer({ server, fetch: fetcher }),
+ legacy: (() => ({
+ session: { promptAsync: async (input: unknown) => legacyInputs.push(input) },
+ })) as unknown as Parameters[0]["legacy"],
+ directory: "/repo",
+ })
+ const send = async (variant?: string) => {
+ await api.session.prompt({
+ sessionID: "ses_1",
+ id: "msg_1",
+ text: "hello",
+ selection: {
+ agent: "build",
+ model: { providerID: "anthropic", id: "sonnet", variant },
+ },
+ agent: "build",
+ model: { providerID: "anthropic", modelID: "sonnet" },
+ variant,
+ })
+ }
+ return { legacyInputs, requests, send }
+}
+
+describe("follow-up selection transport", () => {
+ test("admits V2 selection atomically with the prompt", async () => {
+ const fixture = setup("v2")
+
+ await fixture.send("high")
+
+ expect(fixture.requests.map((request) => new URL(request.url).pathname)).toEqual(["/api/session/ses_1/prompt"])
+ expect(await fixture.requests[0]!.json()).toMatchObject({
+ prompt: { text: "hello" },
+ selection: {
+ agent: "build",
+ model: { providerID: "anthropic", id: "sonnet", variant: "high" },
+ },
+ })
+ })
+
+ test("forwards V1 selection through the legacy prompt fields", async () => {
+ const fixture = setup("v1")
+
+ await fixture.send("high")
+
+ expect(fixture.requests).toEqual([])
+ expect(fixture.legacyInputs[0]).toMatchObject({
+ agent: "build",
+ model: { providerID: "anthropic", modelID: "sonnet" },
+ variant: "high",
+ })
+ })
+
+ test("rejects unsupported current-protocol error content", async () => {
+ const fixture = setup("v2", () => new Response("unauthorized", { status: 401 }))
+
+ const error = await fixture.send().catch((error) => error)
+
+ expect(error).toBeInstanceOf(ClientError)
+ expect(error.reason).toBe("UnsupportedContentType")
+ })
+
+ test("rejects malformed current-protocol success JSON", async () => {
+ const fixture = setup(
+ "v2",
+ () => new Response("not json", { status: 200, headers: { "content-type": "application/json" } }),
+ )
+
+ const error = await fixture.send().catch((error) => error)
+
+ expect(error).toBeInstanceOf(ClientError)
+ expect(error.reason).toBe("MalformedResponse")
+ })
+
+ test("wraps current-protocol response read failures", async () => {
+ const fixture = setup("v2", () => {
+ const response = Response.json({ data: {} })
+ Object.defineProperty(response, "text", { value: () => Promise.reject(new Error("read failed")) })
+ return response
+ })
+
+ const error = await fixture.send().catch((error) => error)
+
+ expect(error).toBeInstanceOf(ClientError)
+ expect(error.reason).toBe("Transport")
+ })
+})
diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts
index b3201b3ef68a0..95ec60c814583 100644
--- a/packages/app/src/components/prompt-input/submit.test.ts
+++ b/packages/app/src/components/prompt-input/submit.test.ts
@@ -18,28 +18,39 @@ const optimistic: Array<{
sessionID?: string
message: {
agent: string
- model: { providerID: string; modelID: string }
- variant?: string
+ model: { providerID: string; modelID: string; variant?: string }
}
}> = []
const optimisticSeeded: boolean[] = []
const storedSessions: Record> = {}
const promoted: Array<{ directory: string; sessionID: string }> = []
+const promotedVariants: Array = []
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
const syncedDirectories: string[] = []
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
const sentPrompts: string[] = []
const promptInputs: unknown[] = []
const sentCommands: unknown[] = []
+const interruptedSessions: string[] = []
+const selectionCalls: unknown[] = []
const commands: Array<{ name: string }> = []
let serverSessionSyncs = 0
+let syncedSession: {
+ id: string
+ agent?: string
+ model?: { providerID: string; id: string; variant?: string }
+} = { id: "session-1", agent: "agent", model: { providerID: "provider", id: "model" } }
let params: { id?: string } = {}
let search: { draftId?: string } = {}
let selected = "/repo/worktree-a"
let variant: string | undefined
+let selectedVariant: string | null | undefined
let permissionServer = "server-a"
let createSessionGate: Promise | undefined
+let localSessionReady = true
+let localSessionInitialized = true
+let modelSelectionReady = true
let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }]
const [promptStore, setPromptStore] = createStore({
@@ -100,6 +111,15 @@ const clientFor = (directory: string) => {
command: async (input: unknown) => {
sentCommands.push(input)
},
+ interrupt: async (input: { sessionID: string }) => {
+ interruptedSessions.push(input.sessionID)
+ },
+ switchAgent: async (input: unknown) => {
+ selectionCalls.push({ type: "agent", input })
+ },
+ switchModel: async (input: unknown) => {
+ selectionCalls.push({ type: "model", input })
+ },
shell: async (input: { sessionID: string; id?: string; command: string }) => {
sentShell.push(input)
},
@@ -132,8 +152,7 @@ beforeAll(async () => {
},
}))
- mock.module("@opencode-ai/ui/toast", () => ({
- Toast: { Region: () => null },
+ mock.module("@/utils/toast", () => ({
showToast: () => 0,
}))
@@ -144,15 +163,19 @@ beforeAll(async () => {
mock.module("@/context/local", () => ({
useLocal: () => ({
model: {
+ ready: () => modelSelectionReady,
current: () => ({ id: "model", provider: { id: "provider" } }),
- variant: { current: () => variant },
+ variant: { current: () => variant, selected: () => selectedVariant },
},
agent: {
current: () => ({ name: "agent" }),
},
session: {
- promote(directory: string, sessionID: string) {
+ ready: () => localSessionReady,
+ initialized: () => localSessionInitialized,
+ promote(directory: string, sessionID: string, state?: { variant?: string | null }) {
promoted.push({ directory, sessionID })
+ promotedVariants.push(state?.variant)
},
},
}),
@@ -235,7 +258,10 @@ beforeAll(async () => {
mock.module("@/context/server-sync", () => ({
useServerSync: () => () => ({
session: {
- remember: () => undefined,
+ get: () => syncedSession,
+ remember: (session: typeof syncedSession) => {
+ syncedSession = session
+ },
set: () => undefined,
sync: async () => {
serverSessionSyncs++
@@ -286,10 +312,13 @@ beforeEach(() => {
optimistic.length = 0
optimisticSeeded.length = 0
promoted.length = 0
+ promotedVariants.length = 0
promotedDrafts.length = 0
sentPrompts.length = 0
promptInputs.length = 0
sentCommands.length = 0
+ interruptedSessions.length = 0
+ selectionCalls.length = 0
commands.length = 0
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
params = {}
@@ -298,9 +327,14 @@ beforeEach(() => {
syncedDirectories.length = 0
selected = "/repo/worktree-a"
variant = undefined
+ selectedVariant = undefined
permissionServer = "server-a"
createSessionGate = undefined
+ localSessionReady = true
+ localSessionInitialized = true
+ modelSelectionReady = true
serverSessionSyncs = 0
+ syncedSession = { id: "session-1", agent: "agent", model: { providerID: "provider", id: "model" } }
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
})
@@ -494,6 +528,40 @@ describe("prompt submit worktree selection", () => {
])
})
+ test("submits changed selection atomically with an existing session prompt", async () => {
+ params = { id: "session-1" }
+ variant = "high"
+ syncedSession = { id: "session-1", agent: "plan", model: { providerID: "openai", id: "gpt" } }
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => ({ id: "session-1" }),
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ })
+
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await Bun.sleep(0)
+
+ expect(selectionCalls).toEqual([])
+ expect(promptInputs[0]).toMatchObject({
+ selection: {
+ agent: "agent",
+ model: { providerID: "provider", id: "model", variant: "high" },
+ },
+ })
+ expect(sentPrompts).toEqual(["/repo/main"])
+ })
+
test("submits slash commands through the current session API", async () => {
params = { id: "session-1" }
variant = "high"
@@ -536,8 +604,9 @@ describe("prompt submit worktree selection", () => {
test("uses an injected model selection", async () => {
params = { id: "session-1" }
const model = {
+ ready: () => true,
current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }),
- variant: { current: () => "draft-variant" },
+ variant: { current: () => "draft-variant", selected: () => undefined },
} as unknown as ModelSelection
const submit = createPromptSubmit({
prompt,
@@ -566,6 +635,141 @@ describe("prompt submit worktree selection", () => {
})
})
+ test("sends explicit default variant on optimistic prompts", async () => {
+ params = { id: "session-1" }
+ selectedVariant = null
+
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => ({ id: "session-1" }),
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ onSubmit: () => undefined,
+ })
+
+ const event = { preventDefault: () => undefined } as unknown as Event
+
+ await submit.handleSubmit(event)
+
+ expect(optimistic).toHaveLength(1)
+ expect(optimistic[0]).toMatchObject({
+ message: {
+ agent: "agent",
+ model: { providerID: "provider", modelID: "model", variant: "default" },
+ },
+ })
+ })
+
+ test("requires persisted session and model selections before submitting an existing session", async () => {
+ params = { id: "session-1" }
+ variant = "high"
+
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => ({ id: "session-1" }),
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ })
+
+ localSessionReady = false
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ expect(sentPrompts).toEqual([])
+ expect(optimistic).toEqual([])
+
+ localSessionReady = true
+ localSessionInitialized = false
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ expect(sentPrompts).toEqual([])
+ expect(optimistic).toEqual([])
+
+ localSessionInitialized = true
+ modelSelectionReady = false
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ expect(sentPrompts).toEqual([])
+ expect(optimistic).toEqual([])
+
+ selectedVariant = null
+ modelSelectionReady = true
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await Bun.sleep(0)
+
+ expect((promptInputs[0] as { variant?: string }).variant).toBe("default")
+ })
+
+ test("allows stopping while persisted model selection is loading", async () => {
+ params = { id: "session-1" }
+ promptValue = [{ type: "text", content: "", start: 0, end: 0 }]
+ modelSelectionReady = false
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => ({ id: "session-1" }),
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => true,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ })
+
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+ await Bun.sleep(0)
+
+ expect(interruptedSessions).toEqual(["session-1"])
+ })
+
+ test("preserves explicit default when promoting a new session", async () => {
+ selectedVariant = null
+
+ const submit = createPromptSubmit({
+ prompt,
+ info: () => undefined,
+ imageAttachments: () => [],
+ commentCount: () => 0,
+ autoAccept: () => false,
+ mode: () => "normal",
+ working: () => false,
+ editor: () => undefined,
+ queueScroll: () => undefined,
+ promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0),
+ addToHistory: () => undefined,
+ resetHistoryNavigation: () => undefined,
+ setMode: () => undefined,
+ setPopover: () => undefined,
+ onSubmit: () => undefined,
+ })
+
+ await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event)
+
+ expect(promotedVariants).toEqual([null])
+ expect(optimistic[0]?.message.model.variant).toBe("default")
+ })
+
test("seeds new sessions before optimistic prompts are added", async () => {
const submit = createPromptSubmit({
prompt,
diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts
index c82c3a439939e..d831be416381d 100644
--- a/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app/src/components/prompt-input/submit.ts
@@ -19,6 +19,7 @@ import { buildRequestParts } from "./build-request-parts"
import { setCursorPosition } from "./editor-dom"
import { formatServerError } from "@/utils/server-errors"
import { ScopedKey } from "@/utils/server-scope"
+import { resolveModelVariantForRequest } from "@opencode-ai/core/util/model-variant"
import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
@@ -165,9 +166,15 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
return false
}
+ const model = {
+ providerID: input.draft.model.providerID,
+ id: input.draft.model.modelID,
+ variant: input.draft.variant,
+ }
await input.api.prompt({
sessionID: input.draft.sessionID,
id: messageID,
+ selection: { agent: input.draft.agent, model },
agent: input.draft.agent,
model: input.draft.model,
variant: input.draft.variant,
@@ -336,9 +343,16 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const modelSelection = input.model ?? local.model
+ if (!modelSelection.ready()) return
+ if (params.id && (!local.session.ready() || !local.session.initialized())) return
+
const currentModel = modelSelection.current()
const currentAgent = local.agent.current()
- const variant = modelSelection.variant.current()
+ const selectedVariant = modelSelection.variant.selected()
+ const variant = resolveModelVariantForRequest({
+ selected: selectedVariant,
+ current: modelSelection.variant.current(),
+ })
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -423,7 +437,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
- variant: variant ?? null,
+ variant: selectedVariant,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx
index b99ce2db9085e..ecd989d4c65d5 100644
--- a/packages/app/src/context/local.tsx
+++ b/packages/app/src/context/local.tsx
@@ -3,51 +3,39 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
+import { createQuery } from "@tanstack/solid-query"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
import { useProviders } from "@/hooks/use-providers"
import { resolveDefaultModel } from "@/hooks/provider-catalog"
import { Persist, persisted } from "@/utils/persist"
import { hasCustomAgent, resolveAgent } from "./local-agent"
-import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
+import {
+ cycleModelVariant,
+ getConfiguredAgentVariant,
+ resolveModelVariant,
+ resolveModelVariantFromMessage,
+} from "@opencode-ai/core/util/model-variant"
import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useServerSDK } from "./server-sdk"
+import { useServerSync } from "./server-sync"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
+import { pathKey } from "@/utils/path-key"
+import { migrateModelSelection, type ModelKey, type ModelSelectionState } from "./model-selection-migration"
-export type ModelKey = { providerID: string; modelID: string; variant?: string }
+export type { ModelKey } from "./model-selection-migration"
-type State = {
- agent?: string
- model?: ModelKey
- variant?: string | null
-}
+type State = ModelSelectionState
type Saved = {
session: Record
}
-const WORKSPACE_KEY = "__workspace__"
const handoff = new Map()
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
-const migrate = (value: unknown) => {
- if (!value || typeof value !== "object") return { session: {} }
-
- const item = value as {
- session?: Record
- pick?: Record
- }
-
- if (item.session && typeof item.session === "object") return { session: item.session }
- if (!item.pick || typeof item.pick !== "object") return { session: {} }
-
- return {
- session: Object.fromEntries(Object.entries(item.pick).filter(([key]) => key !== WORKSPACE_KEY)),
- }
-}
-
const clone = (value: State | undefined) => {
if (!value) return
return {
@@ -63,9 +51,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const sdk = useSDK()
const sync = useSync()
const serverSDK = useServerSDK()
+ const serverSync = useServerSync()
const providers = useProviders(() => sdk().directory)
const models = useModels()
const settings = useSettings()
+ const agentsQuery = createQuery(() => serverSync().queryOptions.agents(pathKey(sdk().directory)))
const id = createMemo(() => params.id || undefined)
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
@@ -75,7 +65,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
- migrate,
+ migrate: migrateModelSelection,
},
createStore({
session: {},
@@ -134,6 +124,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
createEffect(() => {
const session = id()
if (!session) return
+ if (!savedReady()) return
const key = handoffKey(serverSDK().scope, sdk().directory, session)
const next = handoff.get(key)
@@ -192,19 +183,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return
}
+ const prev = scope()
batch(() => {
setStore("current", item.name)
setStore("last", {
type: "agent",
agent: item.name,
model: item.model,
- variant: item.variant ?? null,
+ variant: prev?.variant,
})
- const prev = scope()
const next = {
agent: item.name,
model: item.model ?? prev?.model,
- variant: item.variant ?? prev?.variant,
+ variant: prev?.variant,
} satisfies State
const session = id()
if (session) {
@@ -245,8 +236,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
- agent: { model: item.model, variant: item.variant },
- model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
+ configured: item.model
+ ? { providerID: item.model.providerID, id: item.model.modelID, variant: item.variant }
+ : undefined,
+ model: { providerID: model.provider.id, id: model.id, variants: model.variants },
})
}
@@ -325,12 +318,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
configured,
selected,
current() {
+ const selected = this.selected()
const resolved = resolveModelVariant({
variants: this.list(),
- selected: this.selected(),
+ selected,
configured: this.configured(),
})
if (resolved) return resolved
+ if (selected === null) return
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
@@ -358,6 +353,20 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}),
)
},
+ clear() {
+ startTransition(() =>
+ batch(() => {
+ const model = current()
+ setStore("last", {
+ type: "variant",
+ agent: agent.current()?.name,
+ model: model ? { providerID: model.provider.id, modelID: model.id } : null,
+ variant: undefined,
+ })
+ write({ variant: undefined })
+ }),
+ )
+ },
cycle() {
const items = this.list()
if (items.length === 0) return
@@ -378,6 +387,15 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent,
session: {
ready: savedReady,
+ initialized() {
+ return scope() !== undefined
+ },
+ initialize() {
+ const session = id()
+ if (!session || scope() !== undefined) return
+ if (agentsQuery.isLoading || !serverSync().child(sdk().directory)[0].provider_ready) return
+ setSaved("session", session, {})
+ },
reset() {
setStore({ draft: undefined, promoting: undefined })
},
@@ -387,7 +405,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
- if (dir === sdk().directory) {
+ if (dir === sdk().directory && savedReady()) {
setSaved("session", session, next)
}
@@ -401,10 +419,28 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
+ const restoredAgent = agentsQuery.data?.find((item) => item.name === msg.agent)
+ const restoredModel = models.find(msg.model)
+ if (!restoredAgent || !restoredModel) return
+ const configured = getConfiguredAgentVariant({
+ configured: restoredAgent.model
+ ? {
+ providerID: restoredAgent.model.providerID,
+ id: restoredAgent.model.modelID,
+ variant: restoredAgent.variant,
+ }
+ : undefined,
+ model: {
+ providerID: restoredModel.provider.id,
+ id: restoredModel.id,
+ variants: restoredModel.variants,
+ },
+ })
+
setSaved("session", session, {
agent: msg.agent,
model: msg.model,
- variant: msg.model?.variant ?? null,
+ variant: resolveModelVariantFromMessage({ variant: msg.model.variant, configured }),
})
},
},
diff --git a/packages/app/src/context/model-selection-migration.test.ts b/packages/app/src/context/model-selection-migration.test.ts
new file mode 100644
index 0000000000000..1a64120c01753
--- /dev/null
+++ b/packages/app/src/context/model-selection-migration.test.ts
@@ -0,0 +1,31 @@
+import { describe, expect, test } from "bun:test"
+import { migrateModelSelection } from "./model-selection-migration"
+
+describe("migrateModelSelection", () => {
+ test("migrates persisted default sentinels to explicit Default", () => {
+ expect(
+ migrateModelSelection({
+ session: {
+ ses_default: { agent: "build", variant: "default" },
+ ses_inherit: { agent: "build" },
+ },
+ }),
+ ).toEqual({
+ session: {
+ ses_default: { agent: "build", variant: null },
+ ses_inherit: { agent: "build" },
+ },
+ })
+ })
+
+ test("migrates legacy picks without the workspace entry", () => {
+ expect(
+ migrateModelSelection({
+ pick: {
+ __workspace__: { agent: "build", variant: "high" },
+ ses_legacy: { agent: "plan", variant: "high" },
+ },
+ }),
+ ).toEqual({ session: { ses_legacy: { agent: "plan", variant: "high" } } })
+ })
+})
diff --git a/packages/app/src/context/model-selection-migration.ts b/packages/app/src/context/model-selection-migration.ts
new file mode 100644
index 0000000000000..5350e8d45ee04
--- /dev/null
+++ b/packages/app/src/context/model-selection-migration.ts
@@ -0,0 +1,28 @@
+export type ModelKey = { providerID: string; modelID: string; variant?: string }
+
+export type ModelSelectionState = {
+ agent?: string
+ model?: ModelKey
+ variant?: string | null
+}
+
+const WORKSPACE_KEY = "__workspace__"
+
+export function migrateModelSelection(value: unknown) {
+ if (!value || typeof value !== "object") return { session: {} }
+
+ const item = value as {
+ session?: Record
+ pick?: Record
+ }
+ const sessions = item.session && typeof item.session === "object" ? item.session : item.pick
+ if (!sessions || typeof sessions !== "object") return { session: {} }
+
+ return {
+ session: Object.fromEntries(
+ Object.entries(sessions)
+ .filter(([key]) => key !== WORKSPACE_KEY)
+ .map(([key, state]) => [key, state?.variant === "default" ? { ...state, variant: null } : state]),
+ ),
+ }
+}
diff --git a/packages/app/src/context/model-variant.test.ts b/packages/app/src/context/model-variant.test.ts
deleted file mode 100644
index 583bc5c3dc71f..0000000000000
--- a/packages/app/src/context/model-variant.test.ts
+++ /dev/null
@@ -1,86 +0,0 @@
-import { describe, expect, test } from "bun:test"
-import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
-
-describe("model variant", () => {
- test("resolves configured agent variant when model matches", () => {
- const value = getConfiguredAgentVariant({
- agent: {
- model: { providerID: "openai", modelID: "gpt-5.2" },
- variant: "xhigh",
- },
- model: {
- providerID: "openai",
- modelID: "gpt-5.2",
- variants: { low: {}, high: {}, xhigh: {} },
- },
- })
-
- expect(value).toBe("xhigh")
- })
-
- test("ignores configured variant when model does not match", () => {
- const value = getConfiguredAgentVariant({
- agent: {
- model: { providerID: "openai", modelID: "gpt-5.2" },
- variant: "xhigh",
- },
- model: {
- providerID: "anthropic",
- modelID: "claude-sonnet-4",
- variants: { low: {}, high: {}, xhigh: {} },
- },
- })
-
- expect(value).toBeUndefined()
- })
-
- test("prefers selected variant over configured variant", () => {
- const value = resolveModelVariant({
- variants: ["low", "high", "xhigh"],
- selected: "high",
- configured: "xhigh",
- })
-
- expect(value).toBe("high")
- })
-
- test("lets an explicit default override the configured variant", () => {
- const value = resolveModelVariant({
- variants: ["low", "high", "xhigh"],
- selected: null,
- configured: "xhigh",
- })
-
- expect(value).toBeUndefined()
- })
-
- test("cycles from configured variant to next", () => {
- const value = cycleModelVariant({
- variants: ["low", "high", "xhigh"],
- selected: undefined,
- configured: "high",
- })
-
- expect(value).toBe("xhigh")
- })
-
- test("wraps from configured last variant to first", () => {
- const value = cycleModelVariant({
- variants: ["low", "high", "xhigh"],
- selected: undefined,
- configured: "xhigh",
- })
-
- expect(value).toBe("low")
- })
-
- test("cycles from an explicit default to the first variant", () => {
- const value = cycleModelVariant({
- variants: ["low", "high", "xhigh"],
- selected: null,
- configured: "xhigh",
- })
-
- expect(value).toBe("low")
- })
-})
diff --git a/packages/app/src/context/model-variant.ts b/packages/app/src/context/model-variant.ts
deleted file mode 100644
index 525acbba3219d..0000000000000
--- a/packages/app/src/context/model-variant.ts
+++ /dev/null
@@ -1,52 +0,0 @@
-type AgentModel = {
- providerID: string
- modelID: string
-}
-
-type Agent = {
- model?: AgentModel
- variant?: string
-}
-
-type Model = AgentModel & {
- variants?: Record
-}
-
-type VariantInput = {
- variants: string[]
- selected: string | null | 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 === null) 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 === null) 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]
- }
- 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]
-}
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx
index 2e647e0f4789c..e267275324dc7 100644
--- a/packages/app/src/pages/session.tsx
+++ b/packages/app/src/pages/session.tsx
@@ -563,21 +563,20 @@ export default function Page() {
if (path) void file.load(path)
})
- createEffect(
- on(
- () => lastUserMessage()?.id,
- () => {
- const msg = lastUserMessage()
- if (!msg) return
- syncSessionModel(local, msg)
- },
- ),
- )
+ createEffect(() => {
+ if (!local.session.ready() || !messagesReady()) return
+ const msg = lastUserMessage()
+ if (!msg) {
+ local.session.initialize()
+ return
+ }
+ syncSessionModel(local, msg)
+ })
let restoredModelSession: string | undefined
createEffect(() => {
const id = params.id
- if (!id || !prompt.ready() || !local.session.ready()) return
+ if (!id || !prompt.ready() || !local.session.ready() || !local.session.initialized()) return
if (restoredModelSession !== id) {
restoredModelSession = id
if (restorePromptModel(local, prompt)) return
diff --git a/packages/app/src/pages/session/composer/prompt-model-selection.ts b/packages/app/src/pages/session/composer/prompt-model-selection.ts
index fe890c7f4afef..eb9962dccd7dd 100644
--- a/packages/app/src/pages/session/composer/prompt-model-selection.ts
+++ b/packages/app/src/pages/session/composer/prompt-model-selection.ts
@@ -1,7 +1,7 @@
import { batch, createMemo, startTransition } from "solid-js"
+import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@opencode-ai/core/util/model-variant"
import { useModels } from "@/context/models"
import type { ModelKey, ModelSelection } from "@/context/local"
-import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
@@ -82,20 +82,24 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
const model = current()
if (!item || !model) return
return getConfiguredAgentVariant({
- agent: { model: item.model, variant: item.variant },
- model: { providerID: model.provider.id, modelID: model.id, variants: model.variants },
+ configured: item.model
+ ? { providerID: item.model.providerID, id: item.model.modelID, variant: item.variant }
+ : undefined,
+ model: { providerID: model.provider.id, id: model.id, variants: model.variants },
})
},
selected() {
return prompt.model.current()?.variant
},
current() {
+ const selected = this.selected()
const resolved = resolveModelVariant({
variants: this.list(),
- selected: this.selected(),
+ selected,
configured: this.configured(),
})
if (resolved) return resolved
+ if (selected === null) return
const model = current()
if (!model) return
const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id })
@@ -114,6 +118,13 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
}),
)
},
+ clear() {
+ startTransition(() => {
+ const model = current()
+ if (!model) return
+ prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: undefined })
+ })
+ },
cycle() {
const variants = this.list()
if (variants.length === 0) return
diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts
index a9b0070bc0254..635641712e9ed 100644
--- a/packages/app/src/pages/session/composer/session-composer-controls.ts
+++ b/packages/app/src/pages/session/composer/session-composer-controls.ts
@@ -34,6 +34,7 @@ export function createPromptInputController(input: {
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
return createMemo(() => {
+ const selection = input.model ?? local.model
return {
agents: {
available: sync().data.agent,
@@ -44,7 +45,8 @@ export function createPromptInputController(input: {
select: local.agent.set,
},
model: {
- selection: input.model ?? local.model,
+ selection,
+ ready: selection.ready() && (!input.sessionID() || (local.session.ready() && local.session.initialized())),
paid: providers.paid().length > 0,
loading:
(local.agent.visible() && agentsQuery.isLoading) ||
diff --git a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx
index 41d1f984bdeb1..b28ac247a80e7 100644
--- a/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx
+++ b/packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx
@@ -63,6 +63,7 @@ const controls = {
current: () => ({ id: "claude-3-7-sonnet", name: "Claude 3.7 Sonnet", provider: { id: "anthropic" } }),
variant: { list: () => [], current: () => undefined, set: () => {} },
},
+ ready: true,
paid: true,
loading: false,
},
diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts
index 201627b3691e8..108102258cfb4 100644
--- a/packages/app/src/pages/session/session-model-helpers.test.ts
+++ b/packages/app/src/pages/session/session-model-helpers.test.ts
@@ -13,7 +13,7 @@ const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
}) as UserMessage
describe("syncSessionModel", () => {
- test("restores the last message through session state", () => {
+ test("initializes session state after attempting message restoration", () => {
const calls: unknown[] = []
syncSessionModel(
@@ -22,6 +22,9 @@ describe("syncSessionModel", () => {
restore(value) {
calls.push(value)
},
+ initialize() {
+ calls.push("initialize")
+ },
reset() {},
},
},
@@ -30,6 +33,7 @@ describe("syncSessionModel", () => {
expect(calls).toEqual([
message({ model: { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } }),
+ "initialize",
])
})
})
@@ -43,6 +47,7 @@ describe("resetSessionModel", () => {
reset() {
calls.push("reset")
},
+ initialize() {},
restore() {},
},
})
@@ -52,7 +57,7 @@ describe("resetSessionModel", () => {
})
describe("syncPromptModel", () => {
- test("stores the effective session model in prompt state", () => {
+ test("stores the selected session variant in prompt state", () => {
const calls: unknown[] = []
syncPromptModel(
@@ -60,7 +65,7 @@ describe("syncPromptModel", () => {
model: {
current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }),
set() {},
- variant: { current: () => "high", set() {} },
+ variant: { selected: () => undefined, set() {}, clear() {} },
},
},
{
@@ -71,7 +76,7 @@ describe("syncPromptModel", () => {
},
)
- expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }])
+ expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: undefined }])
})
test("does not rewrite an unchanged prompt model", () => {
@@ -83,7 +88,7 @@ describe("syncPromptModel", () => {
model: {
current: () => ({ id: model.modelID, provider: { id: model.providerID } }),
set() {},
- variant: { current: () => model.variant, set() {} },
+ variant: { selected: () => model.variant, set() {}, clear() {} },
},
},
{
@@ -107,8 +112,9 @@ describe("restorePromptModel", () => {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
- current: () => undefined,
+ selected: () => undefined,
set: (variant) => calls.push(variant),
+ clear: () => calls.push("clear"),
},
},
},
@@ -124,6 +130,58 @@ describe("restorePromptModel", () => {
expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"])
})
+ test("restores an inherited variant without selecting Default", () => {
+ const calls: unknown[] = []
+ const restored = restorePromptModel(
+ {
+ model: {
+ current: () => ({ id: "gpt", provider: { id: "openai" } }),
+ set: (model) => calls.push(model),
+ variant: {
+ selected: () => null,
+ set: (variant) => calls.push(variant),
+ clear: () => calls.push("clear"),
+ },
+ },
+ },
+ {
+ model: {
+ current: () => ({ providerID: "anthropic", modelID: "claude", variant: undefined }),
+ set() {},
+ },
+ },
+ )
+
+ expect(restored).toBe(true)
+ expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "clear"])
+ })
+
+ test("restores an explicit Default variant", () => {
+ const calls: unknown[] = []
+ const restored = restorePromptModel(
+ {
+ model: {
+ current: () => ({ id: "gpt", provider: { id: "openai" } }),
+ set: (model) => calls.push(model),
+ variant: {
+ selected: () => undefined,
+ set: (variant) => calls.push(variant),
+ clear: () => calls.push("clear"),
+ },
+ },
+ },
+ {
+ model: {
+ current: () => ({ providerID: "anthropic", modelID: "claude", variant: null }),
+ set() {},
+ },
+ },
+ )
+
+ expect(restored).toBe(true)
+ expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, undefined])
+ })
+
test("does nothing without a persisted prompt model", () => {
const calls: unknown[] = []
const restored = restorePromptModel(
@@ -132,8 +190,9 @@ describe("restorePromptModel", () => {
current: () => ({ id: "gpt", provider: { id: "openai" } }),
set: (model) => calls.push(model),
variant: {
- current: () => undefined,
+ selected: () => undefined,
set: (variant) => calls.push(variant),
+ clear: () => calls.push("clear"),
},
},
},
diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts
index f2aadff27d84c..41294db6fb306 100644
--- a/packages/app/src/pages/session/session-model-helpers.ts
+++ b/packages/app/src/pages/session/session-model-helpers.ts
@@ -2,6 +2,7 @@ import type { UserMessage } from "@opencode-ai/sdk/v2"
type Local = {
session: {
+ initialize(): void
reset(): void
restore(msg: UserMessage): void
}
@@ -12,8 +13,9 @@ type ModelSelection = {
current(): { id: string; provider: { id: string } } | undefined
set(model: { providerID: string; modelID: string }): void
variant: {
- current(): string | undefined
+ selected(): string | null | undefined
set(variant: string | undefined): void
+ clear(): void
}
}
}
@@ -31,6 +33,7 @@ export const resetSessionModel = (local: Local) => {
export const syncSessionModel = (local: Local, msg: UserMessage) => {
local.session.restore(msg)
+ local.session.initialize()
}
export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
@@ -39,7 +42,7 @@ export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => {
const next = {
providerID: model.provider.id,
modelID: model.id,
- variant: local.model.variant.current(),
+ variant: local.model.variant.selected(),
}
const current = prompt.model.current()
if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant)
@@ -54,10 +57,11 @@ export const restorePromptModel = (local: ModelSelection, prompt: PromptState) =
if (
current?.provider.id === model.providerID &&
current.id === model.modelID &&
- local.model.variant.current() === (model.variant ?? undefined)
+ local.model.variant.selected() === model.variant
)
return true
local.model.set({ providerID: model.providerID, modelID: model.modelID })
- local.model.variant.set(model.variant ?? undefined)
+ if (model.variant === undefined) local.model.variant.clear()
+ if (model.variant !== undefined) local.model.variant.set(model.variant ?? undefined)
return true
}
diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts
index 1df1338b71e5d..f7174b3a1f12c 100644
--- a/packages/app/src/utils/server-compat.ts
+++ b/packages/app/src/utils/server-compat.ts
@@ -43,6 +43,7 @@ type LegacyPrompt = {
agent?: string
model?: { providerID: string; modelID: string }
variant?: string
+ selection?: { agent: string; model: { providerID: string; id: string; variant?: string } }
legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[]
}
type LegacyLocation = { directory?: string }
@@ -197,6 +198,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi {
async interrupt(value: Parameters[0]) {
await legacy().session.abort(value)
},
+ async switchAgent() {},
+ async switchModel() {},
async prompt(value: SessionPromptInput & LegacyPrompt) {
await legacy().session.promptAsync({
sessionID: value.sessionID,
diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts
index 1c8292ca9d957..7fa51acc26a6f 100644
--- a/packages/app/src/utils/server.ts
+++ b/packages/app/src/utils/server.ts
@@ -1,5 +1,5 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client"
-import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
+import { ClientError, OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { ServerConnection } from "@/context/server"
import { decode64 } from "@/utils/base64"
@@ -41,22 +41,93 @@ export function createSdkForServer({
})
}
-export function createApiForServer(input: {
- server: ServerConnection.HttpBase
- fetch?: typeof globalThis.fetch
-}): OpenCodeClient {
- return OpenCode.make({
+export function createApiForServer(input: { server: ServerConnection.HttpBase; fetch?: typeof globalThis.fetch }) {
+ const headers = input.server.password
+ ? {
+ Authorization: `Basic ${authTokenFromCredentials({
+ username: input.server.username,
+ password: input.server.password,
+ })}`,
+ }
+ : undefined
+ const client = OpenCode.make({
baseUrl: input.server.url,
fetch: input.fetch,
- headers: input.server.password
- ? {
- Authorization: `Basic ${authTokenFromCredentials({
- username: input.server.username,
- password: input.server.password,
- })}`,
+ headers,
+ })
+ return {
+ ...client,
+ session: {
+ ...client.session,
+ // The pinned App client predates the current nested prompt payload.
+ async prompt(
+ value: Parameters[0] & {
+ selection?: {
+ agent: string
+ model: { providerID: string; id: string; variant?: string }
+ }
+ },
+ options?: Parameters[1],
+ ) {
+ const requestHeaders = new Headers(headers)
+ for (const [key, header] of new Headers(options?.headers)) requestHeaders.set(key, header)
+ requestHeaders.set("content-type", "application/json")
+ const response = await (input.fetch ?? globalThis.fetch)(
+ new URL(`/api/session/${encodeURIComponent(value.sessionID)}/prompt`, input.server.url),
+ {
+ method: "POST",
+ signal: options?.signal,
+ headers: requestHeaders,
+ body: JSON.stringify({
+ id: value.id,
+ prompt: {
+ text: value.text,
+ files: value.files?.map((file) => ({
+ uri: file.uri,
+ name: file.name,
+ description: file.description,
+ source: file.mention,
+ })),
+ agents: value.agents?.map((agent) => ({ name: agent.name, source: agent.mention })),
+ },
+ delivery: value.delivery,
+ selection: value.selection,
+ resume: value.resume,
+ }),
+ },
+ ).catch((cause) => {
+ throw new ClientError("Transport", { cause })
+ })
+ if ([409, 404, 400, 401].includes(response.status)) throw await responseJson(response)
+ if (response.status !== 200) {
+ await cancelBody(response)
+ throw new ClientError("UnexpectedStatus", { cause: { status: response.status } })
}
- : undefined,
+ return (await responseJson(response)).data
+ },
+ },
+ }
+}
+
+async function responseJson(response: Response) {
+ const contentType = response.headers.get("content-type")
+ if (contentType?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json" && !contentType?.includes("+json")) {
+ await cancelBody(response)
+ throw new ClientError("UnsupportedContentType")
+ }
+ const text = await response.text().catch((cause) => {
+ throw new ClientError("Transport", { cause })
})
+ if (text === "") throw new ClientError("MalformedResponse")
+ try {
+ return JSON.parse(text)
+ } catch (cause) {
+ throw new ClientError("MalformedResponse", { cause })
+ }
+}
+
+async function cancelBody(response: Response) {
+ await response.body?.cancel().catch(() => undefined)
}
export type ServerApi = OpenCodeClient
diff --git a/packages/client/src/generated-effect/client.ts b/packages/client/src/generated-effect/client.ts
index 024c9782806c1..e2cee6ed2e480 100644
--- a/packages/client/src/generated-effect/client.ts
+++ b/packages/client/src/generated-effect/client.ts
@@ -112,12 +112,19 @@ type Endpoint3_6Input = {
readonly id?: Endpoint3_6Request["payload"]["id"]
readonly prompt: Endpoint3_6Request["payload"]["prompt"]
readonly delivery?: Endpoint3_6Request["payload"]["delivery"]
+ readonly selection?: Endpoint3_6Request["payload"]["selection"]
readonly resume?: Endpoint3_6Request["payload"]["resume"]
}
const Endpoint3_6 = (raw: RawClient["server.session"]) => (input: Endpoint3_6Input) =>
raw["session.prompt"]({
params: { sessionID: input["sessionID"] },
- payload: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
+ payload: {
+ id: input["id"],
+ prompt: input["prompt"],
+ delivery: input["delivery"],
+ selection: input["selection"],
+ resume: input["resume"],
+ },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
diff --git a/packages/client/src/generated/client.ts b/packages/client/src/generated/client.ts
index 27ec3d81ba2c0..52353f38f3375 100644
--- a/packages/client/src/generated/client.ts
+++ b/packages/client/src/generated/client.ts
@@ -372,7 +372,13 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/prompt`,
- body: { id: input["id"], prompt: input["prompt"], delivery: input["delivery"], resume: input["resume"] },
+ body: {
+ id: input["id"],
+ prompt: input["prompt"],
+ delivery: input["delivery"],
+ selection: input["selection"],
+ resume: input["resume"],
+ },
successStatus: 200,
declaredStatuses: [409, 404, 400, 401],
empty: false,
diff --git a/packages/client/src/generated/types.ts b/packages/client/src/generated/types.ts
index 3b3188c8742a4..a48588a9f871d 100644
--- a/packages/client/src/generated/types.ts
+++ b/packages/client/src/generated/types.ts
@@ -399,6 +399,10 @@ export type SessionsPromptInput = {
}>
}
readonly delivery?: "steer" | "queue" | null
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ } | null
readonly resume?: boolean | null
}["id"]
readonly prompt: {
@@ -417,6 +421,10 @@ export type SessionsPromptInput = {
}>
}
readonly delivery?: "steer" | "queue" | null
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ } | null
readonly resume?: boolean | null
}["prompt"]
readonly delivery?: {
@@ -435,8 +443,34 @@ export type SessionsPromptInput = {
}>
}
readonly delivery?: "steer" | "queue" | null
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ } | null
readonly resume?: boolean | null
}["delivery"]
+ readonly selection?: {
+ readonly id?: string | null
+ readonly prompt: {
+ readonly text: string
+ readonly files?: ReadonlyArray<{
+ readonly uri: string
+ readonly name?: string
+ readonly description?: string
+ readonly source?: { readonly start: number; readonly end: number; readonly text: string }
+ }>
+ readonly agents?: ReadonlyArray<{
+ readonly name: string
+ readonly source?: { readonly start: number; readonly end: number; readonly text: string }
+ }>
+ }
+ readonly delivery?: "steer" | "queue" | null
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ } | null
+ readonly resume?: boolean | null
+ }["selection"]
readonly resume?: {
readonly id?: string | null
readonly prompt: {
@@ -453,6 +487,10 @@ export type SessionsPromptInput = {
}>
}
readonly delivery?: "steer" | "queue" | null
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ } | null
readonly resume?: boolean | null
}["resume"]
}
@@ -477,6 +515,10 @@ export type SessionsPromptOutput = {
}>
}
readonly delivery: "steer" | "queue"
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ }
readonly timeCreated: number
readonly promotedSeq?: number
}
@@ -748,6 +790,10 @@ export type SessionsHistoryOutput = {
}>
}
readonly delivery: "steer" | "queue"
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ }
}
}
| {
@@ -775,6 +821,10 @@ export type SessionsHistoryOutput = {
}>
}
readonly delivery: "steer" | "queue"
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ }
}
}
| {
@@ -1206,6 +1256,10 @@ export type SessionsEventsOutput =
}>
}
readonly delivery: "steer" | "queue"
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ }
}
}
| {
@@ -1233,6 +1287,10 @@ export type SessionsEventsOutput =
}>
}
readonly delivery: "steer" | "queue"
+ readonly selection?: {
+ readonly agent: string
+ readonly model: { readonly id: string; readonly providerID: string; readonly variant?: string }
+ }
}
}
| {
diff --git a/packages/core/schema.json b/packages/core/schema.json
index d0eeeebd5c419..147a705c476fc 100644
--- a/packages/core/schema.json
+++ b/packages/core/schema.json
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
- "id": "f14a9b18-8207-487e-a3d3-227e629ba9ad",
- "prevIds": ["169a0f0f-d58f-479f-b024-fa1c7b9a09db"],
+ "id": "9af60fc5-6223-45eb-90f4-543e6ee23f9a",
+ "prevIds": ["f14a9b18-8207-487e-a3d3-227e629ba9ad"],
"ddl": [
{
"name": "workspace",
@@ -960,6 +960,16 @@
"entityType": "columns",
"table": "session_input"
},
+ {
+ "type": "text",
+ "notNull": false,
+ "autoincrement": false,
+ "default": null,
+ "generated": null,
+ "name": "selection",
+ "entityType": "columns",
+ "table": "session_input"
+ },
{
"type": "integer",
"notNull": true,
diff --git a/packages/core/src/database/migration.gen.ts b/packages/core/src/database/migration.gen.ts
index e6ea4eaa1477e..c44e67dd3175c 100644
--- a/packages/core/src/database/migration.gen.ts
+++ b/packages/core/src/database/migration.gen.ts
@@ -40,5 +40,6 @@ export const migrations = (
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
+ import("./migration/20260815132145_prompt_selection"),
])
).map((module) => module.default) satisfies DatabaseMigration.Migration[]
diff --git a/packages/core/src/database/migration/20260815132145_prompt_selection.ts b/packages/core/src/database/migration/20260815132145_prompt_selection.ts
new file mode 100644
index 0000000000000..f755e0e92b10e
--- /dev/null
+++ b/packages/core/src/database/migration/20260815132145_prompt_selection.ts
@@ -0,0 +1,11 @@
+import { Effect } from "effect"
+import type { DatabaseMigration } from "../migration"
+
+export default {
+ id: "20260815132145_prompt_selection",
+ up(tx) {
+ return Effect.gen(function* () {
+ yield* tx.run(`ALTER TABLE \`session_input\` ADD \`selection\` text;`)
+ })
+ },
+} satisfies DatabaseMigration.Migration
diff --git a/packages/core/src/database/schema.gen.ts b/packages/core/src/database/schema.gen.ts
index ed60fde6c55fe..4273a234f416d 100644
--- a/packages/core/src/database/schema.gen.ts
+++ b/packages/core/src/database/schema.gen.ts
@@ -160,6 +160,7 @@ export default {
\`session_id\` text NOT NULL,
\`prompt\` text NOT NULL,
\`delivery\` text NOT NULL,
+ \`selection\` text,
\`admitted_seq\` integer NOT NULL,
\`promoted_seq\` integer,
\`time_created\` integer NOT NULL,
diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts
index 2dabfb2d6fba2..b570ea8b41ef5 100644
--- a/packages/core/src/session.ts
+++ b/packages/core/src/session.ts
@@ -149,6 +149,7 @@ export interface Interface {
sessionID: SessionSchema.ID
prompt: PromptInput.Prompt
delivery?: SessionInput.Delivery
+ selection?: SessionInput.Selection
resume?: boolean
}) => Effect.Effect
readonly shell: (input: {
@@ -364,12 +365,13 @@ const layer = Layer.effect(
const prompt = resolvePrompt(input.prompt)
const messageID = input.id ?? SessionMessage.ID.create()
const delivery = input.delivery ?? "steer"
- const expected = { sessionID: input.sessionID, messageID, prompt, delivery }
+ const expected = { sessionID: input.sessionID, messageID, prompt, delivery, selection: input.selection }
const admitted = yield* SessionInput.admit(db, events, {
id: messageID,
sessionID: input.sessionID,
prompt,
delivery,
+ selection: input.selection,
}).pipe(
Effect.catchDefect((defect) =>
defect instanceof SessionInput.LifecycleConflict
@@ -391,7 +393,8 @@ const layer = Layer.effect(
return yield* new OperationUnavailableError({ operation: "skill" })
}),
switchAgent: Effect.fn("V2Session.switchAgent")(function* (input) {
- yield* result.get(input.sessionID)
+ const session = yield* result.get(input.sessionID)
+ if (session.agent === input.agent) return
yield* events.publish(SessionEvent.AgentSwitched, {
sessionID: input.sessionID,
messageID: SessionMessage.ID.create(),
diff --git a/packages/core/src/session/input.ts b/packages/core/src/session/input.ts
index 14b613678dbe0..22f56d0a09d6b 100644
--- a/packages/core/src/session/input.ts
+++ b/packages/core/src/session/input.ts
@@ -2,7 +2,7 @@ export * as SessionInput from "./input"
import { and, asc, eq, isNull, lte } from "drizzle-orm"
import { DateTime, Effect, Schema } from "effect"
-import { Admitted, Delivery } from "@opencode-ai/schema/session-input"
+import { Admitted, Delivery, Selection } from "@opencode-ai/schema/session-input"
import type { Database } from "../database/database"
import type { EventV2 } from "../event"
import { SessionEvent } from "./event"
@@ -13,10 +13,12 @@ import { SessionInputTable, SessionMessageTable } from "./sql"
type DatabaseService = Database.Interface["db"]
-export { Admitted, Delivery }
+export { Admitted, Delivery, Selection }
const decodePrompt = Schema.decodeUnknownSync(Prompt)
const encodePrompt = Schema.encodeSync(Prompt)
+const decodeSelection = Schema.decodeUnknownSync(Selection)
+const encodeSelection = Schema.encodeSync(Selection)
const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
Admitted.make({
@@ -25,6 +27,7 @@ const fromRow = (row: typeof SessionInputTable.$inferSelect): Admitted =>
sessionID: SessionSchema.ID.make(row.session_id),
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
+ ...(row.selection === null ? {} : { selection: decodeSelection(row.selection) }),
timeCreated: DateTime.makeUnsafe(row.time_created),
...(row.promoted_seq === null ? {} : { promotedSeq: row.promoted_seq }),
})
@@ -46,6 +49,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
+ readonly selection?: Selection
},
) {
const existing = yield* find(db, input.id)
@@ -58,6 +62,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
timestamp,
prompt: input.prompt,
delivery: input.delivery,
+ selection: input.selection,
})
.pipe(
Effect.flatMap((event) =>
@@ -70,6 +75,7 @@ export const admit = Effect.fn("SessionInput.admit")(function* (
sessionID: input.sessionID,
prompt: input.prompt,
delivery: input.delivery,
+ selection: input.selection,
timeCreated: timestamp,
}),
),
@@ -88,6 +94,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
+ readonly selection?: Selection
readonly timeCreated: DateTime.Utc
},
) {
@@ -106,6 +113,7 @@ export const projectAdmitted = Effect.fn("SessionInput.projectAdmitted")(functio
admitted_seq: input.admittedSeq,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
+ selection: input.selection,
time_created: DateTime.toEpochMillis(input.timeCreated),
})
.onConflictDoNothing()
@@ -122,6 +130,7 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
+ readonly selection?: Selection
readonly timeCreated: DateTime.Utc
readonly promotedSeq: number
},
@@ -159,6 +168,7 @@ export const projectPrompted = Effect.fn("SessionInput.projectPrompted")(functio
session_id: input.sessionID,
prompt: encodePrompt(input.prompt),
delivery: input.delivery,
+ selection: input.selection,
admitted_seq: input.promotedSeq,
promoted_seq: input.promotedSeq,
time_created: DateTime.toEpochMillis(input.timeCreated),
@@ -188,25 +198,81 @@ export const hasPending = Effect.fn("SessionInput.hasPending")(function* (
return row !== undefined
})
+const pendingSteers = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID, cutoff: number) {
+ return yield* db
+ .select()
+ .from(SessionInputTable)
+ .where(
+ and(
+ eq(SessionInputTable.session_id, sessionID),
+ isNull(SessionInputTable.promoted_seq),
+ eq(SessionInputTable.delivery, "steer"),
+ lte(SessionInputTable.admitted_seq, cutoff),
+ ),
+ )
+ .orderBy(asc(SessionInputTable.admitted_seq))
+ .all()
+ .pipe(Effect.orDie)
+})
+
+const nextQueued = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
+ return yield* db
+ .select()
+ .from(SessionInputTable)
+ .where(
+ and(
+ eq(SessionInputTable.session_id, sessionID),
+ isNull(SessionInputTable.promoted_seq),
+ eq(SessionInputTable.delivery, "queue"),
+ ),
+ )
+ .orderBy(asc(SessionInputTable.admitted_seq))
+ .limit(1)
+ .get()
+ .pipe(Effect.orDie)
+})
+
+export const selectionForPromotion = Effect.fn("SessionInput.selectionForPromotion")(function* (
+ db: DatabaseService,
+ sessionID: SessionSchema.ID,
+ delivery: Delivery,
+ cutoff: number,
+) {
+ const rows =
+ delivery === "steer"
+ ? yield* pendingSteers(db, sessionID, cutoff)
+ : [yield* nextQueued(db, sessionID), ...(yield* pendingSteers(db, sessionID, cutoff))].filter(
+ (row): row is typeof SessionInputTable.$inferSelect => row !== undefined,
+ )
+ const selection = rows.findLast((row) => row.selection !== null)?.selection
+ return selection === undefined ? undefined : decodeSelection(selection)
+})
+
export const equivalent = (
input: Admitted,
expected: {
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
+ readonly selection?: Selection
},
-) => input.delivery === expected.delivery && matchesPrompt(input, expected)
+) => input.delivery === expected.delivery && matchesPrompt(input, expected) && matchesSelection(input, expected)
const matchesPrompt = (input: Admitted, expected: { readonly sessionID: SessionSchema.ID; readonly prompt: Prompt }) =>
input.sessionID === expected.sessionID &&
JSON.stringify(encodePrompt(input.prompt)) === JSON.stringify(encodePrompt(expected.prompt))
+const matchesSelection = (input: Admitted, expected: { readonly selection?: Selection }) =>
+ JSON.stringify(input.selection === undefined ? undefined : encodeSelection(input.selection)) ===
+ JSON.stringify(expected.selection === undefined ? undefined : encodeSelection(expected.selection))
+
const matchesProjection = (
input: Admitted,
expected: {
readonly sessionID: SessionSchema.ID
readonly prompt: Prompt
readonly delivery: Delivery
+ readonly selection?: Selection
readonly timeCreated: DateTime.Utc
},
) =>
@@ -228,6 +294,7 @@ const publish = Effect.fn("SessionInput.publish")(function* (
messageID: id,
prompt: decodePrompt(row.prompt),
delivery: row.delivery,
+ ...(row.selection === null ? {} : { selection: decodeSelection(row.selection) }),
})
.pipe(
Effect.catchDefect((defect) =>
@@ -248,20 +315,7 @@ export const promoteSteers = Effect.fn("SessionInput.promoteSteers")(function* (
sessionID: SessionSchema.ID,
cutoff: number,
) {
- const rows = yield* db
- .select()
- .from(SessionInputTable)
- .where(
- and(
- eq(SessionInputTable.session_id, sessionID),
- isNull(SessionInputTable.promoted_seq),
- eq(SessionInputTable.delivery, "steer"),
- lte(SessionInputTable.admitted_seq, cutoff),
- ),
- )
- .orderBy(asc(SessionInputTable.admitted_seq))
- .all()
- .pipe(Effect.orDie)
+ const rows = yield* pendingSteers(db, sessionID, cutoff)
return yield* publish(db, events, sessionID, rows)
})
@@ -270,19 +324,6 @@ export const promoteNextQueued = Effect.fn("SessionInput.promoteNextQueued")(fun
events: EventV2.Interface,
sessionID: SessionSchema.ID,
) {
- const row = yield* db
- .select()
- .from(SessionInputTable)
- .where(
- and(
- eq(SessionInputTable.session_id, sessionID),
- isNull(SessionInputTable.promoted_seq),
- eq(SessionInputTable.delivery, "queue"),
- ),
- )
- .orderBy(asc(SessionInputTable.admitted_seq))
- .limit(1)
- .get()
- .pipe(Effect.orDie)
+ const row = yield* nextQueued(db, sessionID)
return row === undefined ? false : yield* publish(db, events, sessionID, [row]).pipe(Effect.as(true))
})
diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts
index 792067017d147..d3a50de73eefc 100644
--- a/packages/core/src/session/projector.ts
+++ b/packages/core/src/session/projector.ts
@@ -353,9 +353,18 @@ const layer = Layer.effectDiscard(
sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
+ selection: event.data.selection,
timeCreated: event.data.timestamp,
promotedSeq: event.durable.seq,
})
+ if (event.data.selection) {
+ yield* db
+ .update(SessionTable)
+ .set({ agent: event.data.selection.agent, model: event.data.selection.model })
+ .where(eq(SessionTable.id, event.data.sessionID))
+ .run()
+ .pipe(Effect.orDie)
+ }
yield* run(db, event)
}),
)
@@ -368,6 +377,7 @@ const layer = Layer.effectDiscard(
sessionID: event.data.sessionID,
prompt: event.data.prompt,
delivery: event.data.delivery,
+ selection: event.data.selection,
timeCreated: event.data.timestamp,
})
}),
diff --git a/packages/core/src/session/runner/llm.ts b/packages/core/src/session/runner/llm.ts
index 72c761e10d93e..fcce43b495ca2 100644
--- a/packages/core/src/session/runner/llm.ts
+++ b/packages/core/src/session/runner/llm.ts
@@ -176,24 +176,32 @@ const layer = Layer.effect(
step: number,
recoverOverflow?: typeof compaction.compactAfterOverflow,
) {
- const session = yield* getSession(sessionID)
- if (session.location.directory !== location.directory || session.location.workspaceID !== location.workspaceID)
+ const initial = yield* getSession(sessionID)
+ if (initial.location.directory !== location.directory || initial.location.workspaceID !== location.workspaceID)
return yield* Effect.interrupt
- const agent = yield* agents.select(session.agent)
- const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(agent), session.id)
- const toolFibers = yield* FiberSet.make()
- let needsContinuation = false
- let currentStep = step
- if (promotion) {
- const cutoff = yield* EventV2.latestSequence(db, session.id)
+ const cutoff = promotion ? yield* EventV2.latestSequence(db, initial.id) : -1
+ const pendingSelection = promotion
+ ? yield* SessionInput.selectionForPromotion(db, initial.id, promotion, cutoff)
+ : undefined
+ const turnSelection = pendingSelection ?? { agent: initial.agent, model: initial.model }
+ const initialAgent = yield* agents.select(turnSelection.agent)
+ const initialized = yield* SessionContextEpoch.initialize(db, loadSystemContext(initialAgent), initial.id)
+ const promoted = yield* Effect.gen(function* () {
+ if (!promotion) return 0
let promoted = 0
- if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
+ if (promotion === "steer") promoted = yield* SessionInput.promoteSteers(db, events, initial.id, cutoff)
if (promotion === "queue") {
- promoted += Number(yield* SessionInput.promoteNextQueued(db, events, session.id))
- promoted += yield* SessionInput.promoteSteers(db, events, session.id, cutoff)
+ promoted += Number(yield* SessionInput.promoteNextQueued(db, events, initial.id))
+ promoted += yield* SessionInput.promoteSteers(db, events, initial.id, cutoff)
}
- if (promoted > 0) currentStep = 1
- }
+ return promoted
+ })
+ const projected = promoted > 0 ? yield* getSession(sessionID) : initial
+ const session = { ...projected, agent: turnSelection.agent, model: turnSelection.model }
+ const agent = yield* agents.select(session.agent)
+ const toolFibers = yield* FiberSet.make()
+ let needsContinuation = false
+ let currentStep = promoted > 0 ? 1 : step
const system =
initialized ?? (yield* SessionContextEpoch.prepare(db, events, loadSystemContext(agent), session.id))
const model = yield* models.resolve(session)
diff --git a/packages/core/src/session/sql.ts b/packages/core/src/session/sql.ts
index 264a1d2cca0a3..a306770afd596 100644
--- a/packages/core/src/session/sql.ts
+++ b/packages/core/src/session/sql.ts
@@ -147,6 +147,7 @@ export const SessionInputTable = sqliteTable(
.references(() => SessionTable.id, { onDelete: "cascade" }),
prompt: text({ mode: "json" }).notNull().$type(),
delivery: text().$type().notNull(),
+ selection: text({ mode: "json" }).$type(),
admitted_seq: integer().notNull(),
promoted_seq: integer(),
time_created: integer()
diff --git a/packages/core/src/util/model-variant.ts b/packages/core/src/util/model-variant.ts
new file mode 100644
index 0000000000000..3fa264423ae0c
--- /dev/null
+++ b/packages/core/src/util/model-variant.ts
@@ -0,0 +1,71 @@
+type ModelRef = {
+ providerID: string
+ id: string
+}
+
+type ConfiguredModel = ModelRef & {
+ variant?: string
+}
+
+type Model = ModelRef & {
+ variants?: Record
+}
+
+// selected: string = user-chosen variant name
+// selected: null = user explicitly chose "default" (clears any agent-configured variant)
+// selected: undefined = no user choice yet (fall back to agent-configured variant)
+type VariantInput = {
+ variants: string[]
+ selected: string | null | undefined
+ configured: string | undefined
+}
+
+export function getConfiguredAgentVariant(input: {
+ configured: ConfiguredModel | undefined
+ model: Model | undefined
+}) {
+ if (!input.configured?.variant) return undefined
+ if (!input.model?.variants) return undefined
+ if (input.configured.providerID !== input.model.providerID) return undefined
+ if (input.configured.id !== input.model.id) return undefined
+ if (!(input.configured.variant in input.model.variants)) return undefined
+ return input.configured.variant
+}
+
+export function resolveModelVariant(input: VariantInput) {
+ if (input.selected === null) 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 resolveModelVariantForRequest(input: {
+ selected: string | null | undefined
+ current: string | undefined
+}) {
+ if (input.selected === null) return "default"
+ return input.current
+}
+
+export function resolveModelVariantFromMessage(input: { variant: string | undefined; configured: string | undefined }) {
+ if (input.variant === "default") return null
+ if (input.variant === input.configured) return undefined
+ return input.variant
+}
+
+export function cycleModelVariant(input: VariantInput) {
+ if (input.variants.length === 0) return undefined
+ if (input.selected === null) 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]
+ }
+ // No explicit selection: start cycling from the agent-configured variant.
+ 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]
+}
diff --git a/packages/core/test/session-create.test.ts b/packages/core/test/session-create.test.ts
index 4688ede82d18e..2e541c2b167ca 100644
--- a/packages/core/test/session-create.test.ts
+++ b/packages/core/test/session-create.test.ts
@@ -350,6 +350,21 @@ describe("SessionV2.create", () => {
}),
)
+ it.effect("ignores an agent switch when the selected agent is unchanged", () =>
+ Effect.gen(function* () {
+ const session = yield* SessionV2.Service
+ const agent = AgentV2.ID.make("plan")
+ const created = yield* session.create({ location, agent })
+
+ yield* session.switchAgent({ sessionID: created.id, agent })
+
+ const { db } = yield* Database.Service
+ expect(
+ yield* db.select().from(EventTable).where(eq(EventTable.aggregate_id, created.id)).all().pipe(Effect.orDie),
+ ).toHaveLength(1)
+ }),
+ )
+
it.effect("switches the selected model through the durable Session event", () =>
Effect.gen(function* () {
const session = yield* SessionV2.Service
diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts
index c6bc9430b3ac0..7807ece13e164 100644
--- a/packages/core/test/session-prompt.test.ts
+++ b/packages/core/test/session-prompt.test.ts
@@ -18,6 +18,9 @@ import { SessionExecution } from "@opencode-ai/core/session/execution"
import { SessionInput } from "@opencode-ai/core/session/input"
import { SessionInputTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
import { SessionStore } from "@opencode-ai/core/session/store"
+import { AgentV2 } from "@opencode-ai/core/agent"
+import { ModelV2 } from "@opencode-ai/core/model"
+import { ProviderV2 } from "@opencode-ai/core/provider"
import { testEffect } from "./lib/effect"
const executionCalls: SessionV2.ID[] = []
@@ -50,6 +53,15 @@ const it = testEffect(
)
const sessionID = SessionV2.ID.make("ses_prompt_test")
const messageID = SessionMessage.ID.create()
+const promptSelection = (agent: string) =>
+ SessionInput.Selection.make({
+ agent: AgentV2.ID.make(agent),
+ model: ModelV2.Ref.make({
+ providerID: ProviderV2.ID.make("anthropic"),
+ id: ModelV2.ID.make("sonnet"),
+ variant: ModelV2.VariantID.make("high"),
+ }),
+ })
const setup = Effect.gen(function* () {
const { db } = yield* Database.Service
@@ -162,6 +174,30 @@ describe("SessionV2.prompt", () => {
}),
)
+ it.effect("applies an admitted selection only when the prompt is promoted", () =>
+ Effect.gen(function* () {
+ yield* setup
+ const { db } = yield* Database.Service
+ const session = yield* SessionV2.Service
+ const events = yield* EventV2.Service
+ const selection = promptSelection("review")
+
+ const message = yield* session.prompt({
+ sessionID,
+ prompt: Prompt.make({ text: "Review this change" }),
+ selection,
+ resume: false,
+ })
+
+ expect(message).toMatchObject({ selection })
+ expect(yield* session.get(sessionID)).not.toMatchObject(selection)
+
+ yield* SessionInput.promoteSteers(db, events, sessionID, Number.MAX_SAFE_INTEGER)
+
+ expect(yield* session.get(sessionID)).toMatchObject(selection)
+ }),
+ )
+
it.effect("resolves attachment MIME before admission", () =>
Effect.gen(function* () {
yield* setup
@@ -339,6 +375,33 @@ describe("SessionV2.prompt", () => {
}),
)
+ it.effect("rejects reuse of one ID with a different selection", () =>
+ Effect.gen(function* () {
+ yield* setup
+ const session = yield* SessionV2.Service
+ const prompt = Prompt.make({ text: "Fix the failing tests" })
+
+ yield* session.prompt({
+ id: messageID,
+ sessionID,
+ prompt,
+ selection: promptSelection("build"),
+ resume: false,
+ })
+ const failure = yield* session
+ .prompt({
+ id: messageID,
+ sessionID,
+ prompt,
+ selection: promptSelection("plan"),
+ resume: false,
+ })
+ .pipe(Effect.flip)
+
+ expect(failure._tag).toBe("Session.PromptConflictError")
+ }),
+ )
+
it.effect("returns one recorded message to concurrent exact retries", () =>
Effect.gen(function* () {
yield* setup
diff --git a/packages/core/test/session-runner.test.ts b/packages/core/test/session-runner.test.ts
index 5b40258b2f31a..97292c9941eec 100644
--- a/packages/core/test/session-runner.test.ts
+++ b/packages/core/test/session-runner.test.ts
@@ -630,6 +630,59 @@ describe("SessionRunnerLLM", () => {
}),
)
+ it.effect("runs a promoted prompt with its admitted agent and model selection", () =>
+ Effect.gen(function* () {
+ yield* setup
+ const session = yield* SessionV2.Service
+ const events = yield* EventV2.Service
+ const agent = AgentV2.ID.make("review")
+ skillBaselines.set(agent, "Review guidance")
+ skillBaselines.set(AgentV2.ID.make("later"), "Later guidance")
+ let switched = false
+ yield* events.listen((event) => {
+ if (event.type !== SessionEvent.Prompted.type || switched) return Effect.void
+ switched = true
+ return Effect.gen(function* () {
+ yield* events.publish(SessionEvent.AgentSwitched, {
+ sessionID,
+ messageID: SessionMessage.ID.create(),
+ timestamp: DateTime.makeUnsafe(1),
+ agent: "later",
+ })
+ yield* events.publish(SessionEvent.ModelSwitched, {
+ sessionID,
+ messageID: SessionMessage.ID.create(),
+ timestamp: DateTime.makeUnsafe(2),
+ model: { providerID: ProviderV2.ID.make("fake"), id: ModelV2.ID.make("fake-model") },
+ })
+ })
+ })
+ requests.length = 0
+
+ yield* session.prompt({
+ sessionID,
+ prompt: Prompt.make({ text: "Review this change" }),
+ selection: {
+ agent,
+ model: {
+ providerID: ProviderV2.ID.make("fake"),
+ id: ModelV2.ID.make("replacement"),
+ },
+ },
+ resume: false,
+ })
+ yield* session.resume(sessionID)
+
+ expect(requests).toHaveLength(1)
+ expect(requests[0]?.model).toBe(replacementModel)
+ expect(requests[0]?.system.map((part) => part.text)).toEqual(["Initial context\n\nReview guidance"])
+ expect(yield* session.get(sessionID)).toMatchObject({
+ agent: "later",
+ model: { id: "fake-model", providerID: "fake" },
+ })
+ }),
+ )
+
it.effect("streams one request with registry definitions from chronological V2 user history", () =>
Effect.gen(function* () {
yield* setup
@@ -2436,9 +2489,18 @@ describe("SessionRunnerLLM", () => {
const defect = new Error("fail after prompt promotion")
let fail = true
yield* events.project(SessionEvent.Prompted, () => (fail ? Effect.die(defect) : Effect.void))
- yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Recover promoted input" }), resume: false })
+ yield* session.prompt({
+ sessionID,
+ prompt: Prompt.make({ text: "Recover promoted input" }),
+ selection: {
+ agent: AgentV2.ID.make("review"),
+ model: { providerID: ProviderV2.ID.make("fake"), id: ModelV2.ID.make("replacement") },
+ },
+ resume: false,
+ })
expect(yield* session.resume(sessionID).pipe(Effect.catchDefect(Effect.succeed))).toBe(defect)
+ expect(yield* session.get(sessionID)).not.toMatchObject({ agent: "review", model: { id: "replacement" } })
fail = false
requests.length = 0
response = [
@@ -2451,6 +2513,7 @@ describe("SessionRunnerLLM", () => {
while (requests.length === 0) yield* Effect.yieldNow
expect(userTexts(requests[0]!)).toEqual(["Recover promoted input"])
+ expect(yield* session.get(sessionID)).toMatchObject({ agent: "review", model: { id: "replacement" } })
}),
)
diff --git a/packages/core/test/util/model-variant.test.ts b/packages/core/test/util/model-variant.test.ts
new file mode 100644
index 0000000000000..56ebcec520809
--- /dev/null
+++ b/packages/core/test/util/model-variant.test.ts
@@ -0,0 +1,143 @@
+import { describe, expect, test } from "bun:test"
+import {
+ cycleModelVariant,
+ getConfiguredAgentVariant,
+ resolveModelVariant,
+ resolveModelVariantForRequest,
+ resolveModelVariantFromMessage,
+} from "../../src/util/model-variant"
+
+describe("model variant", () => {
+ test("resolves configured agent variant when model matches", () => {
+ const value = getConfiguredAgentVariant({
+ configured: {
+ providerID: "openai",
+ id: "gpt-5.2",
+ variant: "xhigh",
+ },
+ model: {
+ providerID: "openai",
+ id: "gpt-5.2",
+ variants: { low: {}, high: {}, xhigh: {} },
+ },
+ })
+
+ expect(value).toBe("xhigh")
+ })
+
+ test("ignores configured variant when model does not match", () => {
+ const value = getConfiguredAgentVariant({
+ configured: {
+ providerID: "openai",
+ id: "gpt-5.2",
+ variant: "xhigh",
+ },
+ model: {
+ providerID: "anthropic",
+ id: "claude-sonnet-4",
+ variants: { low: {}, high: {}, xhigh: {} },
+ },
+ })
+
+ expect(value).toBeUndefined()
+ })
+
+ test("prefers selected variant over configured variant", () => {
+ const value = resolveModelVariant({
+ variants: ["low", "high", "xhigh"],
+ selected: "high",
+ configured: "xhigh",
+ })
+
+ expect(value).toBe("high")
+ })
+
+ test("lets an explicit default override the configured variant", () => {
+ const value = resolveModelVariant({
+ variants: ["low", "high", "xhigh"],
+ selected: null,
+ configured: "xhigh",
+ })
+
+ expect(value).toBeUndefined()
+ })
+
+ test("cycles from configured variant to next", () => {
+ const value = cycleModelVariant({
+ variants: ["low", "high", "xhigh"],
+ selected: undefined,
+ configured: "high",
+ })
+
+ expect(value).toBe("xhigh")
+ })
+
+ test("wraps from configured last variant to first", () => {
+ const value = cycleModelVariant({
+ variants: ["low", "high", "xhigh"],
+ selected: undefined,
+ configured: "xhigh",
+ })
+
+ expect(value).toBe("low")
+ })
+
+ test("cycles from an explicit default to the first variant", () => {
+ const value = cycleModelVariant({
+ variants: ["low", "high", "xhigh"],
+ selected: null,
+ configured: "xhigh",
+ })
+
+ expect(value).toBe("low")
+ })
+
+ test("cycles through all variants from explicit selection", () => {
+ const variants = ["low", "high", "xhigh"]
+ const first = cycleModelVariant({ variants, selected: undefined, configured: undefined })
+ const second = cycleModelVariant({ variants, selected: first, configured: undefined })
+ const third = cycleModelVariant({ variants, selected: second, configured: undefined })
+ const fourth = cycleModelVariant({ variants, selected: third, configured: undefined })
+
+ expect(first).toBe("low")
+ expect(second).toBe("high")
+ expect(third).toBe("xhigh")
+ expect(fourth).toBeUndefined()
+ })
+
+ test("sends explicit default as request sentinel", () => {
+ const value = resolveModelVariantForRequest({ selected: null, current: undefined })
+
+ expect(value).toBe("default")
+ })
+
+ test("keeps current variant for requests without explicit default", () => {
+ const value = resolveModelVariantForRequest({ selected: undefined, current: "xhigh" })
+
+ expect(value).toBe("xhigh")
+ })
+
+ test("restores explicit default from message sentinel", () => {
+ const value = resolveModelVariantFromMessage({ variant: "default", configured: "high" })
+
+ expect(value).toBeNull()
+ })
+
+ test("keeps absent message variant as inherit", () => {
+ const value = resolveModelVariantFromMessage({ variant: undefined, configured: "high" })
+
+ expect(value).toBeUndefined()
+ })
+
+ test("restores a configured message variant as inherit", () => {
+ const value = resolveModelVariantFromMessage({ variant: "high", configured: "high" })
+
+ expect(value).toBeUndefined()
+ })
+
+ test("restores a non-configured message variant as an override", () => {
+ const value = resolveModelVariantFromMessage({ variant: "high", configured: "low" })
+
+ expect(value).toBe("high")
+ })
+})
diff --git a/packages/opencode/test/agent/agent.test.ts b/packages/opencode/test/agent/agent.test.ts
index efeaf408a259b..cefdc53956e6e 100644
--- a/packages/opencode/test/agent/agent.test.ts
+++ b/packages/opencode/test/agent/agent.test.ts
@@ -753,3 +753,26 @@ it.instance(
},
},
)
+
+it.instance(
+ "agent variant can be set from config",
+ () =>
+ Effect.gen(function* () {
+ const build = yield* load((svc) => svc.get("build"))
+ expect(build?.variant).toBe("high")
+ }),
+ {
+ config: {
+ agent: {
+ build: { variant: "high" },
+ },
+ },
+ },
+)
+
+it.instance("agent variant defaults to undefined when not set", () =>
+ Effect.gen(function* () {
+ const build = yield* load((svc) => svc.get("build"))
+ expect(build?.variant).toBeUndefined()
+ }),
+)
diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts
index 8ce85ef79686d..65f5b947cc594 100644
--- a/packages/protocol/src/groups/session.ts
+++ b/packages/protocol/src/groups/session.ts
@@ -208,6 +208,7 @@ export const makeSessionGroup = (sessionLo
id: SessionMessage.ID.pipe(Schema.optional),
prompt: PromptInput.Prompt,
delivery: SessionInput.Delivery.pipe(Schema.optional),
+ selection: SessionInput.Selection.pipe(Schema.optional),
resume: Schema.Boolean.pipe(Schema.optional),
}),
success: Schema.Struct({ data: SessionInput.Admitted }),
diff --git a/packages/schema/src/session-event.ts b/packages/schema/src/session-event.ts
index 3a559c3e38a40..7ab4a0261bdef 100644
--- a/packages/schema/src/session-event.ts
+++ b/packages/schema/src/session-event.ts
@@ -12,6 +12,7 @@ import { SessionID } from "./session-id"
import { Location } from "./location"
import { SessionMessage } from "./session-message"
import { Revert } from "./revert"
+import { Selection } from "./session-input"
export { FileAttachment }
@@ -33,6 +34,7 @@ const PromptFields = {
messageID: SessionMessage.ID,
prompt: Prompt,
delivery: Delivery,
+ selection: Selection.pipe(optional),
}
const options = {
diff --git a/packages/schema/src/session-input.ts b/packages/schema/src/session-input.ts
index 40babac105f66..f21c6ed7ca89e 100644
--- a/packages/schema/src/session-input.ts
+++ b/packages/schema/src/session-input.ts
@@ -7,10 +7,18 @@ import { DateTimeUtcFromMillis, NonNegativeInt } from "./schema"
import { SessionDelivery } from "./session-delivery"
import { SessionID } from "./session-id"
import { SessionMessage } from "./session-message"
+import { Agent } from "./agent"
+import { Model } from "./model"
export const Delivery = SessionDelivery.Delivery
export type Delivery = SessionDelivery.Delivery
+export interface Selection extends Schema.Schema.Type {}
+export const Selection = Schema.Struct({
+ agent: Agent.ID,
+ model: Model.Ref,
+}).annotate({ identifier: "SessionInput.Selection" })
+
export interface Admitted extends Schema.Schema.Type {}
export const Admitted = Schema.Struct({
admittedSeq: NonNegativeInt,
@@ -18,6 +26,7 @@ export const Admitted = Schema.Struct({
sessionID: SessionID,
prompt: Prompt,
delivery: Delivery,
+ selection: Selection.pipe(optional),
timeCreated: DateTimeUtcFromMillis,
promotedSeq: NonNegativeInt.pipe(optional),
}).annotate({ identifier: "SessionInput.Admitted" })
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index 9ed0084aac840..8a61822151595 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -195,6 +195,7 @@ import type {
SessionGetResponses,
SessionInitErrors,
SessionInitResponses,
+ SessionInputSelection,
SessionListErrors,
SessionListResponses,
SessionMessageErrors,
@@ -5625,6 +5626,7 @@ export class Session3 extends HeyApiClient {
id?: string
prompt?: PromptInput
delivery?: "steer" | "queue"
+ selection?: SessionInputSelection
resume?: boolean
},
options?: Options,
@@ -5638,6 +5640,7 @@ export class Session3 extends HeyApiClient {
{ in: "body", key: "id" },
{ in: "body", key: "prompt" },
{ in: "body", key: "delivery" },
+ { in: "body", key: "selection" },
{ in: "body", key: "resume" },
],
},
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 90c91e9158ccd..46ed9d4f7858c 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -857,6 +857,7 @@ export type GlobalEvent = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
| {
@@ -868,6 +869,7 @@ export type GlobalEvent = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
| {
@@ -3065,6 +3067,11 @@ export type PromptAgentAttachment = {
source?: PromptSource
}
+export type SessionInputSelection = {
+ agent: string
+ model: ModelRef
+}
+
export type SessionErrorUnknown = {
type: "unknown"
message: string
@@ -3363,6 +3370,7 @@ export type SyncEventSessionNextPrompted = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
}
@@ -3381,6 +3389,7 @@ export type SyncEventSessionNextPromptAdmitted = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
}
@@ -3942,6 +3951,7 @@ export type SessionInputAdmitted = {
sessionID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
timeCreated: number
promotedSeq?: number
}
@@ -4240,6 +4250,7 @@ export type SessionNextPrompted = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
@@ -4261,6 +4272,7 @@ export type SessionNextPromptAdmitted = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
@@ -6290,6 +6302,7 @@ export type EventSessionNextPrompted = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
@@ -6302,6 +6315,7 @@ export type EventSessionNextPromptAdmitted = {
messageID: string
prompt: Prompt
delivery: "steer" | "queue"
+ selection?: SessionInputSelection
}
}
@@ -11555,6 +11569,7 @@ export type V2SessionPromptData = {
id?: string
prompt: PromptInput
delivery?: "steer" | "queue"
+ selection?: SessionInputSelection
resume?: boolean
}
path: {
diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json
index afe14bb604cc2..df79dc823a824 100644
--- a/packages/sdk/openapi.json
+++ b/packages/sdk/openapi.json
@@ -10600,6 +10600,9 @@
"type": "string",
"enum": ["steer", "queue"]
},
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
+ },
"resume": {
"type": "boolean"
}
@@ -17845,6 +17848,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
@@ -17885,6 +17891,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
@@ -24608,6 +24617,19 @@
"required": ["name"],
"additionalProperties": false
},
+ "SessionInputSelection": {
+ "type": "object",
+ "properties": {
+ "agent": {
+ "type": "string"
+ },
+ "model": {
+ "$ref": "#/components/schemas/ModelRef"
+ }
+ },
+ "required": ["agent", "model"],
+ "additionalProperties": false
+ },
"SessionErrorUnknown": {
"type": "object",
"properties": {
@@ -25486,6 +25508,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
@@ -25547,6 +25572,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
@@ -27359,6 +27387,9 @@
"type": "string",
"enum": ["steer", "queue"]
},
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
+ },
"timeCreated": {
"type": "number"
},
@@ -28200,6 +28231,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
@@ -28262,6 +28296,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
@@ -34256,6 +34293,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
@@ -34296,6 +34336,9 @@
"delivery": {
"type": "string",
"enum": ["steer", "queue"]
+ },
+ "selection": {
+ "$ref": "#/components/schemas/SessionInputSelection"
}
},
"required": ["timestamp", "sessionID", "messageID", "prompt", "delivery"],
diff --git a/packages/server/src/handlers/session.ts b/packages/server/src/handlers/session.ts
index 5b7d354b04fc3..8f1df63d64a6b 100644
--- a/packages/server/src/handlers/session.ts
+++ b/packages/server/src/handlers/session.ts
@@ -146,6 +146,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
id: ctx.payload.id,
prompt: ctx.payload.prompt,
delivery: ctx.payload.delivery,
+ selection: ctx.payload.selection,
resume: ctx.payload.resume,
})
.pipe(
diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx
index ff4ff0f1d408f..a914676acb9e6 100644
--- a/packages/session-ui/src/v2/components/prompt-input/index.tsx
+++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx
@@ -257,7 +257,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
options={options()}
title={"Select variant"}
- current={local.model.variant.selected()}
+ current={local.model.variant.current() ?? "default"}
flat={true}
/>
)
diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx
index fe7f4a22f75f3..5fdf30f9db5b7 100644
--- a/packages/tui/src/component/prompt/index.tsx
+++ b/packages/tui/src/component/prompt/index.tsx
@@ -57,6 +57,7 @@ import { usePromptWorkspace } from "./workspace"
import { usePromptMove } from "./move"
import { readLocalAttachment } from "./local-attachment"
import { useLocation } from "../../context/location"
+import { resolveModelVariantForRequest, resolveModelVariantFromMessage } from "@opencode-ai/core/util/model-variant"
registerOpencodeSpinner()
@@ -315,9 +316,7 @@ export function Prompt(props: PromptProps) {
const msg = lastUserMessage()
if (sessionID !== syncedSessionID) {
- if (!sessionID || !msg) return
-
- syncedSessionID = sessionID
+ if (!sessionID || !msg || !sync.ready || !local.model.ready) return
// Only set agent if it's a primary agent (not a subagent)
const isPrimaryAgent = local.agent.list().some((x) => x.name === msg.agent)
@@ -326,9 +325,18 @@ export function Prompt(props: PromptProps) {
if (!args.agent) local.agent.set(msg.agent)
if (msg.model) {
local.model.set(msg.model)
- local.model.variant.set(msg.model.variant)
+ const model = local.model.current()
+ const matches = model?.providerID === msg.model.providerID && model.modelID === msg.model.modelID
+ if (!matches) return
+ const selected = resolveModelVariantFromMessage({
+ variant: msg.model.variant,
+ configured: local.model.variant.configured(),
+ })
+ if (selected === undefined) local.model.variant.clear()
+ if (selected !== undefined) local.model.variant.set(selected ?? undefined)
}
}
+ syncedSessionID = sessionID
}
})
@@ -955,6 +963,7 @@ export function Prompt(props: PromptProps) {
syncExtmarksWithPromptParts()
}
if (props.disabled) return false
+ if (!local.model.ready) return false
if (workspace.creating() || move.creating()) return false
if (auto()?.visible) return false
if (!store.prompt.input) return false
@@ -986,7 +995,10 @@ export function Prompt(props: PromptProps) {
return false
}
- const variant = local.model.variant.current()
+ const variant = resolveModelVariantForRequest({
+ selected: local.model.variant.selected(),
+ current: local.model.variant.current(),
+ })
let sessionID = props.sessionID
let finishMoveProgress = false
if (sessionID == null) {
diff --git a/packages/tui/src/context/local.tsx b/packages/tui/src/context/local.tsx
index a05d315166ed0..cf882c979122f 100644
--- a/packages/tui/src/context/local.tsx
+++ b/packages/tui/src/context/local.tsx
@@ -8,6 +8,7 @@ import { useTuiPaths } from "./runtime"
import { useArgs } from "./args"
import { useSDK } from "./sdk"
import { RGBA } from "@opentui/core"
+import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@opencode-ai/core/util/model-variant"
import { readJson, writeJsonAtomic } from "../util/persistence"
import { useTheme } from "./theme"
import { useToast } from "../ui/toast"
@@ -152,7 +153,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
providerID: string
modelID: string
}[]
- variant: Record
+ variant: Record
}>({
ready: false,
model: {},
@@ -185,8 +186,16 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const value = x as Record
if (Array.isArray(value.recent)) setModelStore("recent", value.recent)
if (Array.isArray(value.favorite)) setModelStore("favorite", value.favorite)
- if (typeof value.variant === "object" && value.variant !== null)
- setModelStore("variant", value.variant as Record)
+ if (typeof value.variant === "object" && value.variant !== null) {
+ const variant = Object.fromEntries(
+ Object.entries(value.variant).map(([key, value]) => [
+ key,
+ value === "default" ? null : typeof value === "string" || value === null ? value : undefined,
+ ]),
+ ) as Record
+ setModelStore("variant", variant)
+ if (Object.values(value.variant).includes("default")) state.pending = true
+ }
})
.catch(() => {})
.finally(() => {
@@ -360,6 +369,19 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
})
},
variant: {
+ configured() {
+ const a = agent.current()
+ const m = currentModel()
+ if (!m || !a) return undefined
+ const provider = sync.data.provider.find((x) => x.id === m.providerID)
+ const info = provider?.models[m.modelID]
+ return getConfiguredAgentVariant({
+ configured: a.model
+ ? { providerID: a.model.providerID, id: a.model.modelID, variant: a.variant }
+ : undefined,
+ model: { providerID: m.providerID, id: m.modelID, variants: info?.variants },
+ })
+ },
selected() {
const m = currentModel()
if (!m) return undefined
@@ -367,10 +389,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
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(),
+ })
},
list() {
const m = currentModel()
@@ -384,23 +407,26 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const m = currentModel()
if (!m) return
const key = `${m.providerID}/${m.modelID}`
- setModelStore("variant", key, value ?? "default")
+ setModelStore("variant", key, value ?? null)
+ save()
+ },
+ clear() {
+ const m = currentModel()
+ if (!m) return
+ const key = `${m.providerID}/${m.modelID}`
+ setModelStore("variant", key, undefined)
save()
},
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(),
+ }),
+ )
},
},
}
diff --git a/specs/v2/schema-changelog.md b/specs/v2/schema-changelog.md
index 379c675d3dbb1..df1abac814873 100644
--- a/specs/v2/schema-changelog.md
+++ b/specs/v2/schema-changelog.md
@@ -1,5 +1,12 @@
# V2 Schema Changelog
+## 2026-08-15: Admit Prompt Selection Atomically
+
+- Add an optional complete agent and model selection snapshot to current prompt admission, durable prompt events, admission receipts, and generated clients.
+- Persist the snapshot with `session_input` and apply it to the Session only when that input is promoted into model-visible history.
+- Apply selection, input promotion, and user-message projection in the same durable event transaction, then reload the Session before model execution.
+- Preserve historical events and inputs without selection; exact retries now include selection in their conflict identity.
+
## 2026-06-26: Add Finite Session History
- Add `GET /api/session/:sessionID/history` and generated Promise, Effect, and legacy JavaScript client methods.