mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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:
+10
-5
@@ -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) {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user