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.