fix(app): drafts and profile forms send every option layer to the daemon

A cold create against a daemon without providerOptionControls routed control
options (Claude fast_mode, Codex service_tier) as providerOptions, which that
daemon does not read for them. The client now lists the provider's features
before the first create or workspace create that carries options, so callers
never prime a cache.

Draft options moved into a plain model. Applying a profile replaces provider
and options in one step, so the form's provider change that follows no longer
clears a cross-provider profile's non-control options; choosing a provider by
hand still starts over. The model sends its complete options (seed, last-used
values once the provider says it still draws them, edits) to the daemon and
the controls draw only the answer, so a remembered legacy fast is drawn as
what it runs as. A toggle shows over the last answer until its own arrives.
The profile form does the same: its edits go through resolution too.
This commit is contained in:
Mohamed Boudra
2026-11-01 23:06:46 +01:00
parent ddae0a4c36
commit ea8c9e7dc4
13 changed files with 523 additions and 199 deletions
+10 -5
View File
@@ -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.
@@ -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<string, unknown>,
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,
);
});
});
@@ -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 },
};
}
@@ -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);
@@ -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<AgentProfileFormState, "options" | "optionEdits">,
): 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);
@@ -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<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile);
applyProfileOptions(profile.options);
applyProfileOptions({ provider: profile.provider, options: profile.options });
},
[applyProfileOptions, formState],
);
@@ -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();
});
});
@@ -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,
};
}
@@ -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<ProviderOptions>(() => initialOptions ?? {});
const [edits, setEdits] = useState<ProviderOptions>({});
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<AgentProvider | null>(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<DraftControlsConfig | null>(() => {
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<ComposerOptionControls | undefined>(
() => (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 };
}
@@ -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<string, Set<string>>();
/** 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) {
+70
View File
@@ -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" },
+33 -7
View File
@@ -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<string, unknown>;
}): Promise<void> | 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<void> {
const payload = await this.listProviderFeatures({ provider, cwd });
if (payload.error) throw new Error(payload.error);
}
private async createLegacyAgent(
options: CreateAgentRequestOptions,
): Promise<AgentSnapshotPayload> {
@@ -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,
+6 -1
View File
@@ -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",