From ddae0a4c36be8cfba291edb911619aba65ee0325 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 22:56:11 +0100 Subject: [PATCH] feat(app): the client and app work on options and controls The agent snapshot's `options` are the state and `controls` only draw them. Running and closed agents draw from the snapshot; drafts and profile forms ask `provider.controls.list.request` with their `providerOptions`, so aliases such as a legacy Fast profile's `service_tier: "fast"` are resolved once, by the daemon, and the draft keeps Fast highlighted. A draft adds only the user's edits and each provider's last-used control values; applying a profile seeds its non-control options (e.g. `sandbox_mode`) into the draft, and a profile form saves them back instead of pruning to the controls it can draw. A daemon without `server_info.features.providerOptionControls` still sends `features` and `featureValues`. `LegacyProviderOptions` in the client package is the one place that translates snapshots, draft listings, create and apply requests, and daemon-config profiles, so the app never branches on it. Persisted preferences and tab layouts rename `featureValues` through tagged COMPAT preprocessors. The features/controls bridge moves from the server into the protocol package so the client can reuse it. --- docs/providers.md | 9 + ...agent-profile-modeless-preferences.spec.ts | 6 +- .../app/src/agent-commands/cache/index.ts | 4 +- .../agent-controls/option-controls.test.ts | 50 ++ .../app/src/agent-controls/option-controls.ts | 47 ++ .../internal/materialize-profile.test.ts | 14 +- .../internal/materialize-profile.ts | 8 +- .../internal/profile-form-model.test.ts | 149 +++--- .../internal/profile-form-model.ts | 131 +++--- .../internal/profile-summary.ts | 2 +- .../internal/use-agent-profile-picker.ts | 4 +- .../internal/use-profile-form-inputs.ts | 20 +- .../settings/agent-profile-edit-modal.tsx | 93 ++-- packages/app/src/agent-stream/view.tsx | 3 +- .../src/client-slash-commands/index.test.ts | 13 +- .../app/src/client-slash-commands/index.ts | 7 +- .../agent-control-contributions.test.ts | 41 +- .../agent-control-contributions.ts | 40 +- .../agent-control-registration.tsx | 34 +- .../app/src/composer/agent-controls/index.tsx | 435 +++++++++--------- .../src/composer/agent-controls/utils.test.ts | 41 -- .../app/src/composer/agent-controls/utils.ts | 12 +- .../src/composer/draft/input-draft-core.ts | 8 +- .../app/src/composer/draft/input-draft.ts | 41 +- .../app/src/composer/draft/workspace-tab.tsx | 26 +- .../preferences.test.ts | 22 +- .../create-agent-preferences/preferences.ts | 44 +- .../src/hooks/draft-provider-options.test.ts | 81 ++++ .../app/src/hooks/draft-provider-options.ts | 29 ++ .../app/src/hooks/feature-preferences.test.ts | 53 --- packages/app/src/hooks/feature-preferences.ts | 60 --- .../app/src/hooks/use-agent-autocomplete.ts | 4 +- .../app/src/hooks/use-agent-form-state.ts | 2 +- .../hooks/use-agent-screen-state-machine.ts | 10 +- .../app/src/hooks/use-draft-agent-features.ts | 159 ------- .../src/hooks/use-draft-provider-options.ts | 142 ++++++ packages/app/src/hooks/use-fork-agent.ts | 9 +- .../src/hooks/use-form-preferences.test.ts | 6 +- packages/app/src/panels/agent-panel.tsx | 12 +- .../provider-selection/provider-selection.ts | 4 +- .../resolve-agent-form.test.ts | 4 +- .../app/src/screens/new-workspace-screen.tsx | 14 +- .../new-workspace/background-handoff.test.ts | 4 +- .../workspace/workspace-draft-agent-config.ts | 4 +- packages/app/src/stores/session-store.ts | 6 +- .../workspace-draft-submission-store.ts | 2 +- .../src/stores/workspace-layout-storage.ts | 27 +- .../src/stores/workspace-layout-store.test.ts | 1 - .../workspace-subagents-integration.test.ts | 1 - packages/app/src/subagents/select.test.ts | 1 - packages/app/src/subagents/sheet-tab.test.tsx | 1 - packages/app/src/utils/agent-snapshots.ts | 6 +- packages/app/src/workspace-tabs/identity.ts | 4 +- packages/app/src/workspace-tabs/model.ts | 2 +- .../src/compat/legacy-provider-options.ts | 219 +++++++++ packages/client/src/daemon-client.test.ts | 227 +++++++++ packages/client/src/daemon-client.ts | 83 +++- packages/client/src/index.test.ts | 2 +- packages/client/src/index.ts | 63 ++- packages/protocol/src/option-controls.ts | 107 +++++ .../src/server/agent/agent-projections.ts | 2 +- .../server/src/server/agent/agent-storage.ts | 3 +- .../src/server/agent/option-controls.ts | 102 +--- .../codex-app-server-agent.features.test.ts | 3 +- .../src/server/agent/tools/paseo-tools.ts | 2 +- .../provider/provider-catalog-session.ts | 3 +- 66 files changed, 1711 insertions(+), 1057 deletions(-) create mode 100644 packages/app/src/agent-controls/option-controls.test.ts create mode 100644 packages/app/src/agent-controls/option-controls.ts create mode 100644 packages/app/src/hooks/draft-provider-options.test.ts create mode 100644 packages/app/src/hooks/draft-provider-options.ts delete mode 100644 packages/app/src/hooks/feature-preferences.test.ts delete mode 100644 packages/app/src/hooks/feature-preferences.ts delete mode 100644 packages/app/src/hooks/use-draft-agent-features.ts create mode 100644 packages/app/src/hooks/use-draft-provider-options.ts create mode 100644 packages/client/src/compat/legacy-provider-options.ts create mode 100644 packages/protocol/src/option-controls.ts diff --git a/docs/providers.md b/docs/providers.md index ae2ccf04b7..6749309547 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -71,6 +71,15 @@ or without a control. `"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the `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 + 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. - **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/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts index a10afd2cca..e13f2e880b 100644 --- a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro const raw = localStorage.getItem(preferencesKey); if (!raw) return null; const preferences = JSON.parse(raw) as FormPreferences; - return preferences.providerPreferences?.[providerId]?.featureValues ?? null; + return preferences.providerPreferences?.[providerId]?.options ?? null; }, { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider }, ); @@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { provider: featureProviderId, model: featureModelId, modeId: "bypassPermissions", - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, ]); const provider = await seedModelProvider({ @@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { expect(createAgentRequest).toMatchObject({ config: { provider: featureProviderId, - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, }); } finally { diff --git a/packages/app/src/agent-commands/cache/index.ts b/packages/app/src/agent-commands/cache/index.ts index 474491f297..baee276a47 100644 --- a/packages/app/src/agent-commands/cache/index.ts +++ b/packages/app/src/agent-commands/cache/index.ts @@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig { modeId?: string; model?: string; thinkingOptionId?: string; - featureValues?: Record; + providerOptions?: Record; } export function normalizeAgentCommandsCwd(cwd: string): string { @@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: { "thinking", draftConfig.thinkingOptionId ?? null, "features", - draftConfig.featureValues ?? null, + draftConfig.providerOptions ?? null, ] as const; } diff --git a/packages/app/src/agent-controls/option-controls.test.ts b/packages/app/src/agent-controls/option-controls.test.ts new file mode 100644 index 0000000000..b14f20d6ed --- /dev/null +++ b/packages/app/src/agent-controls/option-controls.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; +import { + getControlTooltip, + isControlActive, + readSelectOption, + readToggleOption, +} from "./option-controls"; + +const speed: AgentOptionControl = { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast" }, + ], +}; +const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" }; + +describe("option controls", () => { + it("reads a select from options and falls back to the control's default", () => { + if (speed.type !== "select") throw new Error("select expected"); + expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority"); + expect(readSelectOption(speed, {})).toBe("default"); + expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default"); + }); + + it("reads a toggle from options and is off when unset", () => { + if (fast.type !== "toggle") throw new Error("toggle expected"); + expect(readToggleOption(fast, { fast_mode: true })).toBe(true); + expect(readToggleOption(fast, {})).toBe(false); + }); + + it("is active when the option differs from the control's default", () => { + expect(isControlActive(speed, { service_tier: "priority" })).toBe(true); + expect(isControlActive(speed, { service_tier: "default" })).toBe(false); + expect(isControlActive(speed, {})).toBe(false); + expect(isControlActive(fast, { fast_mode: true })).toBe(true); + expect(isControlActive(fast, {})).toBe(false); + }); + + it("prefers a control's tooltip over its label", () => { + expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe( + "Toggle plan mode", + ); + expect(getControlTooltip({ label: "Custom" })).toBe("Custom"); + }); +}); diff --git a/packages/app/src/agent-controls/option-controls.ts b/packages/app/src/agent-controls/option-controls.ts new file mode 100644 index 0000000000..ae513de23d --- /dev/null +++ b/packages/app/src/agent-controls/option-controls.ts @@ -0,0 +1,47 @@ +import type { + AgentOptionControl, + AgentOptionSelectControl, + AgentOptionToggleControl, + ProviderOptions, +} from "@getpaseo/protocol/agent-types"; + +/** + * The composer's option controls. A control only describes how to draw one option key; the + * value is read from `options`, which the daemon resolved (defaults, aliases, what runs). + */ +export interface ComposerOptionControls { + controls: AgentOptionControl[]; + options: ProviderOptions; + onSetOption: (key: string, value: unknown) => void; +} + +export function readToggleOption( + control: AgentOptionToggleControl, + options: ProviderOptions, +): boolean { + const value = options[control.key]; + return typeof value === "boolean" ? value : (control.defaultValue ?? false); +} + +/** The selected choice's value, or the control's default when the option names no choice. */ +export function readSelectOption( + control: AgentOptionSelectControl, + options: ProviderOptions, +): string | null { + const value = options[control.key]; + if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) { + return value; + } + return control.defaultValue ?? null; +} + +/** Whether the option is set to something other than the control's default. */ +export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean { + if (control.type === "toggle") return readToggleOption(control, options); + const value = readSelectOption(control, options); + return value !== null && value !== control.defaultValue; +} + +export function getControlTooltip(control: Pick): string { + return control.tooltip ?? control.label; +} diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts index dd60b6680f..507ea84e12 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.test.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.test.ts @@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => { model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }), ), ).toEqual({ @@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => { modelId: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }); }); @@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => { modelId: "", modeId: "", thinkingOptionId: "", - featureValues: {}, + options: {}, }); }); @@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => { model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }), ), ), @@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => { modelId: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { web_search: true }, + options: { web_search: true }, }); }); @@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => { it("omits an empty feature map so the agent keeps its own feature values", () => { expect( - toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))), - ).not.toHaveProperty("featureValues"); + toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))), + ).not.toHaveProperty("options"); }); it("never carries a provider, which a running agent cannot change", () => { diff --git a/packages/app/src/agent-profiles/internal/materialize-profile.ts b/packages/app/src/agent-profiles/internal/materialize-profile.ts index fc393f5ec2..e0e3df628c 100644 --- a/packages/app/src/agent-profiles/internal/materialize-profile.ts +++ b/packages/app/src/agent-profiles/internal/materialize-profile.ts @@ -12,7 +12,7 @@ export interface MaterializedAgentProfile { modelId: string; modeId: string; thinkingOptionId: string; - featureValues: Record; + options: Record; } function trimmed(value: string | undefined): string { @@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen modelId: trimmed(profile.model), modeId: trimmed(profile.modeId), thinkingOptionId: trimmed(profile.thinkingOptionId), - featureValues: profile.featureValues ?? {}, + options: profile.options ?? {}, }; } @@ -52,8 +52,6 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf ...(profile.modelId ? { modelId: profile.modelId } : {}), ...(profile.modeId ? { modeId: profile.modeId } : {}), ...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}), - ...(Object.keys(profile.featureValues).length > 0 - ? { featureValues: profile.featureValues } - : {}), + ...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}), }; } 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 7689621830..b8aca73b2d 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 @@ -1,7 +1,7 @@ import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types"; import { describe, expect, it } from "vitest"; import { - buildFeatureRequestKey, + buildControlsRequestKey, openAgentProfileForm, type AgentProfileFormModel, } from "./profile-form-model"; @@ -173,7 +173,7 @@ describe("openAgentProfileForm", () => { model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { webSearch: true }, + options: { webSearch: true }, notes: "Visual work only.", }, }); @@ -229,10 +229,11 @@ describe("openAgentProfileForm", () => { selectClaude(model); model.setModel("claude-opus-5", { label: "Opus 5" }); model.setThinking("think-hard", { label: "Think hard" }); - model.applyFeatures(model.getState().featureRequestKey ?? "", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); - model.setFeatureValue("webSearch", true); + model.applyControls(model.getState().controlsRequestKey ?? "", { + controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }); + model.setOption("webSearch", true); model.setProvider("codex", { label: "Codex" }); const state = model.getState(); @@ -242,8 +243,9 @@ describe("openAgentProfileForm", () => { expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" }); expect(state.thinkingOptionId).toBe(""); expect(state.thinkingDisplay).toBeNull(); - expect(state.featureValues).toEqual({}); - expect(state.features).toEqual([]); + expect(state.options).toEqual({}); + expect(state.optionEdits).toEqual({}); + expect(state.controls).toEqual([]); expect(state.name).toBe("Test writer"); }); @@ -334,23 +336,28 @@ describe("openAgentProfileForm", () => { }); }); - describe("features", () => { - it("asks for features per provider/model/mode/thinking combination", () => { + const WEB_SEARCH_CONTROLS = { + controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }; + + describe("controls", () => { + it("asks for controls per provider/model/mode/thinking combination", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); - expect(model.getState().featureRequest).toEqual({ + expect(model.getState().controlsRequest).toEqual({ provider: "claude", model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think", }); - const firstKey = model.getState().featureRequestKey; + const firstKey = model.getState().controlsRequestKey; model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); - expect(model.getState().featureRequestKey).not.toBe(firstKey); - expect(model.getState().featureRequest).toEqual({ + expect(model.getState().controlsRequestKey).not.toBe(firstKey); + expect(model.getState().controlsRequest).toEqual({ provider: "claude", model: "claude-haiku-4-5", modeId: "plan", @@ -361,77 +368,107 @@ describe("openAgentProfileForm", () => { it("moves from pending to complete only for the current request", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); - const key = model.getState().featureRequestKey ?? ""; - expect(model.getState().featureResolution).toBe("pending"); + const key = model.getState().controlsRequestKey ?? ""; + expect(model.getState().controlsResolution).toBe("pending"); - model.applyFeatures("stale-key", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); - expect(model.getState().featureResolution).toBe("pending"); - expect(model.getState().features).toEqual([]); + model.applyControls("stale-key", { + controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }); + expect(model.getState().controlsResolution).toBe("pending"); + expect(model.getState().controls).toEqual([]); - model.applyFeatures(key, [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); - expect(model.getState().featureResolution).toBe("complete"); - expect(model.getState().disclosure.showFeaturesField).toBe(true); + model.applyControls(key, { + controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], + options: { webSearch: false }, + }); + expect(model.getState().controlsResolution).toBe("complete"); + expect(model.getState().disclosure.showControlsField).toBe(true); }); - it("reflects a set feature value back into the rendered feature", () => { + it("reflects a set option back into the rendered controls", () => { const model = openWithCatalog({ mode: "create" }); + model.setName("Searcher"); selectClaude(model); - model.applyFeatures(model.getState().featureRequestKey ?? "", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); + model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS); - model.setFeatureValue("webSearch", true); + model.setOption("webSearch", true); - expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true }); - expect(model.getState().featureValues).toEqual({ webSearch: true }); + expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" }); + expect(model.getState().controlOptions).toEqual({ webSearch: true }); + expect(model.getState().submitValue?.options).toEqual({ webSearch: true }); }); - it("keeps stored feature values while the feature list is still pending", () => { + it("keeps stored options while the controls are still pending", () => { + const model = openAgentProfileForm({ + mode: "edit", + profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } }, + }); + model.applyProviderCatalog(ENTRIES); + + expect(model.getState().controlsResolution).toBe("pending"); + expect(model.getState().submitValue?.options).toEqual({ webSearch: true }); + }); + + it("keeps options no control draws, so editing a profile does not drop them", () => { const model = openAgentProfileForm({ mode: "edit", profile: { id: "p1", name: "UI work", provider: "claude", - featureValues: { webSearch: true }, + options: { webSearch: true, sandbox_mode: "read-only" }, }, }); model.applyProviderCatalog(ENTRIES); + model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS); + model.setOption("webSearch", false); - expect(model.getState().featureResolution).toBe("pending"); - expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true }); + expect(model.getState().submitValue?.options).toEqual({ + webSearch: false, + sandbox_mode: "read-only", + }); }); - it("prunes stored values the provider no longer reports", () => { + it("draws the options the daemon resolved from the stored ones, then the edits", () => { const model = openAgentProfileForm({ mode: "edit", - profile: { - id: "p1", - name: "UI work", - provider: "claude", - featureValues: { webSearch: true, retiredFlag: true }, - }, + profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } }, }); model.applyProviderCatalog(ENTRIES); - model.applyFeatures(model.getState().featureRequestKey ?? "", [ - { type: "toggle", id: "webSearch", label: "Web search", value: false }, - ]); - expect(model.getState().featureValues).toEqual({ webSearch: true }); + // The request carries the stored options so the daemon can resolve the legacy tier. + expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" }); + + model.applyControls(model.getState().controlsRequestKey ?? "", { + controls: [ + { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Standard" }, + { value: "priority", label: "Fast" }, + ], + }, + ], + options: { service_tier: "priority" }, + }); + + expect(model.getState().controlOptions).toEqual({ service_tier: "priority" }); + // Nothing was edited, so the stored value is saved as it was. + expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" }); }); it("resolves an unavailable feature listing without showing the field", () => { const model = openWithCatalog({ mode: "create" }); selectClaude(model); - model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? ""); + model.applyControlsUnavailable(model.getState().controlsRequestKey ?? ""); - expect(model.getState().featureResolution).toBe("complete"); - expect(model.getState().disclosure.showFeaturesField).toBe(false); + expect(model.getState().controlsResolution).toBe("complete"); + expect(model.getState().disclosure.showControlsField).toBe(false); }); }); @@ -464,15 +501,15 @@ describe("openAgentProfileForm", () => { }); }); -describe("buildFeatureRequestKey", () => { +describe("buildControlsRequestKey", () => { it("is null without a provider", () => { - expect(buildFeatureRequestKey(null)).toBeNull(); + expect(buildControlsRequestKey(null)).toBeNull(); }); it("separates the fields so a value cannot bleed across positions", () => { - expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||"); - expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe( - buildFeatureRequestKey({ provider: "claude", model: "a" }), + expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||"); + expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe( + buildControlsRequestKey({ provider: "claude", model: "a" }), ); }); }); 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 2ab6185054..5d455bd241 100644 --- a/packages/app/src/agent-profiles/internal/profile-form-model.ts +++ b/packages/app/src/agent-profiles/internal/profile-form-model.ts @@ -1,5 +1,6 @@ import type { - AgentFeature, + AgentOptionControl, + ProviderOptions, AgentMode, AgentModelDefinition, AgentSelectOption, @@ -7,7 +8,6 @@ import type { } from "@getpaseo/protocol/agent-types"; import type { AgentProfile } from "@getpaseo/protocol/messages"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; -import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences"; import { filterSelectableModels } from "@/provider-selection/model-catalog"; /** @@ -57,11 +57,15 @@ export interface AgentProfileFormSnapshot { } /** - * The inputs a feature listing needs. Features are provider-scoped and change - * with model/mode/thinking, so every one of those is part of the request. + * The inputs a controls listing needs. Controls are provider-scoped and change + * with model/mode/thinking, so every one of those is part of the request. The + * profile's own options ride along so the daemon can resolve them (a legacy + * `service_tier: "fast"` is drawn as the model's Fast tier); they are the same + * for the life of one provider, so they are not part of the key. */ -export interface AgentProfileFeatureRequest { +export interface AgentProfileControlsRequest { provider: string; + providerOptions?: ProviderOptions; model?: string; modeId?: string; thinkingOptionId?: string; @@ -73,7 +77,7 @@ export interface AgentProfileFormDisclosure { showModelField: boolean; showModeField: boolean; showThinkingField: boolean; - showFeaturesField: boolean; + showControlsField: boolean; } export interface AgentProfileFormState { @@ -86,13 +90,18 @@ export interface AgentProfileFormState { modelId: string; modeId: string; thinkingOptionId: string; - featureValues: Record; + /** The options the profile was opened with, including ones no control draws. */ + options: ProviderOptions; + /** Changes made in this form, over `options`. */ + optionEdits: ProviderOptions; providerOptions: AgentProfileFormOption[]; modelOptions: AgentProfileFormOption[]; modeOptions: AgentProfileFormOption[]; thinkingOptions: AgentProfileFormOption[]; - features: AgentFeature[]; + controls: AgentOptionControl[]; + /** What the controls show: the options the daemon resolved from `options`, then the edits. */ + controlOptions: ProviderOptions; providerDisplay: AgentProfileFormDisplay | null; modelDisplay: AgentProfileFormDisplay | null; @@ -100,9 +109,9 @@ export interface AgentProfileFormState { thinkingDisplay: AgentProfileFormDisplay | null; catalogResolution: AgentProfileResolutionStatus; - featureResolution: AgentProfileResolutionStatus; - featureRequest: AgentProfileFeatureRequest | null; - featureRequestKey: string | null; + controlsResolution: AgentProfileResolutionStatus; + controlsRequest: AgentProfileControlsRequest | null; + controlsRequestKey: string | null; disclosure: AgentProfileFormDisclosure; isSubmitting: boolean; @@ -118,9 +127,12 @@ export interface AgentProfileFormModel { /** Late input: the host-scoped provider catalog. Never touches selections. */ applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void; /** Late input: the feature list for one request. Stale keys are ignored. */ - applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void; + applyControls: ( + requestKey: string, + resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions }, + ) => void; /** Resolve a request that produced no usable features (provider error). */ - applyFeaturesUnavailable: (requestKey: string) => void; + applyControlsUnavailable: (requestKey: string) => void; setName: (value: string) => void; setAppearance: (value: { icon: string; color: string }) => void; setNotes: (value: string) => void; @@ -128,7 +140,7 @@ export interface AgentProfileFormModel { setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void; setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void; setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void; - setFeatureValue: (featureId: string, value: unknown) => void; + setOption: (key: string, value: unknown) => void; setSubmitting: (value: boolean) => void; setSubmitError: (value: string | null) => void; } @@ -312,7 +324,7 @@ function refineDisplay( return current ?? { label: selectedId }; } -function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null { +function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null { if (!state.provider) { return null; } @@ -321,10 +333,13 @@ function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureR ...(state.modelId ? { model: state.modelId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), + ...(Object.keys(state.options).length > 0 ? { providerOptions: state.options } : {}), }; } -export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null { +export function buildControlsRequestKey( + request: AgentProfileControlsRequest | null, +): string | null { if (!request) { return null; } @@ -342,6 +357,7 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul if (!name || !state.provider) { return null; } + const options = { ...state.options, ...state.optionEdits }; return { name, ...(state.icon ? { icon: state.icon } : {}), @@ -350,16 +366,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul ...(state.modelId ? { model: state.modelId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), - ...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}), + ...(Object.keys(options).length > 0 ? { options } : {}), ...(notes ? { notes } : {}), }; } -function resolveFeatureStatus( - featureRequestKey: string | null, +function resolveControlsStatus( + controlsRequestKey: string | null, featuresAreCurrent: boolean, ): AgentProfileResolutionStatus { - if (featureRequestKey === null) { + if (controlsRequestKey === null) { return "idle"; } return featuresAreCurrent ? "complete" : "pending"; @@ -392,25 +408,27 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm modelId, modeId: profile.modeId ?? "", thinkingOptionId: profile.thinkingOptionId ?? "", - featureValues: { ...profile.featureValues }, + options: { ...profile.options }, + optionEdits: {}, providerOptions: [], modelOptions: [], modeOptions: [], thinkingOptions: [], - features: [], + controls: [], + controlOptions: {}, providerDisplay: seedDisplay(provider), modelDisplay: seedDisplay(modelId), modeDisplay: seedDisplay(profile.modeId), thinkingDisplay: seedDisplay(profile.thinkingOptionId), catalogResolution: "idle", - featureResolution: "idle", - featureRequest: null, - featureRequestKey: null, + controlsResolution: "idle", + controlsRequest: null, + controlsRequestKey: null, disclosure: { showModelField: false, showModeField: false, showThinkingField: false, - showFeaturesField: false, + showControlsField: false, }, isSubmitting: false, submitError: null, @@ -422,8 +440,9 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel { let entries: readonly ProviderSnapshotEntry[] = []; let catalogResolution: AgentProfileResolutionStatus = "idle"; - let resolvedFeatureKey: string | null = null; - let resolvedFeatures: AgentFeature[] = []; + let resolvedControlsKey: string | null = null; + let resolvedControls: AgentOptionControl[] = []; + let resolvedOptions: ProviderOptions = {}; let listeners = new Set<() => void>(); let closed = false; @@ -436,14 +455,15 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP modes, }); const thinking = resolveThinkingOptions(entries, next.provider, next.modelId); - const featureRequest = buildFeatureRequest(next); - const featureRequestKey = buildFeatureRequestKey(featureRequest); + const controlsRequest = buildControlsRequest(next); + const controlsRequestKey = buildControlsRequestKey(controlsRequest); const featuresAreCurrent = - featureRequestKey !== null && featureRequestKey === resolvedFeatureKey; - const features = featuresAreCurrent - ? applyFeatureValues(resolvedFeatures, next.featureValues) - : []; - const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent); + controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey; + const controls = featuresAreCurrent ? resolvedControls : []; + const controlOptions = featuresAreCurrent + ? { ...resolvedOptions, ...next.optionEdits } + : next.options; + const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent); const withOptions: AgentProfileFormState = { ...next, @@ -451,7 +471,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP modelOptions: buildModelOptions(models), modeOptions: buildModeOptions(modes), thinkingOptions: buildThinkingOptions(thinking), - features, + controls, + controlOptions, providerDisplay: refineDisplay( next.providerDisplay, next.provider, @@ -479,9 +500,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP })(), ), catalogResolution, - featureResolution, - featureRequest, - featureRequestKey, + controlsResolution, + controlsRequest, + controlsRequestKey, }; // A field appears once the catalog can populate it, and stays visible while @@ -492,7 +513,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)), showThinkingField: hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)), - showFeaturesField: hasProvider && features.length > 0, + showControlsField: hasProvider && controls.length > 0, }; const canSubmit = withOptions.name.trim().length > 0 && @@ -531,23 +552,22 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP catalogResolution = "complete"; publish((current) => current); }, - applyFeatures: (requestKey, features) => { - if (requestKey !== state.featureRequestKey) { + applyControls: (requestKey, resolved) => { + if (requestKey !== state.controlsRequestKey) { return; } - resolvedFeatureKey = requestKey; - resolvedFeatures = [...features]; - publish((current) => ({ - ...current, - featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures), - })); + resolvedControlsKey = requestKey; + resolvedControls = [...resolved.controls]; + resolvedOptions = resolved.options; + publish((current) => current); }, - applyFeaturesUnavailable: (requestKey) => { - if (requestKey !== state.featureRequestKey) { + applyControlsUnavailable: (requestKey) => { + if (requestKey !== state.controlsRequestKey) { return; } - resolvedFeatureKey = requestKey; - resolvedFeatures = []; + resolvedControlsKey = requestKey; + resolvedControls = []; + resolvedOptions = {}; publish((current) => current); }, setName: (value) => publish((current) => ({ ...current, name: value })), @@ -572,7 +592,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP modeDisplay: null, thinkingOptionId: "", thinkingDisplay: null, - featureValues: {}, + options: {}, + optionEdits: {}, }; }), setModel: (modelId, display) => @@ -598,10 +619,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP publish((current) => ({ ...current, modeId, modeDisplay: display })), setThinking: (thinkingOptionId, display) => publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })), - setFeatureValue: (featureId, value) => + setOption: (key, value) => publish((current) => ({ ...current, - featureValues: { ...current.featureValues, [featureId]: value }, + optionEdits: { ...current.optionEdits, [key]: value }, })), setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })), setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })), diff --git a/packages/app/src/agent-profiles/internal/profile-summary.ts b/packages/app/src/agent-profiles/internal/profile-summary.ts index a0507e6b94..28431b093c 100644 --- a/packages/app/src/agent-profiles/internal/profile-summary.ts +++ b/packages/app/src/agent-profiles/internal/profile-summary.ts @@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: { tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) }); } - const featureCount = Object.keys(input.profile.featureValues ?? {}).length; + const featureCount = Object.keys(input.profile.options ?? {}).length; if (featureCount > 0) { tags.push({ id: "features", label: input.formatFeatureCount(featureCount) }); } diff --git a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts index c4b97ec229..589d4d0b29 100644 --- a/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts +++ b/packages/app/src/agent-profiles/internal/use-agent-profile-picker.ts @@ -117,9 +117,7 @@ export function useAgentProfilePicker( modelId: resolved.modelId, modeId: resolved.modeId, thinkingOptionId: resolved.thinkingOptionId, - ...(Object.keys(resolved.featureValues).length > 0 - ? { featureValues: resolved.featureValues } - : {}), + ...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}), }), ).catch((error) => { console.warn("[useAgentProfilePicker] persist profile selection failed", error); diff --git a/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts index fb78320ca1..d7cfb2b6e6 100644 --- a/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts +++ b/packages/app/src/agent-profiles/internal/use-profile-form-inputs.ts @@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: { }, [entries, input.model]); } -export function useAgentProfileFormFeatures(input: { +export function useAgentProfileFormControls(input: { serverId: string; model: AgentProfileFormModel; state: AgentProfileFormState; @@ -35,14 +35,14 @@ export function useAgentProfileFormFeatures(input: { const { t } = useTranslation(); const client = useHostRuntimeClient(input.serverId); const isConnected = useHostRuntimeIsConnected(input.serverId); - const request = input.state.featureRequest; - const requestKey = input.state.featureRequestKey; + const request = input.state.controlsRequest; + const requestKey = input.state.controlsRequestKey; // `dataShape: "value"` and not `"list"`: a list shape keeps the previous // page's data while the next request is in flight, which would hand the // features of one provider/model combination to the key of another. - const featuresQuery = useFetchQuery({ - queryKey: ["agentProfileFeatures", input.serverId, requestKey], + const controlsQuery = useFetchQuery({ + queryKey: ["agentProfileControls", input.serverId, requestKey], dataShape: "value", staleTimeMs: 5 * 60 * 1000, enabled: Boolean(client && isConnected && request), @@ -51,28 +51,28 @@ export function useAgentProfileFormFeatures(input: { if (!client || !request) { throw new Error(t("workspace.terminal.hostDisconnected")); } - const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD }); + const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD }); if (payload.error) { throw new Error(payload.error); } - return payload.features ?? []; + return { controls: payload.controls ?? [], options: payload.options ?? {} }; }, }); - const { data, isError } = featuresQuery; + const { data, isError } = controlsQuery; const { model } = input; useEffect(() => { if (!requestKey || !data) { return; } - model.applyFeatures(requestKey, data); + model.applyControls(requestKey, data); }, [data, model, requestKey]); useEffect(() => { if (!requestKey || !isError) { return; } - model.applyFeaturesUnavailable(requestKey); + model.applyControlsUnavailable(requestKey); }, [isError, model, requestKey]); } diff --git a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx index cc06432d62..8632ba4c21 100644 --- a/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx +++ b/packages/app/src/agent-profiles/settings/agent-profile-edit-modal.tsx @@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement } import { Text, View } from "react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet } from "react-native-unistyles"; -import type { AgentFeature } from "@getpaseo/protocol/agent-types"; +import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls"; import type { AgentProfile } from "@getpaseo/protocol/messages"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Button } from "@/components/ui/button"; @@ -25,7 +26,7 @@ import type { } from "../internal/profile-form-model"; import { useAgentProfileFormCatalog, - useAgentProfileFormFeatures, + useAgentProfileFormControls, } from "../internal/use-profile-form-inputs"; import { useAgentProfileFormModel, @@ -141,7 +142,7 @@ function OpenAgentProfileEditModal({ const model = useAgentProfileFormModel(snapshot); const state = useAgentProfileFormState(model); useAgentProfileFormCatalog({ serverId, model }); - useAgentProfileFormFeatures({ serverId, model, state }); + useAgentProfileFormControls({ serverId, model, state }); const sheetHeader = useMemo( () => ({ @@ -328,16 +329,17 @@ function OpenAgentProfileEditModal({ /> ) : null} - {state.disclosure.showFeaturesField ? ( + {state.disclosure.showControlsField ? ( - {state.features.map((feature, index) => ( - ( + model.setFeatureValue(feature.id, value), - [feature.id, model], + (value: boolean) => model.setOption(control.key, value), + [control.key, model], ); const handleSelect = useCallback( - (value: string) => model.setFeatureValue(feature.id, value), - [feature.id, model], + (value: string) => model.setOption(control.key, value), + [control.key, model], ); const selectOptions = useMemo[]>( () => - feature.type === "select" - ? feature.options.map((option) => ({ - id: option.id, - value: option.id, - label: option.label, - ...(option.description ? { description: option.description } : {}), - testID: `agent-profile-feature-option-${feature.id}-${option.id}`, + control.type === "select" + ? control.choices.map((choice) => ({ + id: choice.value, + value: choice.value, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + testID: `agent-profile-feature-option-${control.key}-${choice.value}`, })) : [], - [feature], + [control], ); + const selectedValue = control.type === "select" ? readSelectOption(control, options) : null; const selectedDisplay = useMemo(() => { - if (feature.type !== "select" || !feature.value) { + if (control.type !== "select" || !selectedValue) { return null; } - const option = feature.options.find((candidate) => candidate.id === feature.value); - return { label: option?.label ?? feature.value }; - }, [feature]); + const choice = control.choices.find((candidate) => candidate.value === selectedValue); + return { label: choice?.label ?? selectedValue }; + }, [control, selectedValue]); - if (feature.type === "select") { + if (control.type === "select") { return ( - + ); } return ( - + - {feature.label} + {control.label} - {feature.description ? ( + {control.description ? ( - {feature.description} + {control.description} ) : null} ); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 72923b4212..c5da2f77a7 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -1304,7 +1304,8 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent) if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) { reasons.push("agent.runtimeInfo.thinkingOptionId"); } - if (left.features !== right.features) reasons.push("agent.features"); + if (left.options !== right.options) reasons.push("agent.options"); + if (left.controls !== right.controls) reasons.push("agent.controls"); return reasons; } diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts index c6e86be582..dc2850615f 100644 --- a/packages/app/src/client-slash-commands/index.test.ts +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -40,16 +40,7 @@ function createAgent(overrides: Partial = {}): Agent { cwd: "/repo", model: "agent-model", thinkingOptionId: "think-hard", - features: [ - { type: "toggle", id: "web-search", label: "Web search", value: true }, - { - type: "select", - id: "effort", - label: "Effort", - value: "high", - options: [{ id: "high", label: "High" }], - }, - ], + options: { "web-search": true, effort: "high" }, parentAgentId: null, labels: {}, ...overrides, @@ -114,7 +105,7 @@ describe("buildDraftAgentSetup", () => { modeId: "mode-current", model: "agent-model", thinkingOptionId: "think-hard", - featureValues: { + providerOptions: { "web-search": true, effort: "high", }, diff --git a/packages/app/src/client-slash-commands/index.ts b/packages/app/src/client-slash-commands/index.ts index 5d1a31f7d2..9a85d01591 100644 --- a/packages/app/src/client-slash-commands/index.ts +++ b/packages/app/src/client-slash-commands/index.ts @@ -65,17 +65,12 @@ export function resolveClientSlashCommand(input: { } export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup { - const featureValues: Record = {}; - for (const feature of agent.features ?? []) { - featureValues[feature.id] = feature.value; - } - return { provider: agent.provider, cwd: agent.cwd, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, - featureValues, + providerOptions: { ...agent.options }, }; } diff --git a/packages/app/src/command-center/agent-control-contributions.test.ts b/packages/app/src/command-center/agent-control-contributions.test.ts index 6fe8f91bb6..929804d913 100644 --- a/packages/app/src/command-center/agent-control-contributions.test.ts +++ b/packages/app/src/command-center/agent-control-contributions.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types"; +import type { + AgentMode, + AgentOptionControl, + AgentSelectOption, +} from "@getpaseo/protocol/agent-types"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { CommandCenterContribution } from "./contributions"; import { @@ -31,7 +35,7 @@ const ICONS: AgentControlContributionSource["icons"] = { thinking: TestIcon, planMode: TestIcon, mode: () => TestIcon, - feature: () => TestIcon, + control: () => TestIcon, }; function provider(input: { @@ -65,8 +69,8 @@ function provider(input: { }; } -function toggleFeature(id: string, value = false, icon?: string): AgentFeature { - return { type: "toggle", id, label: id, value, icon }; +function toggleControl(key: string, icon?: string): AgentOptionControl { + return { type: "toggle", key, label: key, icon }; } function makeSource( @@ -75,7 +79,7 @@ function makeSource( models?: Partial; thinking?: Partial; modes?: Partial>; - features?: Partial; + optionControls?: Partial; } = {}, ): AgentControlContributionSource { return { @@ -104,10 +108,11 @@ function makeSource( select: () => undefined, ...overrides.modes, }, - features: { - list: [], - set: () => undefined, - ...overrides.features, + optionControls: { + controls: [], + options: {}, + onSetOption: () => undefined, + ...overrides.optionControls, }, }; } @@ -133,7 +138,7 @@ describe("Command Center agent-control contributions", () => { const contributions = buildAgentControlContributions({ ...makeSource({ models: { providers: [provider({ id: "claude", label: "Claude" })] }, - features: { list: [toggleFeature("plan_mode", true)] }, + optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } }, }), modes: undefined, }); @@ -323,9 +328,10 @@ describe("Command Center agent-control contributions", () => { selectedId: "default", defaultModeId: "default", }, - features: { - list: [toggleFeature("plan_mode", true, "list-todo")], - set: (id, value) => { + optionControls: { + controls: [toggleControl("plan_mode", "list-todo")], + options: { plan_mode: true }, + onSetOption: (id, value) => { calls.push([id, value]); }, }, @@ -341,9 +347,10 @@ describe("Command Center agent-control contributions", () => { const calls: [string, unknown][] = []; const contributions = buildAgentControlContributions( makeSource({ - features: { - list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)], - set: (id, value) => { + optionControls: { + controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")], + options: { fast_mode: false, auto_accept: true }, + onSetOption: (id, value) => { calls.push([id, value]); }, }, @@ -376,7 +383,7 @@ describe("Command Center agent-control contributions", () => { selectedId: "default", defaultModeId: "default", }, - features: { list: [toggleFeature("fast_mode")] }, + optionControls: { controls: [toggleControl("fast_mode")], options: {} }, }), ); const ranks = new Map(); diff --git a/packages/app/src/command-center/agent-control-contributions.ts b/packages/app/src/command-center/agent-control-contributions.ts index d8f31523e9..b16ee90549 100644 --- a/packages/app/src/command-center/agent-control-contributions.ts +++ b/packages/app/src/command-center/agent-control-contributions.ts @@ -1,9 +1,10 @@ import type { - AgentFeature, AgentMode, + AgentOptionControl, AgentProvider, AgentSelectOption, } from "@getpaseo/protocol/agent-types"; +import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; import { @@ -61,7 +62,7 @@ export interface AgentControlContributionIcons { thinking: CommandCenterIcon; planMode: CommandCenterIcon; mode(modeId: string): CommandCenterIcon; - feature(feature: AgentFeature): CommandCenterIcon; + control(control: AgentOptionControl): CommandCenterIcon; } interface CommandCenterChoice { @@ -105,10 +106,7 @@ export interface AgentControlContributionSource { defaultModeId: string | null; select(modeId: string): void | Promise; }; - features: { - list: readonly AgentFeature[]; - set(featureId: string, value: unknown): void | Promise; - }; + optionControls: ComposerOptionControls; } function buildContributions( @@ -242,18 +240,19 @@ function toggleChoices(input: { } function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { - const planFeature = source.features.list.find( - (feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle", + const { controls, options, onSetOption } = source.optionControls; + const planControl = controls.find( + (control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle", ); let choices: CommandCenterChoice[] = []; - if (planFeature?.type === "toggle") { + if (planControl?.type === "toggle") { choices = toggleChoices({ source, key: "plan", - icon: source.icons.feature(planFeature), - isOn: planFeature.value, - turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true), - turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false), + icon: source.icons.control(planControl), + isOn: readToggleOption(planControl, options), + turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true), + turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false), }); } else if (source.modes) { const modes = source.modes; @@ -280,18 +279,19 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent } function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { - const fastFeature = source.features.list.find( - (feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle", + const { controls, options, onSetOption } = source.optionControls; + const fastControl = controls.find( + (control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle", ); const choices = - fastFeature?.type === "toggle" + fastControl?.type === "toggle" ? toggleChoices({ source, key: "fast", - icon: source.icons.feature(fastFeature), - isOn: fastFeature.value, - turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true), - turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false), + icon: source.icons.control(fastControl), + isOn: readToggleOption(fastControl, options), + turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true), + turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false), }) : []; return { diff --git a/packages/app/src/command-center/agent-control-registration.tsx b/packages/app/src/command-center/agent-control-registration.tsx index 21ce492373..81391dba10 100644 --- a/packages/app/src/command-center/agent-control-registration.tsx +++ b/packages/app/src/command-center/agent-control-registration.tsx @@ -1,11 +1,6 @@ import { useMemo } from "react"; import { useTranslation } from "react-i18next"; -import type { - AgentFeature, - AgentMode, - AgentProvider, - AgentSelectOption, -} from "@getpaseo/protocol/agent-types"; +import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getAgentFeatureIcon, @@ -14,6 +9,7 @@ import { ThinkingIcon, } from "@/agent-controls/icons"; import { useProviderIcons } from "@/components/provider-icons"; +import type { ComposerOptionControls } from "@/agent-controls/option-controls"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import { buildAgentControlContributions, @@ -44,15 +40,15 @@ export interface AgentControlCommandCenterSource { selectedId: string | null | undefined; select(modeId: string): void | Promise; }; - features: { - list: readonly AgentFeature[] | undefined; - set?: (featureId: string, value: unknown) => void | Promise; - }; + /** Absent when the surface cannot change options (the agent has no client). */ + optionControls: ComposerOptionControls | null; } -function ignoreAgentFeatureChange(): void { - return; -} +const NO_OPTION_CONTROLS: ComposerOptionControls = { + controls: [], + options: {}, + onSetOption: () => undefined, +}; function resolveDefaultModeId( provider: string | null | undefined, @@ -87,7 +83,7 @@ export function useAgentControlCommandCenterActions(input: { }): void { const { t } = useTranslation(); const { controls } = input; - const { features, models, modes, thinking } = controls; + const { optionControls, models, modes, thinking } = controls; const getProviderIcon = useProviderIcons(controls.serverId); const actions = useMemo( () => @@ -104,7 +100,7 @@ export function useAgentControlCommandCenterActions(input: { getCommandCenterIcon( getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions), ), - feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)), + control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)), }, models: { providers: models.providers, @@ -123,10 +119,7 @@ export function useAgentControlCommandCenterActions(input: { modes?.select, resolveDefaultModeId(controls.provider, controls.providerDefinitions), ), - features: { - list: features.set ? (features.list ?? []) : [], - set: features.set ?? ignoreAgentFeatureChange, - }, + optionControls: optionControls ?? NO_OPTION_CONTROLS, }), [ getProviderIcon, @@ -134,8 +127,7 @@ export function useAgentControlCommandCenterActions(input: { controls.provider, controls.providerDefinitions, controls.serverId, - features.list, - features.set, + optionControls, models.providers, models.select, models.selectedModelId, diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 72795289cf..69c3567313 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -25,6 +25,13 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { useShallow } from "zustand/shallow"; import { Settings2 } from "lucide-react-native"; import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; +import { + getControlTooltip, + isControlActive, + readSelectOption, + readToggleOption, + type ComposerOptionControls, +} from "@/agent-controls/option-controls"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { CombinedModelSelector } from "@/components/combined-model-selector"; @@ -47,7 +54,8 @@ import { import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import type { - AgentFeature, + AgentOptionControl, + ProviderOptions, AgentMode, AgentModelDefinition, AgentProvider, @@ -55,8 +63,6 @@ import type { import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import { getFeatureHighlightColor, - isFeatureActive, - getFeatureTooltip, getAgentControlHintKey, resolveAgentModelSelection, } from "@/composer/agent-controls/utils"; @@ -101,6 +107,7 @@ interface AgentControlOption { type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = []; +const EMPTY_OPTIONS: ProviderOptions = {}; interface ControlledAgentControlsProps { provider: string; @@ -122,8 +129,7 @@ interface ControlledAgentControlsProps { onEditAgentProfiles?: () => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onEditAgentProfile?: (profileId: string) => void; - features?: AgentFeature[]; - onSetFeature?: (featureId: string, value: unknown) => void; + optionControls?: ComposerOptionControls; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; @@ -150,8 +156,7 @@ export interface DraftAgentControlsProps { selectedThinkingOptionId: string; onSelectThinkingOption: (thinkingOptionId: string) => void; onApplyAgentProfile: DraftAgentProfileControls["applyProfile"]; - features?: AgentFeature[]; - onSetFeature?: (featureId: string, value: unknown) => void; + optionControls?: ComposerOptionControls; onDropdownClose?: () => void; onModelSelectorOpen?: () => void; onRetryModelProvider?: (provider: AgentProvider) => void; @@ -212,7 +217,8 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) { } function getFeatureIconColor( - feature: AgentFeature, + control: AgentOptionControl, + options: ProviderOptions, palette: { blue: { 400: string }; green: { 400: string }; @@ -220,11 +226,11 @@ function getFeatureIconColor( }, foregroundMuted: string, ): string { - if (!isFeatureActive(feature)) { + if (!isControlActive(control, options)) { return foregroundMuted; } - switch (getFeatureHighlightColor(feature.id)) { + switch (getFeatureHighlightColor(control.key)) { case "blue": return palette.blue[400]; case "green": @@ -242,20 +248,20 @@ function resolveHasAnyControl({ providerOptions, canSelectModel, thinkingOptions, - features, + controls, hasMode, }: { providerOptions: AgentControlOption[] | undefined; canSelectModel: boolean; thinkingOptions: AgentControlOption[] | undefined; - features: AgentFeature[] | undefined; + controls: readonly AgentOptionControl[] | undefined; hasMode: boolean; }) { return ( Boolean(providerOptions?.length) || canSelectModel || Boolean(thinkingOptions?.length) || - Boolean(features?.length) || + Boolean(controls?.length) || hasMode ); } @@ -393,7 +399,8 @@ type AgentControlsSlice = { cwd: string | null; runtimeModelId: string | null; model: string | null | undefined; - features: AgentFeature[] | undefined; + options: ProviderOptions | undefined; + controls: AgentOptionControl[] | undefined; runtimeThinkingOptionId: string | null; thinkingOptionId: string | null | undefined; lastUsage: unknown; @@ -413,7 +420,8 @@ function selectAgentControlsSlice( cwd: currentAgent.cwd, runtimeModelId: currentAgent.runtimeInfo?.model ?? null, model: currentAgent.model, - features: currentAgent.features, + options: currentAgent.options, + controls: currentAgent.controls, runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: currentAgent.thinkingOptionId, lastUsage: currentAgent.lastUsage, @@ -493,8 +501,7 @@ function ControlledAgentControls({ onEditAgentProfiles, onCreateAgentProfile, onEditAgentProfile, - features, - onSetFeature, + optionControls, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, @@ -546,20 +553,21 @@ function ControlledAgentControls({ providerOptions, canSelectModel, thinkingOptions, - features, + controls: optionControls?.controls, hasMode: modeControl !== null && modeControl !== undefined, }); const featureControls = useMemo( () => - (features ?? []).map((feature) => { - if (feature.type === "toggle") return { type: "toggle" as const }; - const selectedOption = feature.options.find((option) => option.id === feature.value); + (optionControls?.controls ?? []).map((control) => { + if (control.type === "toggle") return { type: "toggle" as const }; + const selected = readSelectOption(control, optionControls?.options ?? {}); + const choice = control.choices.find((candidate) => candidate.value === selected); return { type: "select" as const, - label: selectedOption?.label ?? feature.label, + label: choice?.label ?? control.label, }; }), - [features], + [optionControls], ); const controlPresence = useMemo( () => ({ @@ -743,8 +751,7 @@ function ControlledAgentControls({ selectedModelId={selectedModelId} thinkingOptions={formattedThinkingOptions} selectedThinkingOptionId={selectedThinkingOptionId} - features={features} - onSetFeature={onSetFeature} + optionControls={optionControls} onApplyAgentProfile={onApplyAgentProfile} onEditAgentProfiles={onEditAgentProfiles} onCreateAgentProfile={onCreateAgentProfile} @@ -792,8 +799,7 @@ function ControlledAgentControls({ provider={provider} selectedModelId={selectedModelId} selectedThinkingOptionId={selectedThinkingOptionId} - features={features} - onSetFeature={onSetFeature} + optionControls={optionControls} onApplyAgentProfile={onApplyAgentProfile} onEditAgentProfiles={onEditAgentProfiles} onCreateAgentProfile={onCreateAgentProfile} @@ -838,8 +844,7 @@ interface DesktopAgentControlsContentProps { selectedModelId?: string; thinkingOptions?: AgentControlOption[]; selectedThinkingOptionId?: string; - features?: AgentFeature[]; - onSetFeature?: (featureId: string, value: unknown) => void; + optionControls?: ComposerOptionControls; onApplyAgentProfile?: (profileId: string) => void; onEditAgentProfiles?: () => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void; @@ -900,8 +905,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { selectedModelId, thinkingOptions, selectedThinkingOptionId, - features, - onSetFeature, + optionControls, onApplyAgentProfile, onEditAgentProfiles, onCreateAgentProfile, @@ -1057,7 +1061,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) { {modeControl ? : null} - {presentation.aggregateFeatures && features?.length ? ( + {presentation.aggregateFeatures && optionControls?.controls.length ? ( <> - {features.map((feature) => ( + {optionControls.controls.map((control) => ( ))} ) : ( - features?.map((feature) => ( + optionControls?.controls.map((control) => ( )) @@ -1110,8 +1116,7 @@ interface SheetAgentControlsContentProps { provider: string; selectedModelId?: string; selectedThinkingOptionId?: string; - features?: AgentFeature[]; - onSetFeature?: (featureId: string, value: unknown) => void; + optionControls?: ComposerOptionControls; onApplyAgentProfile?: (profileId: string) => void; onEditAgentProfiles?: () => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void; @@ -1154,8 +1159,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { provider, selectedModelId, selectedThinkingOptionId, - features, - onSetFeature, + optionControls, onApplyAgentProfile, onEditAgentProfiles, onCreateAgentProfile, @@ -1188,6 +1192,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { } = props; const thinkingAnchorRef = useRef(null); + const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS; const hasThinking = comboboxThinkingOptions.length > 0; @@ -1238,14 +1243,15 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { {modeControl ? : null} - {(features ?? []).map((feature) => ( + {(optionControls?.controls ?? []).map((control) => ( ))} @@ -1279,22 +1285,24 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) { } function DesktopFeatureItem({ - feature, + control, + options, disabled, openSelector, handleOpenChange, - onSetFeature, + onSetOption, onActionComplete, }: { - feature: AgentFeature; + control: AgentOptionControl; + options: ProviderOptions; disabled: boolean; openSelector: AgentControlSelector | null; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - onSetFeature?: (featureId: string, value: unknown) => void; + onSetOption?: (key: string, value: unknown) => void; onActionComplete?: () => void; }) { const { theme } = useUnistyles(); - const featureSelector: AgentControlSelector = `feature-${feature.id}`; + const featureSelector: AgentControlSelector = `feature-${control.key}`; const featureAnchorRef = useRef(null); const handleFeatureOpenChange = useMemo( @@ -1307,120 +1315,117 @@ function DesktopFeatureItem({ ); const handleTogglePress = useCallback(() => { - if (feature.type === "toggle") { - onSetFeature?.(feature.id, !feature.value); + if (control.type === "toggle") { + onSetOption?.(control.key, !readToggleOption(control, options)); onActionComplete?.(); } - }, [feature, onActionComplete, onSetFeature]); + }, [control, onActionComplete, onSetOption, options]); const handleSelectOption = useCallback( - (optionId: string) => { - onSetFeature?.(feature.id, optionId); + (choiceValue: string) => { + onSetOption?.(control.key, choiceValue); }, - [feature.id, onSetFeature], + [control.key, onSetOption], ); const comboboxOptions = useMemo( () => - feature.type === "select" - ? feature.options.map((option) => ({ id: option.id, label: option.label })) + control.type === "select" + ? control.choices.map((choice) => ({ id: choice.value, label: choice.label })) : [], - [feature], + [control], ); - if (feature.type === "toggle") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); + const FeatureIcon = getAgentFeatureIcon(control.icon); + const iconColor = getFeatureIconColor( + control, + options, + theme.colors.palette, + theme.colors.foregroundMuted, + ); + + if (control.type === "toggle") { return ( - {getFeatureTooltip(feature)} + {getControlTooltip(control)} ); } - if (feature.type === "select") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); - const selectedOption = feature.options.find((o) => o.id === feature.value); - const iconOnly = feature.desktopTrigger === "icon"; - const tooltip = iconOnly - ? `${feature.label}: ${selectedOption?.label ?? feature.label}` - : getFeatureTooltip(feature); - return ( - <> - - - - - - {tooltip} - - - - - ); - } - - return null; + const selectedValue = readSelectOption(control, options); + const selectedChoice = control.choices.find((choice) => choice.value === selectedValue); + const iconOnly = control.desktopTrigger === "icon"; + const tooltip = iconOnly + ? `${control.label}: ${selectedChoice?.label ?? control.label}` + : getControlTooltip(control); + return ( + <> + + + + + + {tooltip} + + + + + ); } function SheetFeatureItem({ - feature, + control, + options, disabled, openSelector, handleOpenChange, - onSetFeature, + onSetOption, }: { - feature: AgentFeature; + control: AgentOptionControl; + options: ProviderOptions; disabled: boolean; openSelector: AgentControlSelector | null; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; - onSetFeature?: (featureId: string, value: unknown) => void; + onSetOption?: (key: string, value: unknown) => void; }) { const { theme } = useUnistyles(); const { t } = useTranslation(); - const featureSelector: AgentControlSelector = `feature-${feature.id}`; + const featureSelector: AgentControlSelector = `feature-${control.key}`; const featureAnchorRef = useRef(null); const handleFeatureOpenChange = useMemo( @@ -1431,97 +1436,71 @@ function SheetFeatureItem({ () => handleFeatureOpenChange(openSelector !== featureSelector), [featureSelector, handleFeatureOpenChange, openSelector], ); - const sheetHeader = useMemo(() => ({ title: feature.label }), [feature.label]); + const sheetHeader = useMemo(() => ({ title: control.label }), [control.label]); const handleSelectOption = useCallback( - (optionId: string) => { - onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId); + (choiceValue: string) => { + onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue); }, - [feature.id, feature.type, onSetFeature], + [control.key, control.type, onSetOption], ); const comboboxOptions = useMemo(() => { - if (feature.type === "select") { - return feature.options.map((option) => ({ id: option.id, label: option.label })); + if (control.type === "select") { + return control.choices.map((choice) => ({ id: choice.value, label: choice.label })); } return [ { id: "true", label: t("agentControls.features.on") }, { id: "false", label: t("agentControls.features.off") }, ]; - }, [feature, t]); + }, [control, t]); - if (feature.type === "toggle") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); - return ( - <> - - - - ); + const FeatureIcon = getAgentFeatureIcon(control.icon); + const iconColor = getFeatureIconColor( + control, + options, + theme.colors.palette, + theme.colors.foregroundMuted, + ); + let selectedValue: string; + let displayValue: string; + if (control.type === "toggle") { + const isOn = readToggleOption(control, options); + selectedValue = String(isOn); + displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off"); + } else { + const selected = readSelectOption(control, options); + selectedValue = String(selected); + displayValue = + control.choices.find((choice) => choice.value === selected)?.label ?? control.label; } - - if (feature.type === "select") { - const FeatureIcon = getAgentFeatureIcon(feature.icon); - const selectedOption = feature.options.find((o) => o.id === feature.value); - return ( - <> - - - - ); - } - - return null; + return ( + <> + + + + ); } function ThinkingComboboxOption({ @@ -1549,6 +1528,18 @@ function ThinkingComboboxOption({ ); } +function useAgentOptionControls( + agent: { controls?: AgentOptionControl[]; options?: ProviderOptions } | null | undefined, + onSetOption: ComposerOptionControls["onSetOption"], +): ComposerOptionControls { + const controls = agent?.controls; + const options = agent?.options; + return useMemo( + () => ({ controls: controls ?? [], options: options ?? EMPTY_OPTIONS, onSetOption }), + [controls, onSetOption, options], + ); +} + export const AgentControls = memo(function AgentControls({ agentId, serverId, @@ -1700,8 +1691,8 @@ export const AgentControls = memo(function AgentControls({ [activeModelId, agentId, agentProvider, client, toast, updatePreferences], ); - const handleSetFeature = useCallback( - (featureId: string, value: unknown) => { + const handleSetOption = useCallback( + (key: string, value: unknown) => { if (!client || !agentProvider) { return; } @@ -1710,22 +1701,24 @@ export const AgentControls = memo(function AgentControls({ preferences: current, provider: agentProvider, updates: { - featureValues: { - [featureId]: value, + options: { + [key]: value, }, }, }), ).catch((error) => { - console.warn("[AgentControls] persist feature preference failed", error); + console.warn("[AgentControls] persist option preference failed", error); }); - void client.setAgentFeature(agentId, featureId, value).catch((error) => { - console.warn("[AgentControls] setAgentFeature failed", error); + void client.setAgentOptions(agentId, { [key]: value }).catch((error) => { + console.warn("[AgentControls] setAgentOptions failed", error); toast.error(toErrorMessage(error)); }); }, [agentId, agentProvider, client, toast, updatePreferences], ); + const agentOptionControls = useAgentOptionControls(agent, handleSetOption); + const commandCenterControls = useMemo( () => ({ serverId, @@ -1744,21 +1737,18 @@ export const AgentControls = memo(function AgentControls({ select: handleSelectThinkingOption, }, modes: commandCenterModes, - features: { - list: agent?.features, - set: handleSetFeature, - }, + optionControls: client ? agentOptionControls : null, }), [ activeModelId, - agent?.features, + agentOptionControls, + client, agentId, agentModelSelectorProviders, agentProvider, commandCenterModes, handleSelectCommandCenterModel, handleSelectThinkingOption, - handleSetFeature, modeProviderDefinitions, modelSelection.selectedThinkingId, modelSelection.thinkingOptions, @@ -1807,8 +1797,7 @@ export const AgentControls = memo(function AgentControls({ thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} onSelectThinkingOption={handleSelectThinkingOption} - features={agent.features} - onSetFeature={handleSetFeature} + optionControls={agentOptionControls} isModelLoading={snapshotIsLoading || selectedProviderIsLoading} onModelSelectorOpen={handleModelSelectorOpen} onRetryModelProvider={handleRetryModelProvider} @@ -1840,8 +1829,7 @@ export function DraftAgentControls({ selectedThinkingOptionId, onSelectThinkingOption, onApplyAgentProfile, - features, - onSetFeature, + optionControls, onDropdownClose, onModelSelectorOpen, onRetryModelProvider, @@ -1927,8 +1915,7 @@ export function DraftAgentControls({ thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} selectedThinkingOptionId={effectiveSelectedThinkingOption} onSelectThinkingOption={onSelectThinkingOption} - features={features} - onSetFeature={onSetFeature} + optionControls={optionControls} onDropdownClose={onDropdownClose} onModelSelectorOpen={onModelSelectorOpen} onRetryModelProvider={onRetryModelProvider} diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index dfacec0ec7..ad8f6da983 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -1,9 +1,7 @@ import { describe, expect, it } from "vitest"; import { getFeatureHighlightColor, - getFeatureTooltip, getAgentControlHintKey, - isFeatureActive, normalizeModelId, resolveAgentModelSelection, } from "./utils"; @@ -17,51 +15,12 @@ describe("getAgentControlHintKey", () => { }); describe("feature metadata helpers", () => { - it("prefers explicit feature tooltip copy", () => { - expect( - getFeatureTooltip({ - label: "Plan", - tooltip: "Toggle plan mode", - }), - ).toBe("Toggle plan mode"); - }); - - it("falls back to the feature label when no tooltip is provided", () => { - expect( - getFeatureTooltip({ - label: "Custom", - }), - ).toBe("Custom"); - }); - it("maps feature highlight colors by feature id", () => { expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("other")).toBe("default"); }); - - it("treats a select feature as active when a non-default option is selected", () => { - const speed = { - type: "select" as const, - id: "service_tier", - label: "Speed", - options: [ - { id: "default", label: "Normal", isDefault: true }, - { id: "fast", label: "Fast" }, - ], - }; - expect(isFeatureActive({ ...speed, value: "default" })).toBe(false); - expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true); - expect( - isFeatureActive({ - type: "toggle", - id: "fast_mode", - label: "Fast", - value: true, - }), - ).toBe(true); - }); }); describe("normalizeModelId", () => { diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index c0b1da7c47..300566aafa 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -1,4 +1,4 @@ -import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types"; import { i18n } from "@/i18n/i18next"; import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { @@ -35,16 +35,6 @@ export function normalizeModelId(modelId: string | null | undefined): string | n return normalized; } -export function getFeatureTooltip(feature: Pick): string { - return feature.tooltip ?? feature.label; -} - -export function isFeatureActive(feature: AgentFeature): boolean { - if (feature.type === "toggle") return feature.value; - const selectedOption = feature.options.find((option) => option.id === feature.value); - return selectedOption !== undefined && !selectedOption.isDefault; -} - export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor { switch (featureId) { case FAST_MODE_FEATURE_ID: diff --git a/packages/app/src/composer/draft/input-draft-core.ts b/packages/app/src/composer/draft/input-draft-core.ts index 162869855b..00bb581888 100644 --- a/packages/app/src/composer/draft/input-draft-core.ts +++ b/packages/app/src/composer/draft/input-draft-core.ts @@ -20,12 +20,11 @@ export function resolveDraftKey(input: { export function buildDraftAgentControls(input: { formState: UseAgentFormStateResult; - features?: DraftAgentControlsProps["features"]; - onSetFeature?: DraftAgentControlsProps["onSetFeature"]; + optionControls?: DraftAgentControlsProps["optionControls"]; onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"]; onDropdownClose?: DraftAgentControlsProps["onDropdownClose"]; }): DraftAgentControlsProps { - const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input; + const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input; return { providerDefinitions: formState.providerDefinitions, selectedProvider: formState.selectedProvider, @@ -43,8 +42,7 @@ export function buildDraftAgentControls(input: { selectedThinkingOptionId: formState.selectedThinkingOptionId, onSelectThinkingOption: formState.setThinkingOptionFromUser, onApplyAgentProfile, - features, - onSetFeature, + optionControls, onDropdownClose, onModelSelectorOpen: formState.refetchProviderModelsIfStale, onRetryModelProvider: formState.refreshProviderModels, diff --git a/packages/app/src/composer/draft/input-draft.ts b/packages/app/src/composer/draft/input-draft.ts index 2eb606a7f7..d9b2a14844 100644 --- a/packages/app/src/composer/draft/input-draft.ts +++ b/packages/app/src/composer/draft/input-draft.ts @@ -8,7 +8,7 @@ import { type CreateAgentInitialValues, type UseAgentFormStateResult, } from "@/hooks/use-agent-form-state"; -import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features"; +import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options"; import { buildDraftAgentControls, hasDraftContent, @@ -35,7 +35,7 @@ type AttachmentUpdater = interface AgentInputDraftComposerOptions { initialServerId: string | null; initialValues?: CreateAgentInitialValues; - initialFeatureValues?: Record; + initialProviderOptions?: Record; isVisible?: boolean; lockedWorkingDir?: string; } @@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & { effectiveModeId: string; effectiveModelId: string; effectiveThinkingOptionId: string; - featureValues: Record | undefined; + providerOptions: Record | undefined; agentControls: DraftAgentControlsProps; commandDraft: DraftCommandTarget; }; @@ -274,26 +274,25 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr ); const { - features: draftFeatures, - featureValues: draftFeatureValues, - setFeatureValue: setDraftFeatureValue, - applyProfileFeatureValues, - } = useDraftAgentFeatures({ + optionControls: draftOptionControls, + options: draftOptions, + applyProfileOptions, + } = useDraftProviderOptions({ serverId: formState.selectedServerId, provider: formState.selectedProvider, cwd: workingDir, modeId: formState.selectedMode, modelId: effectiveModelId, thinkingOptionId: effectiveThinkingOptionId, - initialFeatureValues: composerOptions?.initialFeatureValues, + initialOptions: composerOptions?.initialProviderOptions, }); const applyDraftAgentProfile = useCallback( (profile: Parameters[0]) => { formState.applyProfileFromUser(profile); - applyProfileFeatureValues(profile.featureValues); + applyProfileOptions(profile.options); }, - [applyProfileFeatureValues, formState], + [applyProfileOptions, formState], ); const commandDraft = useMemo( @@ -303,15 +302,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr cwd: workingDir, effectiveModelId, effectiveThinkingOptionId, - featureValues: draftFeatureValues, + providerOptions: draftOptions, }), - [ - effectiveModelId, - effectiveThinkingOptionId, - draftFeatureValues, - providerSelection, - workingDir, - ], + [effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir], ); const composerState = useMemo(() => { @@ -325,11 +318,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveModeId, effectiveModelId, effectiveThinkingOptionId, - featureValues: draftFeatureValues, + providerOptions: draftOptions, agentControls: buildDraftAgentControls({ formState, - features: draftFeatures, - onSetFeature: setDraftFeatureValue, + optionControls: draftOptionControls, onApplyAgentProfile: applyDraftAgentProfile, }), commandDraft, @@ -340,11 +332,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr effectiveModeId, effectiveModelId, effectiveThinkingOptionId, - draftFeatures, - draftFeatureValues, + draftOptionControls, + draftOptions, applyDraftAgentProfile, formState, - setDraftFeatureValue, workingDir, ]); diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index c4ef45cb3f..b1e6ce9ddc 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -1,3 +1,4 @@ +import type { ComposerOptionControls } from "@/agent-controls/option-controls"; import { useCallback, useEffect, useMemo, useRef } from "react"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { useTranslation } from "react-i18next"; @@ -65,7 +66,7 @@ interface AutoSubmitConfig { modeId: string | null; model: string | null; thinkingOptionId: string | null; - featureValues: Record; + providerOptions: Record; } function resolveAutoSubmitConfig( @@ -74,7 +75,7 @@ function resolveAutoSubmitConfig( modeId?: string | null; model?: string | null; thinkingOptionId?: string | null; - featureValues?: Record; + providerOptions?: Record; } | null, ): AutoSubmitConfig | null { if (!pending) return null; @@ -83,7 +84,7 @@ function resolveAutoSubmitConfig( modeId: pending.modeId ?? null, model: pending.model ?? null, thinkingOptionId: pending.thinkingOptionId ?? null, - featureValues: pending.featureValues ?? {}, + providerOptions: pending.providerOptions ?? {}, }; } @@ -131,7 +132,7 @@ async function submitDraftCreateRequest(input: { effectiveModeId: string; effectiveModelId: string | null; effectiveThinkingOptionId: string | null; - featureValues: Record | undefined; + providerOptions: Record | undefined; }; hostDisconnectedMessage: string; selectModelMessage: string; @@ -170,7 +171,7 @@ async function submitDraftCreateRequest(input: { model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined), thinkingOptionId: autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined), - featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues, + providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions, }); const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; @@ -204,7 +205,7 @@ function buildDraftAgentSnapshot(input: { effectiveThinkingOptionId: string | null; effectiveModeId: string; selectedProvider: string | null; - agentControls: { features?: Agent["features"] }; + agentControls: { optionControls?: ComposerOptionControls }; }; selectModelMessage: string; }): Agent { @@ -241,7 +242,8 @@ function buildDraftAgentSnapshot(input: { title: "Agent", cwd: workspaceDirectory, model, - features: composerState.agentControls.features, + options: composerState.agentControls.optionControls?.options, + controls: composerState.agentControls.optionControls?.controls, thinkingOptionId, parentAgentId: null, labels: {}, @@ -333,7 +335,7 @@ export function WorkspaceDraftAgentTab({ composer: { initialServerId: serverId, initialValues: draftInitialValues, - initialFeatureValues: draftSetup?.featureValues, + initialProviderOptions: draftSetup?.providerOptions, isVisible: true, lockedWorkingDir: draftWorkingDirectory ?? undefined, }, @@ -351,8 +353,7 @@ export function WorkspaceDraftAgentTab({ const draftModeOptions = composerState.modeOptions; const draftSelectedMode = composerState.selectedMode; const draftSetMode = composerState.setModeFromUser; - const draftFeatures = composerState.agentControls.features; - const draftOnSetFeature = composerState.agentControls.onSetFeature; + const draftOptionControls = composerState.agentControls.optionControls; const clearDraftInput = draftInput.clear; const replaceDraftText = draftInput.replaceText; @@ -521,10 +522,7 @@ export function WorkspaceDraftAgentTab({ selectedId: draftSelectedMode, select: draftSetMode, }, - features: { - list: draftFeatures, - set: draftOnSetFeature, - }, + optionControls: draftOptionControls ?? null, }, }); const isReadyForPendingAutoSubmit = Boolean( diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index b30b2a66ca..9e18f05f0a 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -80,7 +80,7 @@ describe("create agent preferences", () => { modelId: "gpt-5.5", modeId: "full-access", thinkingOptionId: "high", - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }), ); @@ -95,7 +95,7 @@ describe("create agent preferences", () => { model: "gpt-5.5", mode: "full-access", thinkingByModel: { "gpt-5.5": "high" }, - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, }, }); @@ -119,7 +119,7 @@ describe("create agent preferences", () => { model: "gpt-5.6", mode: undefined, thinkingByModel: undefined, - featureValues: undefined, + options: undefined, }, }), ).toEqual({ @@ -167,13 +167,13 @@ describe("create agent preferences", () => { modelId: "one-minute-stream", modeId: "approval-test", thinkingOptionId: "", - featureValues: {}, + options: {}, }), ).toEqual({ provider: "mock", providerPreferences: { pi: { model: "anthropic/sonnet" }, - mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} }, + mock: { model: "one-minute-stream", mode: "approval-test", options: {} }, }, }); }); @@ -208,6 +208,18 @@ describe("create agent preferences", () => { }); }); + it("reads preferences saved as featureValues as options", () => { + expect( + parseFormPreferences({ + provider: "codex", + providerPreferences: { codex: { featureValues: { service_tier: "priority" } } }, + }), + ).toEqual({ + provider: "codex", + providerPreferences: { codex: { options: { service_tier: "priority" } } }, + }); + }); + it("rejects unknown persisted fields outside the explicit legacy shape", () => { expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index bdade894e8..7dd421d16a 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,13 +1,14 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])); +const optionsSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])); export interface ProviderPreferences { model?: string; mode?: string; thinkingByModel?: Record; - featureValues?: Record; + /** Last-used values of the provider's option controls, by option key. */ + options?: Record; } export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string }; @@ -20,12 +21,23 @@ export interface FormPreferences { launchTarget?: LaunchTarget; } -const providerPreferencesSchema: z.ZodType = z.strictObject({ - model: z.string().optional(), - mode: z.string().optional(), - thinkingByModel: z.record(z.string(), z.string()).optional(), - featureValues: featureValuesSchema.optional(), -}); +// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Preferences written +// before the rename stored the same record as `featureValues`. +function renameLegacyFeatureValues(value: unknown): unknown { + if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; + const { featureValues, ...rest } = value as { featureValues: unknown } & Record; + return "options" in rest ? rest : { ...rest, options: featureValues }; +} + +const providerPreferencesSchema: z.ZodType = z.preprocess( + renameLegacyFeatureValues, + z.strictObject({ + model: z.string().optional(), + mode: z.string().optional(), + thinkingByModel: z.record(z.string(), z.string()).optional(), + options: optionsSchema.optional(), + }), +); const launchTargetSchema: z.ZodType = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("chat") }), @@ -116,7 +128,7 @@ function applyProviderPreferenceUpdates( ): ProviderPreferences { const next: ProviderPreferences = { ...existing }; const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel); - const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues); + const nextOptions = mergeDefinedRecord(existing.options, updates.options); if (updates.model !== undefined) { next.model = updates.model; @@ -129,8 +141,8 @@ function applyProviderPreferenceUpdates( if (nextThinkingByModel !== undefined) { next.thinkingByModel = nextThinkingByModel; } - if (nextFeatureValues !== undefined) { - next.featureValues = nextFeatureValues; + if (nextOptions !== undefined) { + next.options = nextOptions; } return next; @@ -161,7 +173,7 @@ export function mergeCreateAgentSelectionPreferences(args: { modelId?: string | null; modeId?: string | null; thinkingOptionId?: string | null; - featureValues?: Record; + options?: Record; }): FormPreferences { if (!args.provider) { return args.preferences; @@ -170,7 +182,7 @@ export function mergeCreateAgentSelectionPreferences(args: { const modelId = args.modelId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; - const featureValues = featureValuesSchema.safeParse(args.featureValues); + const options = optionsSchema.safeParse(args.options); return mergeProviderPreferences({ preferences: args.preferences, @@ -179,7 +191,7 @@ export function mergeCreateAgentSelectionPreferences(args: { model: modelId || undefined, mode: args.modeId === undefined ? undefined : modeId || null, ...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), - ...(featureValues.success ? { featureValues: featureValues.data } : {}), + ...(options.success ? { options: options.data } : {}), }, }); } @@ -192,7 +204,7 @@ export function applyAgentProfilePreferences(args: { modelId: string; modeId: string; thinkingOptionId: string; - featureValues: Record; + options: Record; }): FormPreferences { let next = args.preferences; if (args.previousProvider) { @@ -215,7 +227,7 @@ export function applyAgentProfilePreferences(args: { ...(args.modelId && args.thinkingOptionId ? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } } : {}), - featureValues: args.featureValues, + options: args.options, }, }); } diff --git a/packages/app/src/hooks/draft-provider-options.test.ts b/packages/app/src/hooks/draft-provider-options.test.ts new file mode 100644 index 0000000000..dcbbd99c1e --- /dev/null +++ b/packages/app/src/hooks/draft-provider-options.test.ts @@ -0,0 +1,81 @@ +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 new file mode 100644 index 0000000000..62408bb49a --- /dev/null +++ b/packages/app/src/hooks/draft-provider-options.ts @@ -0,0 +1,29 @@ +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/feature-preferences.test.ts b/packages/app/src/hooks/feature-preferences.test.ts deleted file mode 100644 index 7cfca13a68..0000000000 --- a/packages/app/src/hooks/feature-preferences.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { resolveFeatureValues } from "./feature-preferences"; - -describe("feature-preferences", () => { - const features = [ - { - type: "toggle" as const, - id: "fast_mode", - label: "Fast", - value: false, - }, - { - type: "toggle" as const, - id: "plan_mode", - label: "Plan", - value: false, - }, - ]; - - it("restores persisted values for available features", () => { - expect( - resolveFeatureValues({ - features, - persistedFeatureValues: { - fast_mode: true, - unknown_feature: true, - }, - localFeatureValues: {}, - }), - ).toEqual({ - fast_mode: true, - }); - }); - - it("prefers local values over persisted values", () => { - expect( - resolveFeatureValues({ - features, - persistedFeatureValues: { - fast_mode: true, - plan_mode: false, - }, - localFeatureValues: { - fast_mode: false, - }, - }), - ).toEqual({ - fast_mode: false, - plan_mode: false, - }); - }); -}); diff --git a/packages/app/src/hooks/feature-preferences.ts b/packages/app/src/hooks/feature-preferences.ts deleted file mode 100644 index 4fbd956581..0000000000 --- a/packages/app/src/hooks/feature-preferences.ts +++ /dev/null @@ -1,60 +0,0 @@ -import type { AgentFeature } from "@getpaseo/protocol/agent-types"; - -export function pruneFeatureValues( - featureValues: Record, - features: AgentFeature[], -): Record { - const allowedFeatureIds = new Set(features.map((feature) => feature.id)); - let changed = false; - const next: Record = {}; - - for (const [featureId, value] of Object.entries(featureValues)) { - if (!allowedFeatureIds.has(featureId)) { - changed = true; - continue; - } - next[featureId] = value; - } - - return changed ? next : featureValues; -} - -export function applyFeatureValues( - features: AgentFeature[], - featureValues: Record, -): AgentFeature[] { - if (Object.keys(featureValues).length === 0) { - return features; - } - - return features.map((feature) => { - if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) { - return feature; - } - - return { - ...feature, - value: featureValues[feature.id], - } as AgentFeature; - }); -} - -export function resolveFeatureValues(args: { - features: AgentFeature[]; - persistedFeatureValues: Record; - localFeatureValues: Record; -}): Record { - const next: Record = {}; - - for (const feature of args.features) { - if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) { - next[feature.id] = args.localFeatureValues[feature.id]; - continue; - } - if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) { - next[feature.id] = args.persistedFeatureValues[feature.id]; - } - } - - return next; -} diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index ef144477dc..8ca4f9dda4 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -128,14 +128,14 @@ function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig const modeId = draftConfig.modeId?.trim() ?? ""; const model = draftConfig.model?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; - const featureValues = draftConfig.featureValues; + const providerOptions = draftConfig.providerOptions; return { provider: draftConfig.provider, cwd, ...(modeId ? { modeId } : {}), ...(model ? { model } : {}), ...(thinkingOptionId ? { thinkingOptionId } : {}), - ...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}), + ...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), }; } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index 1522ce082e..d088dfb161 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -352,7 +352,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo modelId: model, modeId, thinkingOptionId, - featureValues: profile.featureValues, + options: profile.options, }); }).catch((error) => { console.warn("[useAgentFormState] persist profile preference failed", error); diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index e0cc75ee06..082d11827e 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -1,9 +1,6 @@ import { useRef } from "react"; -import type { - AgentCapabilityFlags, - AgentFeature, - AgentProvider, -} from "@getpaseo/protocol/agent-types"; +import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { Agent } from "@/stores/session-store"; import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync"; export interface AgentScreenAgent { @@ -22,7 +19,8 @@ export interface AgentScreenAgent { modeId?: string | null; thinkingOptionId?: string | null; } | null; - features?: readonly AgentFeature[]; + options?: Agent["options"]; + controls?: Agent["controls"]; lastError?: string | null; projectPlacement?: { projectKey?: string; diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts deleted file mode 100644 index 9ab23ad6d0..0000000000 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { useQuery } from "@tanstack/react-query"; -import { useTranslation } from "react-i18next"; -import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; -import { - applyFeatureValues, - pruneFeatureValues, - resolveFeatureValues, -} from "./feature-preferences"; - -type DraftFeatureConfig = Pick< - AgentSessionConfig, - "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" ->; - -export function useDraftAgentFeatures(input: { - serverId: string | null | undefined; - provider: AgentProvider | null; - cwd: string | null | undefined; - modeId: string | null | undefined; - modelId: string | null | undefined; - thinkingOptionId: string | null | undefined; - initialFeatureValues?: Record; -}) { - const { t } = useTranslation(); - const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } = - input; - const [localFeatureValues, setLocalFeatureValues] = useState>( - () => initialFeatureValues ?? {}, - ); - 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 persistedFeatureValues = useMemo( - () => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}), - [preferences.providerPreferences, provider], - ); - - const draftConfig = useMemo(() => { - if (!normalizedProvider || !normalizedCwd) { - return null; - } - - return { - provider: normalizedProvider, - cwd: normalizedCwd, - ...(modeId ? { modeId } : {}), - ...(modelId ? { model: modelId } : {}), - ...(thinkingOptionId ? { thinkingOptionId } : {}), - }; - }, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]); - - const featuresQuery = useQuery({ - queryKey: [ - "providerFeatures", - serverId ?? null, - normalizedProvider, - normalizedCwd || null, - modeId ?? null, - modelId ?? null, - thinkingOptionId ?? null, - ], - enabled: Boolean(serverId && client && isConnected && draftConfig), - staleTime: 5 * 60 * 1000, - queryFn: async () => { - if (!client || !draftConfig) { - throw new Error(t("workspace.terminal.hostDisconnected")); - } - const payload = await client.listProviderFeatures(draftConfig); - if (payload.error) { - throw new Error(payload.error); - } - return payload.features ?? []; - }, - }); - const availableFeaturesRaw = featuresQuery.data; - const availableFeatures = useMemo(() => availableFeaturesRaw ?? [], [availableFeaturesRaw]); - const featureValues = useMemo( - () => - resolveFeatureValues({ - features: availableFeatures, - persistedFeatureValues, - localFeatureValues, - }), - [availableFeatures, localFeatureValues, persistedFeatureValues], - ); - - const features = useMemo(() => { - return applyFeatureValues(availableFeatures, featureValues); - }, [availableFeatures, featureValues]); - - useEffect(() => { - const previousProvider = previousProviderRef.current; - previousProviderRef.current = normalizedProvider; - if (previousProvider === null) { - return; - } - if (previousProvider !== normalizedProvider) { - setLocalFeatureValues({}); - } - }, [normalizedProvider]); - - useEffect(() => { - if (availableFeaturesRaw === undefined) { - return; - } - const next = pruneFeatureValues(localFeatureValues, availableFeatures); - if (next !== localFeatureValues) { - setLocalFeatureValues(next); - } - }, [availableFeatures, availableFeaturesRaw, localFeatureValues]); - - const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; - const setFeatureValue = useCallback( - (featureId: string, value: unknown) => { - setLocalFeatureValues((current) => { - if (Object.is(current[featureId], value)) { - return current; - } - - return { ...current, [featureId]: value }; - }); - if (!provider) { - return; - } - void updatePreferences((current) => - mergeProviderPreferences({ - preferences: current, - provider, - updates: { - featureValues: { - [featureId]: value, - }, - }, - }), - ).catch((error) => { - console.warn("[useDraftAgentFeatures] persist feature preference failed", error); - }); - }, - [provider, updatePreferences], - ); - - const applyProfileFeatureValues = useCallback((values: Record) => { - setLocalFeatureValues(values); - }, []); - - return { - features, - featureValues: effectiveFeatureValues, - isLoading: featuresQuery.isLoading, - setFeatureValue, - applyProfileFeatureValues, - }; -} diff --git a/packages/app/src/hooks/use-draft-provider-options.ts b/packages/app/src/hooks/use-draft-provider-options.ts new file mode 100644 index 0000000000..a3891aed0a --- /dev/null +++ b/packages/app/src/hooks/use-draft-provider-options.ts @@ -0,0 +1,142 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { + AgentProvider, + AgentSessionConfig, + ProviderOptions, +} from "@getpaseo/protocol/agent-types"; +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< + AgentSessionConfig, + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions" +>; + +/** + * 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. + */ +export function useDraftProviderOptions(input: { + serverId: string | null | undefined; + provider: AgentProvider | null; + cwd: string | null | undefined; + modeId: string | null | undefined; + modelId: string | null | undefined; + thinkingOptionId: string | null | undefined; + initialOptions?: ProviderOptions; +}) { + const { t } = useTranslation(); + const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input; + const [seed, setSeed] = useState(() => initialOptions ?? {}); + const [edits, setEdits] = useState({}); + 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 draftConfig = useMemo(() => { + if (!normalizedProvider || !normalizedCwd) return null; + return { + provider: normalizedProvider, + cwd: normalizedCwd, + ...(modeId ? { modeId } : {}), + ...(modelId ? { model: modelId } : {}), + ...(thinkingOptionId ? { thinkingOptionId } : {}), + ...(Object.keys(seed).length > 0 ? { providerOptions: seed } : {}), + }; + }, [modeId, modelId, normalizedCwd, normalizedProvider, seed, thinkingOptionId]); + + const controlsQuery = useFetchQuery({ + queryKey: [ + "providerControls", + serverId ?? null, + normalizedProvider, + normalizedCwd || null, + modeId ?? null, + modelId ?? null, + thinkingOptionId ?? null, + seed, + ], + enabled: Boolean(serverId && client && isConnected && draftConfig), + dataShape: "value", + staleTimeMs: 5 * 60 * 1000, + queryFn: async () => { + if (!client || !draftConfig) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + const payload = await client.listProviderControls(draftConfig); + if (payload.error) { + throw new Error(payload.error); + } + 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]); + + const setOption = useCallback( + (key: string, value: unknown) => { + setEdits((current) => + Object.is(current[key], value) ? current : { ...current, [key]: value }, + ); + if (!provider) return; + void updatePreferences((current) => + mergeProviderPreferences({ + preferences: current, + provider, + updates: { options: { [key]: value } }, + }), + ).catch((error) => { + console.warn("[useDraftProviderOptions] persist option preference failed", error); + }); + }, + [provider, updatePreferences], + ); + + const applyProfileOptions = useCallback((profileOptions: ProviderOptions) => { + setSeed(profileOptions); + setEdits({}); + }, []); + + const optionControls = useMemo( + () => (controls ? { controls, options, onSetOption: setOption } : undefined), + [controls, options, setOption], + ); + + return { + optionControls, + options: ownOptions, + isLoading: controlsQuery.isLoading, + applyProfileOptions, + }; +} diff --git a/packages/app/src/hooks/use-fork-agent.ts b/packages/app/src/hooks/use-fork-agent.ts index f2cdf3f911..5ecb08ebe0 100644 --- a/packages/app/src/hooks/use-fork-agent.ts +++ b/packages/app/src/hooks/use-fork-agent.ts @@ -36,7 +36,7 @@ export type ForkAgentSource = Pick< | "model" | "thinkingOptionId" | "runtimeInfo" - | "features" + | "options" | "projectPlacement" >; @@ -95,18 +95,13 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u return undefined; } - const featureValues: Record = {}; - for (const feature of agent.features ?? []) { - featureValues[feature.id] = feature.value; - } - return { provider: agent.provider, cwd: agent.cwd, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, - featureValues, + providerOptions: { ...agent.options }, }; } diff --git a/packages/app/src/hooks/use-form-preferences.test.ts b/packages/app/src/hooks/use-form-preferences.test.ts index be0653fbc1..9c1dbc7be6 100644 --- a/packages/app/src/hooks/use-form-preferences.test.ts +++ b/packages/app/src/hooks/use-form-preferences.test.ts @@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => { providerPreferences: { codex: { model: "gpt-5.4", - featureValues: { + options: { fast_mode: true, }, }, @@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => { }, provider: "codex", updates: { - featureValues: { + options: { plan_mode: true, }, }, @@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => { providerPreferences: { codex: { model: "gpt-5.4", - featureValues: { + options: { fast_mode: true, plan_mode: true, }, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 147ab7b260..60d5b187cf 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -118,7 +118,8 @@ interface ChatAgentStateShape { model?: Agent["model"]; thinkingOptionId?: Agent["thinkingOptionId"]; runtimeInfo?: Agent["runtimeInfo"]; - features?: Agent["features"]; + options?: Agent["options"]; + controls?: Agent["controls"]; lastError?: Agent["lastError"] | null; } @@ -177,7 +178,8 @@ function selectChatAgentState( model: agent.model, thinkingOptionId: agent.thinkingOptionId, runtimeInfo: agent.runtimeInfo, - features: agent.features, + options: agent.options, + controls: agent.controls, lastError: agent.lastError ?? null, archivedAt: agent.archivedAt ?? null, requiresAttention: agent.requiresAttention ?? false, @@ -204,7 +206,8 @@ function buildChatAgentFromState( model: state.model, thinkingOptionId: state.thinkingOptionId, runtimeInfo: state.runtimeInfo, - features: state.features, + options: state.options, + controls: state.controls, lastError: state.lastError ?? null, projectPlacement, }; @@ -637,7 +640,8 @@ function AgentPanelBody({ model: agentState.model, thinkingOptionId: agentState.thinkingOptionId, runtimeInfo: agentState.runtimeInfo, - features: agentState.features, + options: agentState.options, + controls: agentState.controls, lastError: agentState.lastError ?? null, projectPlacement, } diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index 54d75f1da2..592824341a 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: { cwd: string; effectiveModelId: string; effectiveThinkingOptionId: string; - featureValues?: Record; + providerOptions?: Record; }): DraftCommandTarget { const cwd = input.cwd.trim(); if (!cwd) { @@ -332,7 +332,7 @@ export function buildDraftCommandTarget(input: { ...(input.effectiveThinkingOptionId ? { thinkingOptionId: input.effectiveThinkingOptionId } : {}), - ...(input.featureValues ? { featureValues: input.featureValues } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), }; return { status: "ready", config }; } diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index 3912d87e13..877898b979 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -284,7 +284,7 @@ describe("mergeSelectedComposerPreferences", () => { codex: { mode: "full-access", thinkingByModel: { "gpt-5.4-mini": "medium" }, - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, claude: { model: "claude-sonnet-4-6" }, }, @@ -299,7 +299,7 @@ describe("mergeSelectedComposerPreferences", () => { model: "gpt-5.4", mode: "full-access", thinkingByModel: { "gpt-5.4-mini": "medium" }, - featureValues: { fast_mode: true }, + options: { fast_mode: true }, }, claude: { model: "claude-sonnet-4-6" }, }, diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index 3cb01ae9df..60b8f2756e 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -271,7 +271,7 @@ interface WorkspaceDraftSubmissionConfig { modeId: string | null; model: string | null; thinkingOptionId: string | null; - featureValues: Record | undefined; + providerOptions: Record | undefined; target: WorkspaceTabTarget; } @@ -374,7 +374,7 @@ function buildWorkspaceDraftSetupFromComposer(input: { modeId: input.composerState.effectiveModeId || null, model: input.composerState.effectiveModelId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, - featureValues: input.composerState.featureValues ?? {}, + providerOptions: input.composerState.providerOptions ?? {}, }; } @@ -454,7 +454,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise { modeId: null, model: null, thinkingOptionId: null, - featureValues: {}, + providerOptions: {}, }, }); }); @@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => { modeId: null, model: null, thinkingOptionId: null, - featureValues: {}, + providerOptions: {}, }, }); diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-config.ts b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts index cf122ee566..38b97fc159 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-config.ts +++ b/packages/app/src/screens/workspace/workspace-draft-agent-config.ts @@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: { modeId?: string; model?: string; thinkingOptionId?: string; - featureValues?: Record; + providerOptions?: Record; }): AgentSessionConfig { return { provider: input.provider, @@ -14,6 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: { ...(input.modeId ? { modeId: input.modeId } : {}), ...(input.model ? { model: input.model } : {}), ...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}), - ...(input.featureValues ? { featureValues: input.featureValues } : {}), + ...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), }; } diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 911573762f..29246ca2d3 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -26,7 +26,8 @@ import type { ComposerAttachment } from "@/attachments/types"; import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; import type { AgentPermissionRequest, - AgentFeature, + AgentOptionControl, + ProviderOptions, AgentProvider, AgentMode, AgentCapabilityFlags, @@ -91,7 +92,8 @@ export interface Agent { cwd: string; workspaceId?: string; model: string | null; - features?: AgentFeature[]; + options?: ProviderOptions; + controls?: AgentOptionControl[]; thinkingOptionId?: string | null; requiresAttention?: boolean; attentionReason?: "finished" | "error" | "permission" | null; diff --git a/packages/app/src/stores/workspace-draft-submission-store.ts b/packages/app/src/stores/workspace-draft-submission-store.ts index 639701d740..4a7d3ceaf3 100644 --- a/packages/app/src/stores/workspace-draft-submission-store.ts +++ b/packages/app/src/stores/workspace-draft-submission-store.ts @@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission { modeId?: string; model?: string; thinkingOptionId?: string; - featureValues?: Record; + providerOptions?: Record; allowEmptyText?: boolean; } diff --git a/packages/app/src/stores/workspace-layout-storage.ts b/packages/app/src/stores/workspace-layout-storage.ts index c105d40a06..fc3d22a057 100644 --- a/packages/app/src/stores/workspace-layout-storage.ts +++ b/packages/app/src/stores/workspace-layout-storage.ts @@ -1,14 +1,25 @@ import { z } from "zod"; import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions"; -const WorkspaceDraftTabSetupStorageSchema = z.strictObject({ - provider: z.string(), - cwd: z.string(), - modeId: z.string().nullable(), - model: z.string().nullable(), - thinkingOptionId: z.string().nullable(), - featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), -}); +// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Layouts saved before +// the rename stored the same record as `featureValues`. +function renameLegacyFeatureValues(value: unknown): unknown { + if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; + const { featureValues, ...rest } = value as { featureValues: unknown } & Record; + return "providerOptions" in rest ? rest : { ...rest, providerOptions: featureValues }; +} + +const WorkspaceDraftTabSetupStorageSchema = z.preprocess( + renameLegacyFeatureValues, + z.strictObject({ + provider: z.string(), + cwd: z.string(), + modeId: z.string().nullable(), + model: z.string().nullable(), + thinkingOptionId: z.string().nullable(), + providerOptions: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])), + }), +); const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ z.strictObject({ kind: z.literal("new_tab") }), z.strictObject({ diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 8b478b7d27..b22f0d85c7 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -4551,7 +4551,6 @@ describe("compact workspace sheet", () => { title: agent.id, cwd: "/repo", model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index ff2c8b2f4b..13fe6cfa81 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -63,7 +63,6 @@ const AGENT_DEFAULTS: Agent = { cwd: WORKSPACE_DIRECTORY, workspaceId: WORKSPACE_ID, model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index c9d7ca4096..9864e77aac 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -41,7 +41,6 @@ const AGENT_DEFAULTS: Agent = { title: "Agent", cwd: "/tmp/project", model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/subagents/sheet-tab.test.tsx b/packages/app/src/subagents/sheet-tab.test.tsx index e295f7b7c7..c8e0ab7f13 100644 --- a/packages/app/src/subagents/sheet-tab.test.tsx +++ b/packages/app/src/subagents/sheet-tab.test.tsx @@ -60,7 +60,6 @@ const AGENT_DEFAULTS: Agent = { cwd: "/repo/worktree", workspaceId: WORKSPACE_ID, model: null, - features: undefined, thinkingOptionId: undefined, requiresAttention: false, attentionReason: null, diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 13a7bcd14b..9bf4ee3404 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -64,7 +64,8 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { cwd: agent.cwd, ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), model: agent.model, - ...(agent.features ? { features: agent.features } : {}), + ...(agent.options ? { options: agent.options } : {}), + ...(agent.controls ? { controls: agent.controls } : {}), thinkingOptionId: agent.thinkingOptionId ?? null, createdAt: agent.createdAt.toISOString(), updatedAt: agent.updatedAt.toISOString(), @@ -127,7 +128,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: cwd: snapshot.cwd, workspaceId: snapshot.workspaceId, model: snapshot.model ?? null, - features: snapshot.features, + options: snapshot.options, + controls: snapshot.controls, thinkingOptionId: snapshot.thinkingOptionId ?? null, requiresAttention: snapshot.requiresAttention ?? false, attentionReason: snapshot.attentionReason ?? null, diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 6e15d506c2..ba6dcb52f5 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -92,7 +92,7 @@ export function normalizeWorkspaceDraftTabSetup( thinkingOptionId: trimOptionalString( typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null, ), - featureValues: isPlainRecord(record.featureValues) ? { ...record.featureValues } : {}, + providerOptions: isPlainRecord(record.providerOptions) ? { ...record.providerOptions } : {}, }; } @@ -171,7 +171,7 @@ function workspaceDraftTabSetupsEqual( left.modeId === right.modeId && left.model === right.model && left.thinkingOptionId === right.thinkingOptionId && - recordsShallowEqual(left.featureValues, right.featureValues) + recordsShallowEqual(left.providerOptions, right.providerOptions) ); } diff --git a/packages/app/src/workspace-tabs/model.ts b/packages/app/src/workspace-tabs/model.ts index fa0e155060..f1e17fb3a6 100644 --- a/packages/app/src/workspace-tabs/model.ts +++ b/packages/app/src/workspace-tabs/model.ts @@ -8,7 +8,7 @@ export interface WorkspaceDraftTabSetup { modeId: string | null; model: string | null; thinkingOptionId: string | null; - featureValues: Record; + providerOptions: Record; } export interface WorkspaceWorkingDiffTabTarget { diff --git a/packages/client/src/compat/legacy-provider-options.ts b/packages/client/src/compat/legacy-provider-options.ts new file mode 100644 index 0000000000..01e609ad10 --- /dev/null +++ b/packages/client/src/compat/legacy-provider-options.ts @@ -0,0 +1,219 @@ +import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls"; +import type { + AgentProfile, + AgentSnapshotPayload, + MutableDaemonConfig, + ListProviderFeaturesResponseMessage, + ProviderControlsListResponseMessage, + ServerInfoStatusPayload, + SessionInboundMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; + +/** + * COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01 once daemon floor >= + * v0.15.0. + * + * A daemon without `server_info.features.providerOptionControls` sends `features` (a control with + * its value attached) and takes composer values as `featureValues`. This is the one place that + * turns that into the shape everyone else uses: `controls` over `options`, written through + * `providerOptions`. + */ +export function usesLegacyProviderOptions(info: ServerInfoStatusPayload): boolean { + return info.features?.providerOptionControls !== true; +} + +export class LegacyProviderOptions { + /** Option keys a legacy daemon exposes as features, per provider, as last seen. */ + private readonly controlKeys = new Map>(); + + /** Gives agent snapshots `controls` and `options`, built from their `features`. */ + normalize(message: SessionOutboundMessage): SessionOutboundMessage { + switch (message.type) { + case "agent_update": + if (message.payload.kind !== "upsert") return message; + return { + ...message, + payload: { ...message.payload, agent: this.snapshot(message.payload.agent) }, + }; + case "agent_status": + return { + ...message, + payload: { ...message.payload, info: this.snapshot(message.payload.info) }, + }; + case "fetch_agents_response": + return { + ...message, + payload: { + ...message.payload, + entries: message.payload.entries.map((entry) => ({ + ...entry, + agent: this.snapshot(entry.agent), + })), + }, + }; + case "fetch_agent_response": + case "agent.create.response": + case "cancel_agent_response": + return { + ...message, + payload: { + ...message.payload, + agent: message.payload.agent && this.snapshot(message.payload.agent), + }, + } as SessionOutboundMessage; + case "workspace.create.response": + return { + ...message, + payload: { + ...message.payload, + ...(message.payload.agent ? { agent: this.snapshot(message.payload.agent) } : {}), + }, + }; + case "wait_for_finish_response": + return { + ...message, + payload: { + ...message.payload, + final: message.payload.final && this.snapshot(message.payload.final), + }, + }; + case "get_daemon_config_response": + case "set_daemon_config_response": + return { + ...message, + payload: { ...message.payload, config: this.daemonConfig(message.payload.config) }, + }; + case "status": + if (message.payload.status !== "daemon_config_changed") return message; + return { + ...message, + payload: { + ...message.payload, + config: this.daemonConfig(message.payload.config as MutableDaemonConfig), + }, + } as SessionOutboundMessage; + case "list_provider_features_response": + // Remember the keys even when the caller used the legacy listing directly. + this.remember(message.payload.provider, message.payload.features); + return message; + default: + return message; + } + } + + /** Sends control keys as `featureValues`, which is where a legacy daemon reads them. */ + prepare(message: SessionInboundMessage): SessionInboundMessage { + switch (message.type) { + case "create_agent_request": + case "agent.create.request": + return { ...message, config: this.config(message.config) }; + case "workspace.create.request": + if (!message.agent) return message; + return { + ...message, + agent: { ...message.agent, config: this.config(message.agent.config) }, + }; + case "agent.config.apply.request": { + const { options, ...config } = message.config; + if (!options) return message; + return { + ...message, + config: { ...config, featureValues: { ...config.featureValues, ...options } }, + }; + } + case "list_commands_request": + if (!message.draftConfig) return message; + return { ...message, draftConfig: this.config(message.draftConfig) }; + case "set_daemon_config_request": + if (!message.config.agentProfiles) return message; + return { + ...message, + config: { + ...message.config, + agentProfiles: message.config.agentProfiles.map(profileWithFeatureValues), + }, + }; + default: + return message; + } + } + + /** The legacy answer to a controls listing, in the shape of the current response. */ + controlsResponse( + legacy: ListProviderFeaturesResponseMessage["payload"], + ): ProviderControlsListResponseMessage["payload"] { + const { features, ...rest } = legacy; + this.remember(legacy.provider, features); + return { + ...rest, + ...(features + ? { controls: controlsFromFeatures(features), options: optionsFromFeatures(features) } + : {}), + }; + } + + private daemonConfig(config: MutableDaemonConfig): MutableDaemonConfig { + if (!config.agentProfiles) return config; + return { ...config, agentProfiles: config.agentProfiles.map(profileWithOptions) }; + } + + private readonly snapshot = (agent: AgentSnapshotPayload): AgentSnapshotPayload => { + if (agent.controls || !agent.features) return agent; + this.remember(agent.provider, agent.features); + return { + ...agent, + controls: controlsFromFeatures(agent.features), + options: optionsFromFeatures(agent.features), + }; + }; + + private remember(provider: string, features: Parameters[0]): void { + if (features) this.controlKeys.set(provider, new Set(features.map((feature) => feature.id))); + } + + private config< + T extends { + provider?: string; + providerOptions?: Record; + featureValues?: Record; + }, + >(config: T): T { + if (!config.provider || !config.providerOptions) return config; + const { featureValues, providerOptions } = this.split(config.provider, config.providerOptions); + const { providerOptions: _options, ...rest } = config; + return { + ...(rest as T), + ...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}), + ...(Object.keys(featureValues).length > 0 + ? { featureValues: { ...config.featureValues, ...featureValues } } + : {}), + }; + } + + private split( + provider: string, + options: Record, + ): { featureValues: Record; providerOptions: Record } { + const keys = this.controlKeys.get(provider); + const featureValues: Record = {}; + const providerOptions: Record = {}; + for (const [key, value] of Object.entries(options)) { + (keys?.has(key) ? featureValues : providerOptions)[key] = value; + } + return { featureValues, providerOptions }; + } +} + +/** A legacy daemon stores a profile's options under `featureValues`. */ +function profileWithOptions(profile: AgentProfile): AgentProfile { + if (profile.options || !profile.featureValues) return profile; + const { featureValues, ...rest } = profile; + return { ...rest, options: featureValues }; +} + +function profileWithFeatureValues(profile: AgentProfile): AgentProfile { + if (!profile.options) return profile; + const { options, ...rest } = profile; + return { ...rest, featureValues: options }; +} diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 4fa7d5723c..a055a749e3 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -8924,3 +8924,230 @@ test("routed channels inherit an origin without drain support", async () => { expect(route.supportsDrain).toBe(false); route.close(); }); + +describe("provider options across daemon versions", () => { + const agentSnapshot = { + id: "agent-1", + provider: "codex", + cwd: "/repo", + model: null, + thinkingOptionId: null, + effectiveThinkingOptionId: null, + createdAt: "2026-04-30T00:00:00.000Z", + updatedAt: "2026-04-30T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: false, + supportsSessionPersistence: false, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: false, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + requiresAttention: false, + attentionReason: null, + }; + const speedFeature = { + type: "select", + id: "service_tier", + label: "Speed", + value: "priority", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + }; + + async function connect(features: Record) { + const mock = createMockTransport(); + const client = new DaemonClient({ + url: "ws://test", + clientId: "provider_options", + transportFactory: () => mock.transport, + reconnect: { enabled: false }, + }); + clients.push(client); + const connecting = client.connect(); + mock.triggerOpen({ features }); + await connecting; + const sentMessages = () => + mock.sent.map( + (frame) => (JSON.parse(frame as string) as { message: Record }).message, + ); + return { mock, client, sentMessages }; + } + + test("a legacy daemon's features arrive as controls over options", async () => { + const { mock, client } = await connect({}); + const received: Array<{ controls?: unknown; options?: unknown }> = []; + client.on("agent_update", (message) => { + if (message.payload.kind === "upsert") received.push(message.payload.agent); + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent_update", + payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } }, + }), + ); + expect(received).toEqual([ + expect.objectContaining({ + options: { service_tier: "priority" }, + controls: [ + expect.objectContaining({ + type: "select", + key: "service_tier", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast" }, + ], + }), + ], + }), + ]); + }); + + test("a legacy daemon takes option writes as feature writes", async () => { + const { client, sentMessages, mock } = await connect({}); + const write = client.setAgentOptions("agent-1", { service_tier: "default" }); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "set_agent_feature_request", + agentId: "agent-1", + featureId: "service_tier", + value: "default", + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "set_agent_feature_response", + payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null }, + }), + ); + await write; + }); + + test("a current daemon takes option writes as one options patch", async () => { + const { client, sentMessages, mock } = await connect({ providerOptionControls: true }); + const write = client.setAgentOptions("agent-1", { service_tier: null, sandbox_mode: "x" }); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "agent.config.apply.request", + agentId: "agent-1", + config: { options: { service_tier: null, sandbox_mode: "x" } }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "agent.config.apply.response", + payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null }, + }), + ); + await write; + }); + + test("a legacy draft listing answers with controls and options", async () => { + const { client, sentMessages, mock } = await connect({}); + const listing = client.listProviderControls({ + provider: "codex", + cwd: "/repo", + providerOptions: { service_tier: "default" }, + }); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "list_provider_features_request", + draftConfig: { provider: "codex", featureValues: { service_tier: "default" } }, + }); + mock.triggerMessage( + wrapSessionMessage({ + type: "list_provider_features_response", + payload: { + provider: "codex", + features: [{ ...speedFeature, value: "default" }], + error: null, + fetchedAt: "2026-04-30T00:00:00.000Z", + requestId: request.requestId, + }, + }), + ); + await expect(listing).resolves.toMatchObject({ + provider: "codex", + options: { service_tier: "default" }, + controls: [expect.objectContaining({ key: "service_tier" })], + }); + }); + + test("a legacy daemon's profiles are read and saved by their options", async () => { + const { client, sentMessages, mock } = await connect({}); + const reading = client.getDaemonConfig(); + const [read] = sentMessages(); + mock.triggerMessage( + wrapSessionMessage({ + type: "get_daemon_config_response", + payload: { + requestId: read.requestId, + config: { + mcp: { injectIntoAgents: true }, + agentProfiles: [ + { + id: "p1", + name: "Fast", + provider: "codex", + featureValues: { service_tier: "fast" }, + }, + ], + }, + }, + }), + ); + const { config } = await reading; + expect(config.agentProfiles?.[0]).toMatchObject({ options: { service_tier: "fast" } }); + expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues"); + + void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {}); + const written = sentMessages().find((message) => message.type === "set_daemon_config_request"); + expect(written).toMatchObject({ + config: { agentProfiles: [{ featureValues: { service_tier: "fast" } }] }, + }); + expect(written).not.toMatchObject({ config: { agentProfiles: [{ options: {} }] } }); + }); + + test("a legacy daemon applies a profile's options as feature values", async () => { + const { client, sentMessages } = await connect({}); + void client.applyAgentConfig("agent-1", { options: { service_tier: "fast" } }).catch(() => {}); + const [request] = sentMessages(); + expect(request).toMatchObject({ + type: "agent.config.apply.request", + 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( + wrapSessionMessage({ + type: "agent_update", + payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } }, + }), + ); + void client + .createAgent({ + config: { + provider: "codex", + cwd: "/repo", + providerOptions: { service_tier: "priority", sandbox_mode: "read-only" }, + }, + }) + .catch(() => {}); + const [request] = sentMessages(); + expect(request.config).toMatchObject({ + featureValues: { service_tier: "priority" }, + providerOptions: { sandbox_mode: "read-only" }, + }); + }); +}); diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 3aa91f84c7..21e2fa42e2 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -102,6 +102,7 @@ import type { WorkspaceSetupStatusResponseMessage, ListCommandsResponse, ListProviderFeaturesResponseMessage, + ProviderControlsListResponseMessage, ListProviderModelsResponseMessage, ListProviderModesResponseMessage, ListAvailableProvidersResponse, @@ -184,6 +185,10 @@ import { normalizeProviderSnapshotUpdateMessage, normalizeProvidersSnapshotPayload, } from "./compat/normalize-provider-models.js"; +import { + LegacyProviderOptions, + usesLegacyProviderOptions, +} from "./compat/legacy-provider-options.js"; import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js"; import type { BrowserAutomationExecuteRequest, @@ -598,6 +603,7 @@ export interface FileUploadInput { export type FileUploadResult = FileUploadResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"]; +export type ListProviderControlsPayload = ProviderControlsListResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; @@ -624,7 +630,7 @@ type WriteProjectConfigPayload = Extract< type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsDraftConfig = Pick< AgentSessionConfig, - "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" | "providerOptions" >; export interface WriteProjectConfigInput { repoRoot: string; @@ -2089,17 +2095,13 @@ export class DaemonClient { } private sendSessionPayload(payload: SessionInboundMessage): Promise { - return this.sendJsonMessage( - "session", - payload.type, - { type: "session", message: payload }, - () => { - if (!("requestId" in payload) || typeof payload.requestId !== "string") return; - for (const waiter of this.waiters) { - if (waiter.requestId === payload.requestId) waiter.submitted = true; - } - }, - ); + const wire = this.legacyProviderOptions()?.prepare(payload) ?? payload; + return this.sendJsonMessage("session", payload.type, { type: "session", message: wire }, () => { + if (!("requestId" in payload) || typeof payload.requestId !== "string") return; + for (const waiter of this.waiters) { + if (waiter.requestId === payload.requestId) waiter.submitted = true; + } + }); } private sendTransportFrame( @@ -4065,6 +4067,24 @@ export class DaemonClient { } } + /** + * Changes the agent's own provider options: top-level keys replace, `null` removes the key. + * A daemon without `providerOptionControls` takes each key as a feature write. + */ + async setAgentOptions( + agentId: string, + patch: Record, + ): Promise { + if (this.legacyProviderOptions()) { + // COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. + for (const [featureId, value] of Object.entries(patch)) { + await this.setAgentFeature(agentId, featureId, value); + } + return null; + } + return this.applyAgentConfig(agentId, { options: patch }); + } + async setAgentThinkingOption( agentId: string, thinkingOptionId: string | null, @@ -5494,6 +5514,36 @@ export class DaemonClient { }); } + /** + * The controls a draft agent would show and the options it would run with: `config.json` + * defaults merged with the draft's own `providerOptions`, resolved by the daemon. + */ + async listProviderControls( + draftConfig: ListCommandsDraftConfig, + options?: { requestId?: string }, + ): Promise { + const legacy = this.legacyProviderOptions(); + if (legacy) { + const { providerOptions, ...rest } = draftConfig; + const payload = await this.listProviderFeatures( + { + ...rest, + // A legacy daemon reads the options it exposes as features, whatever the others are. + ...(providerOptions ? { featureValues: providerOptions } : {}), + }, + options, + ); + return legacy.controlsResponse(payload); + } + return this.sendCorrelatedSessionRequest({ + requestId: options?.requestId, + message: { type: "provider.controls.list.request", draftConfig }, + responseType: "provider.controls.list.response", + timeout: 90000, + }); + } + + /** @deprecated Use `listProviderControls`. */ async listProviderFeatures( draftConfig: ListCommandsDraftConfig, options?: { requestId?: string }, @@ -6747,6 +6797,14 @@ export class DaemonClient { return requestId ?? crypto.randomUUID(); } + private readonly legacyOptions = new LegacyProviderOptions(); + + /** The translation for a daemon without `providerOptionControls`; null for a current one. */ + private legacyProviderOptions(): LegacyProviderOptions | null { + const info = this.lastServerInfoMessage; + return info && usesLegacyProviderOptions(info) ? this.legacyOptions : null; + } + getLastServerInfoMessage(): ServerInfoStatusPayload | null { return this.lastServerInfoMessage; } @@ -7308,6 +7366,7 @@ export class DaemonClient { private handleSessionMessage(msg: SessionOutboundMessage): void { msg = this.owned.normalize(msg); + msg = this.legacyProviderOptions()?.normalize(msg) ?? msg; if ( msg.type === "providers_snapshot_update" && this.config.providerSnapshots !== "wire" && diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 1b11a3032f..ed96f2e6c7 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1633,8 +1633,8 @@ test("agent config maps provider/model and provider-native options to the daemon model: "gpt-5.4", modeId: "full-access", thinkingOptionId: "high", - featureValues: { webSearch: true }, providerOptions: { + webSearch: true, approval_policy: "never", sandbox_mode: "workspace-write", sandbox_workspace_write: { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 3655b0023a..b476a35cda 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -20,6 +20,8 @@ import type { ListCommandsResponse, ListProviderFeaturesRequestMessage, ListProviderFeaturesResponseMessage, + ProviderControlsListRequestMessage, + ProviderControlsListResponseMessage, ListProviderModelsResponseMessage, ProjectListRequestMessage, ProjectListResponseMessage, @@ -239,8 +241,12 @@ export interface PaseoAgentConfig { provider: string; modeId?: PaseoAgentSessionConfig["modeId"]; thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"]; + /** @deprecated Put these in `options`; they are folded into it on send. */ featureValues?: PaseoProviderFeatureValues; - /** JSON-safe provider-native settings, validated by the selected provider. */ + /** + * JSON-safe provider settings, validated by the selected provider. They include the settings the + * provider offers a composer control for, such as `service_tier` or `fast_mode`. + */ options?: PaseoAgentSessionConfig["providerOptions"]; systemPrompt?: PaseoAgentSessionConfig["systemPrompt"]; toolPolicy?: PaseoAgentSessionConfig["toolPolicy"]; @@ -364,7 +370,12 @@ export interface PaseoAgentHandle { readonly activeTurn: NonNullable | null; readonly lastUsage: NonNullable | null; readonly lastError: NonNullable | null; + /** @deprecated Use `controls` and `options`. */ readonly features: NonNullable | null; + /** The provider options the agent runs with: `config.json` defaults merged with its own. */ + readonly options: NonNullable | null; + /** How the provider wants its options drawn. Controls hold no value; read `options`. */ + readonly controls: NonNullable | null; readonly runtimeInfo: NonNullable | null; readonly archivedAt: NonNullable | null; readonly timeline: PaseoAgentTimelineHandle; @@ -377,6 +388,12 @@ export interface PaseoAgentHandle { reload(options?: { requestId?: string }): Promise; send(text: string, options?: PaseoAgentSendOptions): Promise; respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise; + /** + * Changes the agent's own options: top-level keys replace, `null` removes the key so the + * `config.json` default (or the provider's own default) applies again. Resolves once the + * provider has applied the patch; a provider that cannot apply a key live rejects it. + */ + setOptions(patch: Record): Promise; /** Sends a prompt and resolves when that turn finishes or needs attention. */ run(text: string, options?: PaseoAgentRunOptions): Promise; /** Waits for the current turn, including one started with `prompt`. */ @@ -413,14 +430,21 @@ export interface PaseoAgentActions { export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"]; export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"]; -type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"]; -export interface PaseoProviderFeaturesInput extends Omit< - PaseoProviderFeaturesDraft, +type PaseoProviderControlsDraft = ProviderControlsListRequestMessage["draftConfig"]; +export interface PaseoProviderControlsInput extends Omit< + PaseoProviderControlsDraft, "provider" | "model" > { /** Provider and model in `provider/model` format. */ provider: string; } +export type PaseoProviderControlsResult = ProviderControlsListResponseMessage["payload"]; +/** @deprecated Use `PaseoProviderControlsInput`. */ +export type PaseoProviderFeaturesInput = Omit< + ListProviderFeaturesRequestMessage["draftConfig"], + "provider" | "model" +> & { provider: string }; +/** @deprecated Use `PaseoProviderControlsResult`. */ export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"]; export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"]; export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"]; @@ -459,6 +483,15 @@ export interface PaseoProviderActions { provider: PaseoAgentProvider, options?: PaseoProviderListOptions, ): Promise; + /** + * The controls a draft agent would show and the options it would run with, resolved by the + * daemon from `config.json` defaults and the draft's own `providerOptions`. + */ + listControls( + draftConfig: PaseoProviderControlsInput, + options?: { requestId?: string }, + ): Promise; + /** @deprecated Use `listControls`. */ listFeatures( draftConfig: PaseoProviderFeaturesInput, options?: { requestId?: string }, @@ -543,7 +576,14 @@ function toDaemonAgentCreateOptions( serverId?: string, ): CreateAgentRequestOptions { const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; - const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig; + const { + provider: providerModel, + options: ownOptions, + featureValues, + ...runtimeConfig + } = agentConfig; + // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. An explicit option wins. + const providerOptions = featureValues ? { ...featureValues, ...ownOptions } : ownOptions; const { provider, model } = parseProviderModel(providerModel); const relationship = parent ? encodeParent(parent, serverId) : {}; return { @@ -776,6 +816,10 @@ export function createPaseoApi( const { provider, model } = parseProviderModel(providerModel); return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options); }, + listControls: ({ provider: providerModel, ...draftConfig }, options) => { + const { provider, model } = parseProviderModel(providerModel); + return daemonClient.listProviderControls({ ...draftConfig, provider, model }, options); + }, listAvailable: (options) => daemonClient.listAvailableProviders(options), snapshot: (options) => daemonClient.getProvidersSnapshot(options), waitForReady: (options) => @@ -975,6 +1019,12 @@ function createAgentHandleFactory( get features() { return current?.features ?? null; }, + get options() { + return current?.options ?? null; + }, + get controls() { + return current?.controls ?? null; + }, get runtimeInfo() { return current?.runtimeInfo ?? null; }, @@ -996,6 +1046,9 @@ function createAgentHandleFactory( send: async (text, options) => { await daemonClient.sendAgentMessage(id, text, options); }, + setOptions: async (patch) => { + await daemonClient.setAgentOptions(id, patch); + }, respondToPermission: async ({ requestId, response }) => { await daemonClient.respondToPermission(id, requestId, response); }, diff --git a/packages/protocol/src/option-controls.ts b/packages/protocol/src/option-controls.ts new file mode 100644 index 0000000000..29b7df40fb --- /dev/null +++ b/packages/protocol/src/option-controls.ts @@ -0,0 +1,107 @@ +import type { + AgentFeature, + AgentOptionControl, + AgentOptionSelectControl, + ProviderOptions, +} from "./agent-types.js"; + +// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. +// `features` are controls with their value attached. The daemon builds them for apps that +// predate controls, and the client package rebuilds controls and options from them when it +// talks to a daemon that predates controls. Both directions live here so they stay inverse. + +function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) { + const value = options[control.key]; + if (typeof value === "string") { + const choice = control.choices.find((candidate) => candidate.value === value); + if (choice) return choice.value; + } + return control.defaultValue ?? null; +} + +function describe(control: { description?: string; tooltip?: string; icon?: string }): { + description?: string; + tooltip?: string; + icon?: string; +} { + return { + ...(control.description !== undefined ? { description: control.description } : {}), + ...(control.tooltip !== undefined ? { tooltip: control.tooltip } : {}), + ...(control.icon !== undefined ? { icon: control.icon } : {}), + }; +} + +function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature { + if (control.type === "toggle") { + const value = effective[control.key]; + return { + type: "toggle", + id: control.key, + label: control.label, + ...describe(control), + value: typeof value === "boolean" ? value : (control.defaultValue ?? false), + }; + } + return { + type: "select", + id: control.key, + label: control.label, + ...describe(control), + ...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}), + value: selectedChoice(control, effective), + options: control.choices.map((choice) => ({ + id: choice.value, + label: choice.label, + ...(choice.description ? { description: choice.description } : {}), + ...(choice.value === control.defaultValue ? { isDefault: true } : {}), + })), + }; +} + +/** Apps that predate controls render `features`; build them from controls + effective options. */ +export function featuresFromControls(input: { + controls: readonly AgentOptionControl[] | undefined; + options: ProviderOptions | undefined; +}): AgentFeature[] { + const { controls, options } = input; + const effective = options ?? {}; + return (controls ?? []).map((control) => featureFromControl(control, effective)); +} + +/** A recorded feature's value is not carried over: controls hold no state, only `options` do. */ +function controlFromFeature(feature: AgentFeature): AgentOptionControl { + const descriptor = { + key: feature.id, + label: feature.label, + ...describe(feature), + }; + if (feature.type === "toggle") { + return { type: "toggle", ...descriptor }; + } + const defaultChoice = feature.options.find((option) => option.isDefault); + return { + type: "select", + ...descriptor, + ...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}), + ...(defaultChoice ? { defaultValue: defaultChoice.id } : {}), + choices: feature.options.map((option) => ({ + value: option.id, + label: option.label, + ...(option.description ? { description: option.description } : {}), + })), + }; +} + +/** Agent records and daemons that predate controls carry `features`; their descriptors become controls. */ +export function controlsFromFeatures( + features: readonly AgentFeature[] | undefined, +): AgentOptionControl[] { + return (features ?? []).map(controlFromFeature); +} + +/** The options a list of `features` stands for: each feature's value under its id. */ +export function optionsFromFeatures( + features: readonly AgentFeature[] | undefined, +): ProviderOptions { + return Object.fromEntries((features ?? []).map((feature) => [feature.id, feature.value])); +} diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index c42b2e6867..491635befe 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -20,7 +20,7 @@ import type { } from "./agent-sdk-types.js"; import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; import type { ManagedAgent } from "./agent-manager.js"; -import { featuresFromControls } from "./option-controls.js"; +import { featuresFromControls } from "@getpaseo/protocol/option-controls"; import type { JsonValue } from "../json-utils.js"; import { isStoredAgentProviderAvailable, diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 07dd93bfa3..03c99cfb0b 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -9,7 +9,8 @@ import { toStoredAgentRecord } from "./agent-projections.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js"; import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js"; -import { controlsFromFeatures, foldFeatureValues } from "./option-controls.js"; +import { controlsFromFeatures } from "@getpaseo/protocol/option-controls"; +import { foldFeatureValues } from "./option-controls.js"; const SERIALIZABLE_CONFIG_SCHEMA = z .object({ diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 9e843f6fe2..f3893fcfef 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -1,10 +1,5 @@ import { isDeepStrictEqual } from "node:util"; -import type { - AgentFeature, - AgentOptionControl, - AgentOptionSelectControl, - ProviderOptions, -} from "@getpaseo/protocol/agent-types"; +import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; /** * A patch over an agent's own options: top-level keys replace, `null` removes the key so the @@ -207,98 +202,3 @@ export function foldProfileFeatureValues(input: { export function withLegacyProfileFeatureValues(profile: T): T { return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile; } - -function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) { - const value = options[control.key]; - if (typeof value === "string") { - const choice = control.choices.find((candidate) => candidate.value === value); - if (choice) return choice.value; - } - return control.defaultValue ?? null; -} - -function describe(control: { description?: string; tooltip?: string; icon?: string }): { - description?: string; - tooltip?: string; - icon?: string; -} { - return { - ...(control.description !== undefined ? { description: control.description } : {}), - ...(control.tooltip !== undefined ? { tooltip: control.tooltip } : {}), - ...(control.icon !== undefined ? { icon: control.icon } : {}), - }; -} - -function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature { - if (control.type === "toggle") { - const value = effective[control.key]; - return { - type: "toggle", - id: control.key, - label: control.label, - ...describe(control), - value: typeof value === "boolean" ? value : (control.defaultValue ?? false), - }; - } - return { - type: "select", - id: control.key, - label: control.label, - ...describe(control), - ...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}), - value: selectedChoice(control, effective), - options: control.choices.map((choice) => ({ - id: choice.value, - label: choice.label, - ...(choice.description ? { description: choice.description } : {}), - ...(choice.value === control.defaultValue ? { isDefault: true } : {}), - })), - }; -} - -/** - * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - * Apps that predate controls render `features`; build them from controls + effective options. - */ -export function featuresFromControls(input: { - controls: readonly AgentOptionControl[] | undefined; - options: ProviderOptions | undefined; -}): AgentFeature[] { - const { controls, options } = input; - const effective = options ?? {}; - return (controls ?? []).map((control) => featureFromControl(control, effective)); -} - -/** A recorded feature's value is not carried over: controls hold no state, only `options` do. */ -function controlFromFeature(feature: AgentFeature): AgentOptionControl { - const descriptor = { - key: feature.id, - label: feature.label, - ...describe(feature), - }; - if (feature.type === "toggle") { - return { type: "toggle", ...descriptor }; - } - const defaultChoice = feature.options.find((option) => option.isDefault); - return { - type: "select", - ...descriptor, - ...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}), - ...(defaultChoice ? { defaultValue: defaultChoice.id } : {}), - choices: feature.options.map((option) => ({ - value: option.id, - label: option.label, - ...(option.description ? { description: option.description } : {}), - })), - }; -} - -/** - * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - * Agent records written before controls stored `features`; their descriptors become controls. - */ -export function controlsFromFeatures( - features: readonly AgentFeature[] | undefined, -): AgentOptionControl[] { - return (features ?? []).map(controlFromFeature); -} diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 48a53a3647..120e877d8e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -7,7 +7,8 @@ import { describe, expect, test, vi } from "vitest"; import { AgentManager } from "../agent-manager.js"; import { toAgentPayload } from "../agent-projections.js"; import { AgentStorage } from "../agent-storage.js"; -import { convertLegacyOptions, featuresFromControls } from "../option-controls.js"; +import { featuresFromControls } from "@getpaseo/protocol/option-controls"; +import { convertLegacyOptions } from "../option-controls.js"; import type { AgentClient, AgentSession, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 8a0409b188..9cee3eee45 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -18,7 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk- import type { AgentManager } from "../agent-manager.js"; import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages"; import type { DaemonConfigStore } from "../../daemon-config-store.js"; -import { featuresFromControls } from "../option-controls.js"; +import { featuresFromControls } from "@getpaseo/protocol/option-controls"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index 041d7182fc..ee9560f812 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -19,7 +19,8 @@ import { type ProviderSnapshotEntry, } from "../../agent/agent-sdk-types.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; -import { featuresFromControls, foldFeatureValues } from "../../agent/option-controls.js"; +import { featuresFromControls } from "@getpaseo/protocol/option-controls"; +import { foldFeatureValues } from "../../agent/option-controls.js"; import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; import { expandTilde } from "../../../utils/path.js";