mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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.
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-2
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<AgentOptionControl, "label" | "tooltip">): string {
|
||||
return control.tooltip ?? control.label;
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
|
||||
modelId: string;
|
||||
modeId: string;
|
||||
thinkingOptionId: string;
|
||||
featureValues: Record<string, unknown>;
|
||||
options: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
/** 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 })),
|
||||
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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<SheetHeader>(
|
||||
() => ({
|
||||
@@ -328,16 +329,17 @@ function OpenAgentProfileEditModal({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{state.disclosure.showFeaturesField ? (
|
||||
{state.disclosure.showControlsField ? (
|
||||
<Field
|
||||
label={t("settings.host.agentProfiles.featuresLabel")}
|
||||
testID="agent-profile-features-field"
|
||||
>
|
||||
<View style={styles.featureCard} testID="agent-profile-features-card">
|
||||
{state.features.map((feature, index) => (
|
||||
<AgentProfileFeatureRow
|
||||
key={feature.id}
|
||||
feature={feature}
|
||||
{state.controls.map((control, index) => (
|
||||
<AgentProfileControlRow
|
||||
key={control.key}
|
||||
control={control}
|
||||
options={state.controlOptions}
|
||||
isFirst={index === 0}
|
||||
disabled={state.isSubmitting}
|
||||
size={controlSize}
|
||||
@@ -400,14 +402,16 @@ function OpenAgentProfileEditModal({
|
||||
);
|
||||
}
|
||||
|
||||
function AgentProfileFeatureRow({
|
||||
feature,
|
||||
function AgentProfileControlRow({
|
||||
control,
|
||||
options,
|
||||
isFirst,
|
||||
disabled,
|
||||
size,
|
||||
model,
|
||||
}: {
|
||||
feature: AgentFeature;
|
||||
control: AgentOptionControl;
|
||||
options: ProviderOptions;
|
||||
isFirst: boolean;
|
||||
disabled: boolean;
|
||||
size: FieldControlSize;
|
||||
@@ -418,74 +422,75 @@ function AgentProfileFeatureRow({
|
||||
[isFirst],
|
||||
);
|
||||
const handleToggle = useCallback(
|
||||
(value: boolean) => 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<SelectFieldOption<string>[]>(
|
||||
() =>
|
||||
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<SelectFieldDisplay | null>(() => {
|
||||
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 (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<SelectField
|
||||
label={feature.label}
|
||||
value={feature.value}
|
||||
label={control.label}
|
||||
value={selectedValue}
|
||||
selectedDisplay={selectedDisplay}
|
||||
options={selectOptions}
|
||||
onChange={handleSelect}
|
||||
placeholder={feature.label}
|
||||
emptyText={feature.label}
|
||||
placeholder={control.label}
|
||||
emptyText={control.label}
|
||||
disabled={disabled}
|
||||
{...(feature.description ? { hint: feature.description } : {})}
|
||||
title={feature.label}
|
||||
{...(control.description ? { hint: control.description } : {})}
|
||||
title={control.label}
|
||||
size={size}
|
||||
testID={`agent-profile-feature-field-${feature.id}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
|
||||
testID={`agent-profile-feature-field-${control.key}`}
|
||||
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
||||
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||
<View style={styles.featureMeta}>
|
||||
<Text style={styles.featureLabel} numberOfLines={1}>
|
||||
{feature.label}
|
||||
{control.label}
|
||||
</Text>
|
||||
{feature.description ? (
|
||||
{control.description ? (
|
||||
<Text style={styles.featureDescription} numberOfLines={2}>
|
||||
{feature.description}
|
||||
{control.description}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Switch
|
||||
value={feature.value === true}
|
||||
value={readToggleOption(control, options)}
|
||||
onValueChange={handleToggle}
|
||||
disabled={disabled}
|
||||
accessibilityLabel={feature.label}
|
||||
testID={`agent-profile-feature-switch-${feature.id}`}
|
||||
accessibilityLabel={control.label}
|
||||
testID={`agent-profile-feature-switch-${control.key}`}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -40,16 +40,7 @@ function createAgent(overrides: Partial<Agent> = {}): 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",
|
||||
},
|
||||
|
||||
@@ -65,17 +65,12 @@ export function resolveClientSlashCommand(input: {
|
||||
}
|
||||
|
||||
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<AgentControlContributionSource["models"]>;
|
||||
thinking?: Partial<AgentControlContributionSource["thinking"]>;
|
||||
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
|
||||
features?: Partial<AgentControlContributionSource["features"]>;
|
||||
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
|
||||
} = {},
|
||||
): 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<string, number>();
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[];
|
||||
set(featureId: string, value: unknown): void | Promise<void>;
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
features: {
|
||||
list: readonly AgentFeature[] | undefined;
|
||||
set?: (featureId: string, value: unknown) => void | Promise<void>;
|
||||
};
|
||||
/** 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,
|
||||
|
||||
@@ -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 ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
|
||||
|
||||
{presentation.aggregateFeatures && features?.length ? (
|
||||
{presentation.aggregateFeatures && optionControls?.controls.length ? (
|
||||
<>
|
||||
<Pressable
|
||||
onPress={handleOpenFeatures}
|
||||
@@ -1077,27 +1081,29 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
||||
onClose={handleCloseSheet}
|
||||
testID="agent-features-sheet"
|
||||
>
|
||||
{features.map((feature) => (
|
||||
{optionControls.controls.map((control) => (
|
||||
<SheetFeatureItem
|
||||
key={`feature-${feature.id}`}
|
||||
feature={feature}
|
||||
key={`feature-${control.key}`}
|
||||
control={control}
|
||||
options={optionControls.options}
|
||||
disabled={disabled}
|
||||
openSelector={openSelector}
|
||||
handleOpenChange={handleNestedOpenChange}
|
||||
onSetFeature={onSetFeature}
|
||||
onSetOption={optionControls.onSetOption}
|
||||
/>
|
||||
))}
|
||||
</AdaptiveModalSheet>
|
||||
</>
|
||||
) : (
|
||||
features?.map((feature) => (
|
||||
optionControls?.controls.map((control) => (
|
||||
<DesktopFeatureItem
|
||||
key={`feature-${feature.id}`}
|
||||
feature={feature}
|
||||
key={`feature-${control.key}`}
|
||||
control={control}
|
||||
options={optionControls.options}
|
||||
disabled={disabled}
|
||||
openSelector={openSelector}
|
||||
handleOpenChange={handleOpenChange}
|
||||
onSetFeature={onSetFeature}
|
||||
onSetOption={optionControls.onSetOption}
|
||||
onActionComplete={onDropdownClose}
|
||||
/>
|
||||
))
|
||||
@@ -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<View | null>(null);
|
||||
const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS;
|
||||
|
||||
const hasThinking = comboboxThinkingOptions.length > 0;
|
||||
|
||||
@@ -1238,14 +1243,15 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
||||
|
||||
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
|
||||
|
||||
{(features ?? []).map((feature) => (
|
||||
{(optionControls?.controls ?? []).map((control) => (
|
||||
<SheetFeatureItem
|
||||
key={`feature-${feature.id}`}
|
||||
feature={feature}
|
||||
key={`feature-${control.key}`}
|
||||
control={control}
|
||||
options={sheetOptions}
|
||||
disabled={disabled}
|
||||
openSelector={openSelector}
|
||||
handleOpenChange={handleOpenChange}
|
||||
onSetFeature={onSetFeature}
|
||||
onSetOption={optionControls?.onSetOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
@@ -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<View>(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<ComboboxOption[]>(
|
||||
() =>
|
||||
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 (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<AgentControlTrigger
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
iconColor={iconColor}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
label={control.label}
|
||||
showToolbarLabel={false}
|
||||
disabled={disabled}
|
||||
onPress={handleTogglePress}
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
accessibilityLabel={getControlTooltip(control)}
|
||||
testID={`agent-feature-${control.key}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
||||
<Text style={styles.tooltipText}>{getControlTooltip(control)}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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 delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="toolbar"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
showToolbarLabel={!iconOnly}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={tooltip}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={String(feature.value)}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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 delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild triggerRefProp="ref">
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={iconColor}
|
||||
surface="toolbar"
|
||||
label={control.label}
|
||||
value={selectedChoice?.label ?? control.label}
|
||||
showToolbarLabel={!iconOnly}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={tooltip}
|
||||
testID={`agent-feature-${control.key}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={String(selectedValue)}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
desktopPlacement="top-start"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<View>(null);
|
||||
|
||||
const handleFeatureOpenChange = useMemo(
|
||||
@@ -1431,97 +1436,71 @@ function SheetFeatureItem({
|
||||
() => handleFeatureOpenChange(openSelector !== featureSelector),
|
||||
[featureSelector, handleFeatureOpenChange, openSelector],
|
||||
);
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
|
||||
const sheetHeader = useMemo<SheetHeader>(() => ({ 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<ComboboxOption[]>(() => {
|
||||
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 (
|
||||
<>
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="sheet"
|
||||
label={feature.label}
|
||||
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
/>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={String(feature.value)}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
presentation="push"
|
||||
searchable={false}
|
||||
header={sheetHeader}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
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 (
|
||||
<>
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={getFeatureIconColor(
|
||||
feature,
|
||||
theme.colors.palette,
|
||||
theme.colors.foregroundMuted,
|
||||
)}
|
||||
surface="sheet"
|
||||
label={feature.label}
|
||||
value={selectedOption?.label ?? feature.label}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={getFeatureTooltip(feature)}
|
||||
testID={`agent-feature-${feature.id}`}
|
||||
/>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={String(feature.value)}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
presentation="push"
|
||||
header={sheetHeader}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return (
|
||||
<>
|
||||
<AgentControlTrigger
|
||||
ref={featureAnchorRef}
|
||||
icon={FeatureIcon}
|
||||
iconColor={iconColor}
|
||||
surface="sheet"
|
||||
label={control.label}
|
||||
value={displayValue}
|
||||
open={openSelector === featureSelector}
|
||||
disabled={disabled}
|
||||
onPress={handleSelectPress}
|
||||
accessibilityLabel={getControlTooltip(control)}
|
||||
testID={`agent-feature-${control.key}`}
|
||||
/>
|
||||
<Combobox
|
||||
options={comboboxOptions}
|
||||
value={selectedValue}
|
||||
onSelect={handleSelectOption}
|
||||
open={openSelector === featureSelector}
|
||||
onOpenChange={handleFeatureOpenChange}
|
||||
anchorRef={featureAnchorRef}
|
||||
presentation="push"
|
||||
searchable={control.type === "toggle" ? false : undefined}
|
||||
header={sheetHeader}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
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<AgentControlCommandCenterSource>(
|
||||
() => ({
|
||||
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}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<AgentFeature, "label" | "tooltip">): 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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
initialProviderOptions?: Record<string, unknown>;
|
||||
isVisible?: boolean;
|
||||
lockedWorkingDir?: string;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
||||
effectiveModeId: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: Record<string, unknown> | 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<typeof formState.applyProfileFromUser>[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<DraftComposerState | null>(() => {
|
||||
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
providerOptions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resolveAutoSubmitConfig(
|
||||
@@ -74,7 +75,7 @@ function resolveAutoSubmitConfig(
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
} | 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<string, unknown> | undefined;
|
||||
providerOptions: Record<string, unknown> | 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(
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
featureValues?: Record<string, unknown>;
|
||||
/** Last-used values of the provider's option controls, by option key. */
|
||||
options?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
|
||||
@@ -20,12 +21,23 @@ export interface FormPreferences {
|
||||
launchTarget?: LaunchTarget;
|
||||
}
|
||||
|
||||
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = 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<string, unknown>;
|
||||
return "options" in rest ? rest : { ...rest, options: featureValues };
|
||||
}
|
||||
|
||||
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = 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<LaunchTarget> = 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<string, unknown>;
|
||||
options?: Record<string, unknown>;
|
||||
}): 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<string, unknown>;
|
||||
options: Record<string, unknown>;
|
||||
}): 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,60 +0,0 @@
|
||||
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
|
||||
|
||||
export function pruneFeatureValues(
|
||||
featureValues: Record<string, unknown>,
|
||||
features: AgentFeature[],
|
||||
): Record<string, unknown> {
|
||||
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
||||
let changed = false;
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
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<string, unknown>,
|
||||
): 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<string, unknown>;
|
||||
localFeatureValues: Record<string, unknown>;
|
||||
}): Record<string, unknown> {
|
||||
const next: Record<string, unknown> = {};
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
|
||||
input;
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
|
||||
() => initialFeatureValues ?? {},
|
||||
);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const normalizedProvider = provider ?? null;
|
||||
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
|
||||
const persistedFeatureValues = useMemo(
|
||||
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
||||
[preferences.providerPreferences, provider],
|
||||
);
|
||||
|
||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
||||
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<string, unknown>) => {
|
||||
setLocalFeatureValues(values);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
features,
|
||||
featureValues: effectiveFeatureValues,
|
||||
isLoading: featuresQuery.isLoading,
|
||||
setFeatureValue,
|
||||
applyProfileFeatureValues,
|
||||
};
|
||||
}
|
||||
@@ -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<ProviderOptions>(() => initialOptions ?? {});
|
||||
const [edits, setEdits] = useState<ProviderOptions>({});
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const normalizedProvider = provider ?? null;
|
||||
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
|
||||
const persisted = useMemo(
|
||||
() => (provider ? (preferences.providerPreferences?.[provider]?.options ?? {}) : {}),
|
||||
[preferences.providerPreferences, provider],
|
||||
);
|
||||
|
||||
const draftConfig = useMemo<DraftControlsConfig | null>(() => {
|
||||
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<ComposerOptionControls | undefined>(
|
||||
() => (controls ? { controls, options, onSetOption: setOption } : undefined),
|
||||
[controls, options, setOption],
|
||||
);
|
||||
|
||||
return {
|
||||
optionControls,
|
||||
options: ownOptions,
|
||||
isLoading: controlsQuery.isLoading,
|
||||
applyProfileOptions,
|
||||
};
|
||||
}
|
||||
@@ -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<string, unknown> = {};
|
||||
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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: {
|
||||
cwd: string;
|
||||
effectiveModelId: string;
|
||||
effectiveThinkingOptionId: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}): 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 };
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -271,7 +271,7 @@ interface WorkspaceDraftSubmissionConfig {
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: Record<string, unknown> | 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<Su
|
||||
modeId: composerState.effectiveModeId || undefined,
|
||||
model: composerState.effectiveModelId || undefined,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||
featureValues: composerState.featureValues,
|
||||
providerOptions: composerState.providerOptions,
|
||||
},
|
||||
initialPrompt: text,
|
||||
clientMessageId: `${input.draftId}:initial-message`,
|
||||
@@ -534,7 +534,7 @@ function buildComposerConfig(input: {
|
||||
return {
|
||||
initialServerId: serverId || null,
|
||||
initialValues: buildComposerInitialValues({ initialSetup }),
|
||||
initialFeatureValues: initialSetup?.featureValues,
|
||||
initialProviderOptions: initialSetup?.providerOptions,
|
||||
isVisible: true,
|
||||
lockedWorkingDir: workingDir,
|
||||
};
|
||||
@@ -567,7 +567,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
modeId: initialSetup.modeId,
|
||||
model: initialSetup.model,
|
||||
thinkingOptionId: initialSetup.thinkingOptionId,
|
||||
featureValues: initialSetup.featureValues,
|
||||
providerOptions: initialSetup.providerOptions,
|
||||
target: { kind: "draft", draftId, setup: initialSetup },
|
||||
};
|
||||
}
|
||||
@@ -577,7 +577,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
||||
modeId: composerState.effectiveModeId || null,
|
||||
model: composerState.effectiveModelId || null,
|
||||
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||
featureValues: composerState.featureValues,
|
||||
providerOptions: composerState.providerOptions,
|
||||
target: { kind: "draft", draftId },
|
||||
};
|
||||
}
|
||||
@@ -635,7 +635,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
|
||||
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
||||
...(submission.model ? { model: submission.model } : {}),
|
||||
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
|
||||
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
|
||||
...(submission.providerOptions ? { providerOptions: submission.providerOptions } : {}),
|
||||
allowEmptyText: true,
|
||||
agentCreation: input.agentCreation,
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("createWorkspaceAgentInBackground", () => {
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
providerOptions: {},
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => {
|
||||
modeId: null,
|
||||
model: null,
|
||||
thinkingOptionId: null,
|
||||
featureValues: {},
|
||||
providerOptions: {},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}): 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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
allowEmptyText?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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({
|
||||
|
||||
@@ -4551,7 +4551,6 @@ describe("compact workspace sheet", () => {
|
||||
title: agent.id,
|
||||
cwd: "/repo",
|
||||
model: null,
|
||||
features: undefined,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
|
||||
@@ -63,7 +63,6 @@ const AGENT_DEFAULTS: Agent = {
|
||||
cwd: WORKSPACE_DIRECTORY,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
features: undefined,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
|
||||
@@ -41,7 +41,6 @@ const AGENT_DEFAULTS: Agent = {
|
||||
title: "Agent",
|
||||
cwd: "/tmp/project",
|
||||
model: null,
|
||||
features: undefined,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
|
||||
@@ -60,7 +60,6 @@ const AGENT_DEFAULTS: Agent = {
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
model: null,
|
||||
features: undefined,
|
||||
thinkingOptionId: undefined,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export interface WorkspaceDraftTabSetup {
|
||||
modeId: string | null;
|
||||
model: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
featureValues: Record<string, unknown>;
|
||||
providerOptions: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface WorkspaceWorkingDiffTabTarget {
|
||||
|
||||
@@ -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<string, Set<string>>();
|
||||
|
||||
/** 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<typeof controlsFromFeatures>[0]): void {
|
||||
if (features) this.controlKeys.set(provider, new Set(features.map((feature) => feature.id)));
|
||||
}
|
||||
|
||||
private config<
|
||||
T extends {
|
||||
provider?: string;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
>(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<string, unknown>,
|
||||
): { featureValues: Record<string, unknown>; providerOptions: Record<string, unknown> } {
|
||||
const keys = this.controlKeys.get(provider);
|
||||
const featureValues: Record<string, unknown> = {};
|
||||
const providerOptions: Record<string, unknown> = {};
|
||||
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 };
|
||||
}
|
||||
@@ -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<string, boolean>) {
|
||||
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<string, unknown> }).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" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<void> {
|
||||
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<string, unknown>,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
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<ListProviderControlsPayload> {
|
||||
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" &&
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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<PaseoAgent["activeTurn"]> | null;
|
||||
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
|
||||
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
|
||||
/** @deprecated Use `controls` and `options`. */
|
||||
readonly features: NonNullable<PaseoAgent["features"]> | null;
|
||||
/** The provider options the agent runs with: `config.json` defaults merged with its own. */
|
||||
readonly options: NonNullable<PaseoAgent["options"]> | null;
|
||||
/** How the provider wants its options drawn. Controls hold no value; read `options`. */
|
||||
readonly controls: NonNullable<PaseoAgent["controls"]> | null;
|
||||
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
|
||||
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
|
||||
readonly timeline: PaseoAgentTimelineHandle;
|
||||
@@ -377,6 +388,12 @@ export interface PaseoAgentHandle {
|
||||
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
|
||||
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
||||
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
|
||||
/**
|
||||
* 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<string, unknown>): Promise<void>;
|
||||
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
||||
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
||||
/** 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<PaseoProviderModesResult>;
|
||||
/**
|
||||
* 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<PaseoProviderControlsResult>;
|
||||
/** @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);
|
||||
},
|
||||
|
||||
@@ -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]));
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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<T extends ProfileOptionFields>(input: {
|
||||
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user