diff --git a/docs/providers.md b/docs/providers.md index 6749309547..7bf9db65e2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -72,14 +72,19 @@ or without a control. `applyOptions` result like any other value. The `featureValues` to `options` fold is separate and provider-agnostic; it stays at the protocol and legacy edges. - **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the - agent snapshot for a running or closed agent. A draft and a profile form ask - `provider.controls.list.request` with their `providerOptions` and draw what comes back, so an - alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once, by the daemon. - A draft adds only what the user changed and each provider's last-used control values; a + agent snapshot for a running or closed agent. A draft and a profile form send everything they + would create with (seed, the provider's last-used control values once the provider has said it + still draws them, and the user's edits) in `provider.controls.list.request` and draw only what + comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once, + by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's + layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile + replaces provider and options in one step, while choosing a provider by hand starts over. A profile saves its stored options plus the form's edits, so options no control draws survive an edit. A daemon without `server_info.features.providerOptionControls` sends `features` and `featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that - turns those into `controls` and `options` and back, so nothing in the app branches on it. + turns those into `controls` and `options` and back, so nothing in the app branches on it. Its + first create for a provider lists that provider's features to learn which options go out as + `featureValues`; callers do not prime anything. - **Provider-initiated changes.** A setting the provider changes on its own is an `options_changed` event. The daemon stores and forwards it and does not push the old value back. diff --git a/packages/app/src/agent-controls/draft-options-model.test.ts b/packages/app/src/agent-controls/draft-options-model.test.ts new file mode 100644 index 0000000000..9de5093ea3 --- /dev/null +++ b/packages/app/src/agent-controls/draft-options-model.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from "vitest"; +import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; +import { isControlActive } from "./option-controls"; +import { openDraftOptions, ownDraftOptions, viewDraftOptions } from "./draft-options-model"; + +const SPEED: AgentOptionControl = { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Standard" }, + { value: "priority", label: "Fast" }, + ], +}; +const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" }; + +function answer( + provider: string, + requestKey: string, + options: Record, + controls = [SPEED, WEB_SEARCH], +) { + return { provider, requestKey, controls, options }; +} + +describe("draft options model", () => { + it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => { + const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } }); + model.applyResolved(answer("codex", "k1", { service_tier: "priority" })); + + const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" }); + + expect(view?.options).toEqual({ service_tier: "priority" }); + expect(view && isControlActive(SPEED, view.options)).toBe(true); + // The daemon still gets the stored value to resolve, and so does create. + expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" }); + }); + + it("applying a profile from another provider keeps its options through the provider change", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", {})); + + model.applyProfile({ + provider: "codex", + options: { sandbox_mode: "read-only", service_tier: "fast" }, + }); + // The form follows the profile to its provider. + model.observeProvider("codex"); + + expect(ownDraftOptions(model.getState())).toEqual({ + sandbox_mode: "read-only", + service_tier: "fast", + }); + expect(model.getState().resolved).toBeNull(); + }); + + it("choosing another provider by hand starts the options over", () => { + const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } }); + model.setOption("web_search", true); + + model.observeProvider("claude"); + + expect(ownDraftOptions(model.getState())).toBeUndefined(); + expect(model.getState().resolved).toBeNull(); + }); + + it("keeps a draft's seed when the first provider is chosen", () => { + const model = openDraftOptions({ provider: null, seed: { web_search: true } }); + + model.observeProvider("claude"); + + expect(ownDraftOptions(model.getState())).toEqual({ web_search: true }); + }); + + it("sends a remembered value to the daemon, and draws only what comes back", () => { + const model = openDraftOptions({ provider: "codex" }); + model.setRemembered({ service_tier: "fast", retired: true }); + // Nothing is known about the provider's controls yet. + expect(ownDraftOptions(model.getState())).toBeUndefined(); + + model.applyResolved(answer("codex", "k1", { service_tier: "default" })); + expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" }); + // The answer to the first request does not show the remembered value as if it applied. + expect( + viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options, + ).toEqual({ service_tier: "default" }); + + model.applyResolved(answer("codex", "k2", { service_tier: "priority" })); + expect( + viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options, + ).toEqual({ service_tier: "priority" }); + }); + + it("does not let a remembered value override the seed", () => { + const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } }); + model.applyResolved(answer("codex", "k1", { service_tier: "default" })); + model.setRemembered({ service_tier: "fast" }); + + expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" }); + }); + + it("lays a toggle over the previous answer until the new one arrives", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", { web_search: false })); + + model.setOption("web_search", true); + + expect( + viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options, + ).toEqual({ web_search: true }); + model.applyResolved(answer("claude", "k2", { web_search: true })); + expect( + viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options, + ).toEqual({ web_search: true }); + }); + + it("draws nothing for a provider the answer is not for", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", {})); + + expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe( + undefined, + ); + }); +}); diff --git a/packages/app/src/agent-controls/draft-options-model.ts b/packages/app/src/agent-controls/draft-options-model.ts new file mode 100644 index 0000000000..c27abc4a69 --- /dev/null +++ b/packages/app/src/agent-controls/draft-options-model.ts @@ -0,0 +1,131 @@ +import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; + +/** + * The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers + * make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon + * (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias, + * default or legacy key is resolved here. + * + * - `seed`: options the draft started from (a profile, a forked agent). + * - `remembered`: the provider's last-used control values. + * - `edits`: toggles made in this draft. + * + * Choosing a provider by hand starts the options over; applying a profile replaces the provider + * and the options in one step, so the provider change that follows does not clear them. + */ +export interface ResolvedDraftOptions { + provider: string; + /** Names the request that produced this answer; see `viewDraftOptions`. */ + requestKey: string; + controls: AgentOptionControl[]; + options: ProviderOptions; +} + +export interface DraftOptionsState { + provider: string | null; + seed: ProviderOptions; + remembered: ProviderOptions; + edits: ProviderOptions; + resolved: ResolvedDraftOptions | null; +} + +export interface DraftOptionsModel { + getState: () => DraftOptionsState; + subscribe: (listener: () => void) => () => void; + /** The provider the draft's form now has. A different one than the model's is a manual choice. */ + observeProvider: (provider: string | null) => void; + applyProfile: (profile: { provider: string; options: ProviderOptions }) => void; + setRemembered: (remembered: ProviderOptions) => void; + setOption: (key: string, value: unknown) => void; + applyResolved: (resolved: ResolvedDraftOptions) => void; +} + +export function openDraftOptions(input: { + provider: string | null; + seed?: ProviderOptions; +}): DraftOptionsModel { + let state: DraftOptionsState = { + provider: input.provider, + seed: input.seed ?? {}, + remembered: {}, + edits: {}, + resolved: null, + }; + const listeners = new Set<() => void>(); + + function publish(update: (current: DraftOptionsState) => DraftOptionsState): void { + const next = update(state); + if (next === state) return; + state = next; + for (const listener of listeners) listener(); + } + + return { + getState: () => state, + subscribe: (listener) => { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + observeProvider: (provider) => + publish((current) => { + if (provider === current.provider) return current; + // The first provider a draft picks does not discard what it was opened with. + if (current.provider === null) return { ...current, provider }; + return { ...current, provider, seed: {}, edits: {}, resolved: null }; + }), + applyProfile: (profile) => + publish((current) => ({ + ...current, + provider: profile.provider, + seed: profile.options, + edits: {}, + resolved: current.provider === profile.provider ? current.resolved : null, + })), + setRemembered: (remembered) => publish((current) => ({ ...current, remembered })), + setOption: (key, value) => + publish((current) => + Object.is(current.edits[key], value) + ? current + : { ...current, edits: { ...current.edits, [key]: value } }, + ), + applyResolved: (resolved) => publish((current) => ({ ...current, resolved })), + }; +} + +/** + * Everything the draft would create with, for the daemon to resolve. A last-used value applies + * once the provider has told us it still draws a control for that key, and never over the seed. + */ +export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined { + const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key)); + const remembered = Object.fromEntries( + Object.entries(state.remembered).filter( + ([key]) => controlKeys.has(key) && !(key in state.seed), + ), + ); + const own = { ...state.seed, ...remembered, ...state.edits }; + return Object.keys(own).length > 0 ? own : undefined; +} + +/** + * What the controls draw: the daemon's answer for this provider. The last answer stays up while + * a newer request is in flight, with the toggles made since laid over it, so a switch does not + * wait for the round trip. It never carries a seed or remembered value the daemon has not + * resolved. + */ +export function viewDraftOptions( + state: DraftOptionsState, + current: { provider: string | null; requestKey: string }, +): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined { + const { resolved } = state; + if (!resolved || resolved.provider !== current.provider) return undefined; + return { + controls: resolved.controls, + options: + resolved.requestKey === current.requestKey + ? resolved.options + : { ...resolved.options, ...state.edits }, + }; +} diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts index b8aca73b2d..34c5aa1594 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.test.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.test.ts @@ -461,6 +461,67 @@ describe("openAgentProfileForm", () => { expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" }); }); + it("sends an edit to the daemon with the stored options, and draws the answer", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { + id: "p1", + name: "Fast", + provider: "codex", + options: { service_tier: "fast", sandbox_mode: "read-only" }, + }, + }); + model.applyProviderCatalog(ENTRIES); + const speed = { + controls: [ + { + type: "select" as const, + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Standard" }, + { value: "priority", label: "Fast" }, + ], + }, + ], + options: { service_tier: "priority", sandbox_mode: "read-only" }, + }; + model.applyControls(model.getState().controlsRequestKey ?? "", speed); + + model.setOption("service_tier", "default"); + + // The controls stay up while the daemon answers, with the toggle laid over the last answer. + expect(model.getState().controls).toHaveLength(1); + expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" }); + // The request carries everything the profile would save, so the daemon resolves it. + expect(model.getState().controlsRequest?.providerOptions).toEqual({ + service_tier: "default", + sandbox_mode: "read-only", + }); + expect(model.getState().controlsResolution).toBe("pending"); + + model.applyControls(model.getState().controlsRequestKey ?? "", { + ...speed, + options: { service_tier: "default", sandbox_mode: "read-only" }, + }); + expect(model.getState().controlsResolution).toBe("complete"); + expect(model.getState().controlOptions).toEqual({ + service_tier: "default", + sandbox_mode: "read-only", + }); + }); + + it("draws no controls for the previous model while another model's answer is pending", () => { + const model = openWithCatalog({ mode: "create" }); + selectClaude(model); + model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS); + + model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); + + expect(model.getState().controls).toEqual([]); + }); + it("resolves an unavailable feature listing without showing the field", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); diff --git a/packages/app/src/agent-profiles/internal/profile-form-model.ts b/packages/app/src/agent-profiles/internal/profile-form-model.ts index 5d455bd241..88d15c52dd 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.ts @@ -328,18 +328,25 @@ function buildControlsRequest(state: AgentProfileFormState): AgentProfileControl if (!state.provider) { return null; } + const ownOptions = ownOptionsOf(state); return { provider: state.provider, ...(state.modelId ? { model: state.modelId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), - ...(Object.keys(state.options).length > 0 ? { providerOptions: state.options } : {}), + ...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}), }; } -export function buildControlsRequestKey( - request: AgentProfileControlsRequest | null, -): string | null { +/** Everything the profile would save as its options; the daemon resolves them for the form. */ +function ownOptionsOf( + state: Pick, +): ProviderOptions { + return { ...state.options, ...state.optionEdits }; +} + +/** The part of a request that decides which controls exist; options only change their values. */ +function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null { if (!request) { return null; } @@ -351,13 +358,25 @@ export function buildControlsRequestKey( ].join("|"); } +export function buildControlsRequestKey( + request: AgentProfileControlsRequest | null, +): string | null { + if (!request) { + return null; + } + const scope = buildControlsScopeKey(request); + return request.providerOptions + ? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}` + : scope; +} + function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null { const name = state.name.trim(); const notes = state.notes.trim(); if (!name || !state.provider) { return null; } - const options = { ...state.options, ...state.optionEdits }; + const options = ownOptionsOf(state); return { name, ...(state.icon ? { icon: state.icon } : {}), @@ -441,6 +460,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP let entries: readonly ProviderSnapshotEntry[] = []; let catalogResolution: AgentProfileResolutionStatus = "idle"; let resolvedControlsKey: string | null = null; + let resolvedScopeKey: string | null = null; let resolvedControls: AgentOptionControl[] = []; let resolvedOptions: ProviderOptions = {}; let listeners = new Set<() => void>(); @@ -457,12 +477,20 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP const thinking = resolveThinkingOptions(entries, next.provider, next.modelId); const controlsRequest = buildControlsRequest(next); const controlsRequestKey = buildControlsRequestKey(controlsRequest); + const controlsScopeKey = buildControlsScopeKey(controlsRequest); const featuresAreCurrent = controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey; - const controls = featuresAreCurrent ? resolvedControls : []; - const controlOptions = featuresAreCurrent - ? { ...resolvedOptions, ...next.optionEdits } - : next.options; + // A toggle changes only the options, so the controls stay up while the daemon answers and the + // toggle shows over the last answer. A change of provider, model, mode or thinking draws none + // until its own answer arrives. + const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey; + const controls = controlsApply ? resolvedControls : []; + let controlOptions: ProviderOptions = next.options; + if (controlsApply) { + controlOptions = featuresAreCurrent + ? resolvedOptions + : { ...resolvedOptions, ...next.optionEdits }; + } const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent); const withOptions: AgentProfileFormState = { @@ -557,6 +585,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP return; } resolvedControlsKey = requestKey; + resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); resolvedControls = [...resolved.controls]; resolvedOptions = resolved.options; publish((current) => current); @@ -566,6 +595,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP return; } resolvedControlsKey = requestKey; + resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); resolvedControls = []; resolvedOptions = {}; publish((current) => current); diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index d9b2a14844..dac68abcf9 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -276,7 +276,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const { optionControls: draftOptionControls, options: draftOptions, - applyProfileOptions, + applyProfile: applyProfileOptions, } = useDraftProviderOptions({ serverId: formState.selectedServerId, provider: formState.selectedProvider, @@ -290,7 +290,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr const applyDraftAgentProfile = useCallback( (profile: Parameters[0]) => { formState.applyProfileFromUser(profile); - applyProfileOptions(profile.options); + applyProfileOptions({ provider: profile.provider, options: profile.options }); }, [applyProfileOptions, formState], ); diff --git a/packages/app/src/hooks/draft-provider-options.test.ts b/packages/app/src/hooks/draft-provider-options.test.ts deleted file mode 100644 index dcbbd99c1e..0000000000 --- a/packages/app/src/hooks/draft-provider-options.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; -import { isControlActive } from "@/agent-controls/option-controls"; -import { composeDraftOptions } from "./draft-provider-options"; - -const SPEED: AgentOptionControl = { - type: "select", - key: "service_tier", - label: "Speed", - defaultValue: "default", - choices: [ - { value: "default", label: "Standard" }, - { value: "priority", label: "Fast" }, - ], -}; -const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" }; - -describe("composeDraftOptions", () => { - it("shows a legacy Fast profile as the Fast tier, highlighted", () => { - const { shown } = composeDraftOptions({ - seed: { service_tier: "fast" }, - persisted: {}, - edits: {}, - controls: [SPEED], - resolved: { service_tier: "priority" }, - }); - - expect(shown).toEqual({ service_tier: "priority" }); - expect(isControlActive(SPEED, shown)).toBe(true); - }); - - it("creates with the profile's options, including ones no control draws", () => { - const { own } = composeDraftOptions({ - seed: { service_tier: "fast", sandbox_mode: "read-only" }, - persisted: {}, - edits: {}, - controls: [SPEED], - resolved: { service_tier: "priority" }, - }); - - expect(own).toEqual({ service_tier: "fast", sandbox_mode: "read-only" }); - }); - - it("lets an edit win over the daemon-resolved value and the seed", () => { - const { shown, own } = composeDraftOptions({ - seed: { service_tier: "fast" }, - persisted: {}, - edits: { service_tier: "default" }, - controls: [SPEED], - resolved: { service_tier: "priority" }, - }); - - expect(shown).toEqual({ service_tier: "default" }); - expect(own).toEqual({ service_tier: "default" }); - }); - - it("restores last-used values only for controls the provider still draws, and not over the seed", () => { - const { shown, own } = composeDraftOptions({ - seed: { service_tier: "fast" }, - persisted: { web_search: true, retired: true, service_tier: "default" }, - edits: {}, - controls: [SPEED, WEB_SEARCH], - resolved: { service_tier: "priority" }, - }); - - expect(shown).toEqual({ service_tier: "priority", web_search: true }); - expect(own).toEqual({ service_tier: "fast", web_search: true }); - }); - - it("sends nothing when nothing was seeded, remembered or edited", () => { - expect( - composeDraftOptions({ - seed: {}, - persisted: {}, - edits: {}, - controls: [SPEED], - resolved: { service_tier: "default" }, - }).own, - ).toBeUndefined(); - }); -}); diff --git a/packages/app/src/hooks/draft-provider-options.ts b/packages/app/src/hooks/draft-provider-options.ts deleted file mode 100644 index 62408bb49a..0000000000 --- a/packages/app/src/hooks/draft-provider-options.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; - -/** - * What a draft agent shows and runs with, from its layers: - * - `seed`: options the draft started from (a profile, a forked agent). The daemon resolves them - * into `resolved`, so a legacy Fast profile is drawn as the model's Fast tier. - * - `persisted`: last-used control values, applied to keys the seed leaves alone and that the - * provider still draws a control for. - * - `edits`: toggles made in this draft. - */ -export function composeDraftOptions(input: { - seed: ProviderOptions; - persisted: ProviderOptions; - edits: ProviderOptions; - controls: readonly AgentOptionControl[] | undefined; - resolved: ProviderOptions | undefined; -}): { shown: ProviderOptions; own: ProviderOptions | undefined } { - const { seed, persisted, edits, controls, resolved } = input; - const controlKeys = new Set((controls ?? []).map((control) => control.key)); - const remembered = Object.fromEntries( - Object.entries(persisted).filter(([key]) => controlKeys.has(key) && !(key in seed)), - ); - const own = { ...seed, ...remembered, ...edits }; - return { - shown: { ...resolved, ...remembered, ...edits }, - // The daemon resolves defaults again on create, so only what differs from them travels. - own: Object.keys(own).length > 0 ? own : undefined, - }; -} diff --git a/packages/app/src/hooks/use-draft-provider-options.ts b/packages/app/src/hooks/use-draft-provider-options.ts index a3891aed0a..c1ecbad769 100644 --- a/packages/app/src/hooks/use-draft-provider-options.ts +++ b/packages/app/src/hooks/use-draft-provider-options.ts @@ -1,14 +1,18 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; import type { AgentProvider, AgentSessionConfig, ProviderOptions, } from "@getpaseo/protocol/agent-types"; +import { + openDraftOptions, + ownDraftOptions, + viewDraftOptions, +} from "@/agent-controls/draft-options-model"; import type { ComposerOptionControls } from "@/agent-controls/option-controls"; import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { composeDraftOptions } from "./draft-provider-options"; import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; type DraftControlsConfig = Pick< @@ -17,13 +21,8 @@ type DraftControlsConfig = Pick< >; /** - * The options a draft agent would run with, and the controls that draw them. The daemon resolves - * the values (config defaults, aliases such as a legacy Fast profile's `service_tier`); the draft - * adds only what the user chose here or last time. - * - * - `seed`: options the draft starts from (a profile, a workspace setup). Sent to the daemon. - * - `edits`: toggles made in this draft. - * - persisted: last-used control values for the provider. + * Adapter between a draft's options model and the screen: it sends the model's complete options + * to the daemon, hands the answer back, and persists the user's toggles as last-used values. */ export function useDraftProviderOptions(input: { serverId: string | null | undefined; @@ -36,45 +35,40 @@ export function useDraftProviderOptions(input: { }) { const { t } = useTranslation(); const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input; - const [seed, setSeed] = useState(() => initialOptions ?? {}); - const [edits, setEdits] = useState({}); + const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions })); + const state = useSyncExternalStore(model.subscribe, model.getState, model.getState); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const { preferences, updatePreferences } = useFormPreferences(); const normalizedCwd = cwd?.trim() || ""; - const normalizedProvider = provider ?? null; - const previousProviderRef = useRef(normalizedProvider); - const persisted = useMemo( - () => (provider ? (preferences.providerPreferences?.[provider]?.options ?? {}) : {}), - [preferences.providerPreferences, provider], - ); + const persisted = preferences.providerPreferences?.[provider ?? ""]?.options; + useEffect(() => model.observeProvider(provider), [model, provider]); + useEffect(() => model.setRemembered(persisted ?? {}), [model, persisted]); + + const ownOptions = ownDraftOptions(state); const draftConfig = useMemo(() => { - if (!normalizedProvider || !normalizedCwd) return null; + if (!provider || !normalizedCwd) return null; return { - provider: normalizedProvider, + provider, cwd: normalizedCwd, ...(modeId ? { modeId } : {}), ...(modelId ? { model: modelId } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), - ...(Object.keys(seed).length > 0 ? { providerOptions: seed } : {}), + ...(ownOptions ? { providerOptions: ownOptions } : {}), }; - }, [modeId, modelId, normalizedCwd, normalizedProvider, seed, thinkingOptionId]); + }, [modeId, modelId, normalizedCwd, ownOptions, provider, thinkingOptionId]); + const queryKey = useMemo( + () => ["providerControls", serverId ?? null, draftConfig], + [draftConfig, serverId], + ); + const requestKey = JSON.stringify(queryKey); - const controlsQuery = useFetchQuery({ - queryKey: [ - "providerControls", - serverId ?? null, - normalizedProvider, - normalizedCwd || null, - modeId ?? null, - modelId ?? null, - thinkingOptionId ?? null, - seed, - ], - enabled: Boolean(serverId && client && isConnected && draftConfig), + const { data } = useFetchQuery({ + queryKey, dataShape: "value", staleTimeMs: 5 * 60 * 1000, + enabled: Boolean(serverId && client && isConnected && draftConfig), queryFn: async () => { if (!client || !draftConfig) { throw new Error(t("workspace.terminal.hostDisconnected")); @@ -86,29 +80,14 @@ export function useDraftProviderOptions(input: { return { controls: payload.controls ?? [], options: payload.options ?? {} }; }, }); - const controls = controlsQuery.data?.controls; - const resolved = controlsQuery.data?.options; - - const { shown: options, own: ownOptions } = useMemo( - () => composeDraftOptions({ seed, persisted, edits, controls, resolved }), - [controls, edits, persisted, resolved, seed], - ); useEffect(() => { - const previousProvider = previousProviderRef.current; - previousProviderRef.current = normalizedProvider; - if (previousProvider === null) return; - if (previousProvider !== normalizedProvider) { - setSeed({}); - setEdits({}); - } - }, [normalizedProvider]); + if (provider && data) model.applyResolved({ provider, requestKey, ...data }); + }, [data, model, provider, requestKey]); const setOption = useCallback( (key: string, value: unknown) => { - setEdits((current) => - Object.is(current[key], value) ? current : { ...current, [key]: value }, - ); + model.setOption(key, value); if (!provider) return; void updatePreferences((current) => mergeProviderPreferences({ @@ -120,23 +99,19 @@ export function useDraftProviderOptions(input: { console.warn("[useDraftProviderOptions] persist option preference failed", error); }); }, - [provider, updatePreferences], + [model, provider, updatePreferences], ); - const applyProfileOptions = useCallback((profileOptions: ProviderOptions) => { - setSeed(profileOptions); - setEdits({}); - }, []); - + const view = viewDraftOptions(state, { provider, requestKey }); + const viewControls = view?.controls; + const viewOptions = view?.options; const optionControls = useMemo( - () => (controls ? { controls, options, onSetOption: setOption } : undefined), - [controls, options, setOption], + () => + viewControls && viewOptions + ? { controls: viewControls, options: viewOptions, onSetOption: setOption } + : undefined, + [setOption, viewControls, viewOptions], ); - return { - optionControls, - options: ownOptions, - isLoading: controlsQuery.isLoading, - applyProfileOptions, - }; + return { optionControls, options: ownOptions, applyProfile: model.applyProfile }; } diff --git a/packages/client/src/compat/legacy-provider-options.ts b/packages/client/src/compat/legacy-provider-options.ts index 01e609ad10..9c015cfa71 100644 --- a/packages/client/src/compat/legacy-provider-options.ts +++ b/packages/client/src/compat/legacy-provider-options.ts @@ -27,6 +27,11 @@ export class LegacyProviderOptions { /** Option keys a legacy daemon exposes as features, per provider, as last seen. */ private readonly controlKeys = new Map>(); + /** Whether the keys a provider exposes as features are known, so options can be routed. */ + knowsControlKeys(provider: string): boolean { + return this.controlKeys.has(provider); + } + /** Gives agent snapshots `controls` and `options`, built from their `features`. */ normalize(message: SessionOutboundMessage): SessionOutboundMessage { switch (message.type) { diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index a055a749e3..66489d0b14 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -9127,6 +9127,75 @@ describe("provider options across daemon versions", () => { }); }); + test("a cold legacy create lists the provider's features before routing its options", async () => { + const { client, sentMessages, mock } = await connect({}); + void client + .createAgent({ + config: { + provider: "claude", + cwd: "/repo", + providerOptions: { fast_mode: true, sandbox_mode: "read-only" }, + }, + }) + .catch(() => {}); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(1)); + const [listing] = sentMessages(); + expect(listing).toMatchObject({ + type: "list_provider_features_request", + draftConfig: { provider: "claude", cwd: "/repo" }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "list_provider_features_response", + payload: { + provider: "claude", + features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }], + error: null, + fetchedAt: "2026-04-30T00:00:00.000Z", + requestId: listing.requestId, + }, + }), + ); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(2)); + expect(sentMessages()[1].config).toMatchObject({ + featureValues: { fast_mode: true }, + providerOptions: { sandbox_mode: "read-only" }, + }); + }); + + test("a cold legacy workspace create routes its agent's options the same way", async () => { + const { client, sentMessages, mock } = await connect({ creationLifecycle: true }); + void client + .createWorkspace({ + source: { kind: "directory", path: "/repo" }, + agent: { + config: { provider: "codex", cwd: "/repo", providerOptions: { service_tier: "fast" } }, + }, + } as never) + .catch(() => {}); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(1)); + const [listing] = sentMessages(); + expect(listing).toMatchObject({ type: "list_provider_features_request" }); + mock.triggerMessage( + wrapSessionMessage({ + type: "list_provider_features_response", + payload: { + provider: "codex", + features: [speedFeature], + error: null, + fetchedAt: "2026-04-30T00:00:00.000Z", + requestId: listing.requestId, + }, + }), + ); + const sentTypes = () => sentMessages().map((message) => message.type); + await vi.waitFor(() => expect(sentTypes()).toContain("workspace.create.request")); + const created = sentMessages()[sentTypes().indexOf("workspace.create.request")]; + expect(created).toMatchObject({ + agent: { config: { featureValues: { service_tier: "fast" } } }, + }); + }); + test("a legacy daemon creates an agent with control keys as feature values", async () => { const { client, sentMessages, mock } = await connect({}); mock.triggerMessage( @@ -9144,6 +9213,7 @@ describe("provider options across daemon versions", () => { }, }) .catch(() => {}); + await vi.waitFor(() => expect(sentMessages()).toHaveLength(1)); const [request] = sentMessages(); expect(request.config).toMatchObject({ featureValues: { service_tier: "priority" }, diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 21e2fa42e2..d47180911a 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3269,14 +3269,39 @@ export class DaemonClient { ); } if (options.background !== undefined) this.requireBackgroundWorkspaces(); - const result = await this.creations.createAgent({ - ...options, - config: resolveAgentConfig(options), - }); + const config = resolveAgentConfig(options); + // Awaited only when there is something to learn, so other creates still send synchronously. + const learning = this.learnLegacyControlKeys(config); + if (learning) await learning; + const result = await this.creations.createAgent({ ...options, config }); if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); return result.agent; } + /** + * A legacy daemon reads control options as `featureValues`, so routing a create's options needs + * the provider's feature ids. Callers never prime that: the first create for a provider lists + * them, and the answer is remembered with the snapshots and listings that also teach it. + */ + private learnLegacyControlKeys(config: { + provider?: string; + cwd?: string; + providerOptions?: Record; + }): Promise | undefined { + const legacy = this.legacyProviderOptions(); + const { provider, providerOptions } = config; + if (!legacy || !provider || !providerOptions || Object.keys(providerOptions).length === 0) { + return undefined; + } + if (legacy.knowsControlKeys(provider)) return undefined; + return this.listFeaturesOrThrow(provider, config.cwd || "~"); + } + + private async listFeaturesOrThrow(provider: string, cwd: string): Promise { + const payload = await this.listProviderFeatures({ provider, cwd }); + if (payload.error) throw new Error(payload.error); + } + private async createLegacyAgent( options: CreateAgentRequestOptions, ): Promise { @@ -5028,12 +5053,13 @@ export class DaemonClient { if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces(); if (input.agent?.background !== undefined) throw new Error("Configure background on the workspace, not its initial agent."); + const agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined; + const learning = agentConfig ? this.learnLegacyControlKeys(agentConfig) : undefined; + if (learning) await learning; const result = await this.creations.createWorkspace({ ...input, requestId: resolvedRequestId, - ...(input.agent - ? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } } - : {}), + ...(input.agent && agentConfig ? { agent: { ...input.agent, config: agentConfig } } : {}), }); return { ...result, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index ed96f2e6c7..2a0083042d 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1601,7 +1601,12 @@ test("config actions delegate to existing daemon config RPCs", async () => { }); test("agent config maps provider/model and provider-native options to the daemon", async () => { - const { client, ws } = await connectClient(); + const { client, ws } = await connectClient({ + providerUsageList: true, + providersSnapshotCwd: true, + ownedSubscriptions: true, + providerOptionControls: true, + }); const createdAgent = createAgent({ model: "gpt-5.4", currentModeId: "full-access",