diff --git a/packages/app/src/agent-controls/draft-options-model.test.ts b/packages/app/src/agent-controls/draft-options-model.test.ts index 9de5093ea3..9588bab568 100644 --- a/packages/app/src/agent-controls/draft-options-model.test.ts +++ b/packages/app/src/agent-controls/draft-options-model.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "vitest"; import type { AgentOptionControl } from "@getpaseo/protocol/agent-types"; import { isControlActive } from "./option-controls"; -import { openDraftOptions, ownDraftOptions, viewDraftOptions } from "./draft-options-model"; +import { + draftControlsRequest, + openDraftOptions, + ownDraftOptions, + viewDraftOptions, +} from "./draft-options-model"; const SPEED: AgentOptionControl = { type: "select", @@ -65,6 +70,24 @@ describe("draft options model", () => { expect(model.getState().resolved).toBeNull(); }); + it("never pairs a manually chosen provider with the previous provider's options", () => { + const model = openDraftOptions({ provider: "claude" }); + model.applyResolved(answer("claude", "k1", { fast_mode: true })); + model.setOption("fast_mode", true); + const form = { provider: "claude", cwd: "/repo" }; + expect(draftControlsRequest(model.getState(), form)).toMatchObject({ + provider: "claude", + providerOptions: { fast_mode: true }, + }); + + // The form shows OpenCode on its next render; the model has not observed it yet. + expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull(); + + model.observeProvider("opencode"); + const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" }); + expect(request).toEqual({ provider: "opencode", cwd: "/repo" }); + }); + it("keeps a draft's seed when the first provider is chosen", () => { const model = openDraftOptions({ provider: null, seed: { web_search: true } }); diff --git a/packages/app/src/agent-controls/draft-options-model.ts b/packages/app/src/agent-controls/draft-options-model.ts index c27abc4a69..aed92ded91 100644 --- a/packages/app/src/agent-controls/draft-options-model.ts +++ b/packages/app/src/agent-controls/draft-options-model.ts @@ -1,4 +1,8 @@ -import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; +import type { + AgentOptionControl, + AgentSessionConfig, + ProviderOptions, +} from "@getpaseo/protocol/agent-types"; /** * The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers @@ -109,6 +113,39 @@ export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | und return Object.keys(own).length > 0 ? own : undefined; } +export type DraftControlsRequest = Pick< + AgentSessionConfig, + "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions" +>; + +/** + * The listing request for the draft's form, built from one snapshot of the model. The form + * reaches a new provider before the model has observed it, and the model still holds the + * previous provider's options until it does; that render has no request, so a request never + * pairs one provider with another's options. + */ +export function draftControlsRequest( + state: DraftOptionsState, + form: { + provider: string | null; + cwd: string; + modeId?: string | null; + modelId?: string | null; + thinkingOptionId?: string | null; + }, +): DraftControlsRequest | null { + if (!form.provider || !form.cwd || form.provider !== state.provider) return null; + const providerOptions = ownDraftOptions(state); + return { + provider: form.provider, + cwd: form.cwd, + ...(form.modeId ? { modeId: form.modeId } : {}), + ...(form.modelId ? { model: form.modelId } : {}), + ...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}), + ...(providerOptions ? { providerOptions } : {}), + }; +} + /** * What the controls draw: the daemon's answer for this provider. The last answer stays up while * a newer request is in flight, with the toggles made since laid over it, so a switch does not diff --git a/packages/app/src/hooks/use-draft-provider-options.ts b/packages/app/src/hooks/use-draft-provider-options.ts index c1ecbad769..0eb11507d7 100644 --- a/packages/app/src/hooks/use-draft-provider-options.ts +++ b/packages/app/src/hooks/use-draft-provider-options.ts @@ -1,11 +1,8 @@ import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { useTranslation } from "react-i18next"; -import type { - AgentProvider, - AgentSessionConfig, - ProviderOptions, -} from "@getpaseo/protocol/agent-types"; +import type { AgentProvider, ProviderOptions } from "@getpaseo/protocol/agent-types"; import { + draftControlsRequest, openDraftOptions, ownDraftOptions, viewDraftOptions, @@ -15,11 +12,6 @@ import { useFetchQuery } from "@/data/query"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences"; -type DraftControlsConfig = Pick< - AgentSessionConfig, - "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions" ->; - /** * Adapter between a draft's options model and the screen: it sends the model's complete options * to the daemon, hands the answer back, and persists the user's toggles as last-used values. @@ -47,17 +39,17 @@ export function useDraftProviderOptions(input: { useEffect(() => model.setRemembered(persisted ?? {}), [model, persisted]); const ownOptions = ownDraftOptions(state); - const draftConfig = useMemo(() => { - if (!provider || !normalizedCwd) return null; - return { - provider, - cwd: normalizedCwd, - ...(modeId ? { modeId } : {}), - ...(modelId ? { model: modelId } : {}), - ...(thinkingOptionId ? { thinkingOptionId } : {}), - ...(ownOptions ? { providerOptions: ownOptions } : {}), - }; - }, [modeId, modelId, normalizedCwd, ownOptions, provider, thinkingOptionId]); + const draftConfig = useMemo( + () => + draftControlsRequest(state, { + provider, + cwd: normalizedCwd, + modeId, + modelId, + thinkingOptionId, + }), + [modeId, modelId, normalizedCwd, provider, state, thinkingOptionId], + ); const queryKey = useMemo( () => ["providerControls", serverId ?? null, draftConfig], [draftConfig, serverId], diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts index e1be8c5a0c..49c4bcb983 100644 --- a/packages/app/src/utils/agent-snapshots.test.ts +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { isControlActive, readSelectOption } from "@/agent-controls/option-controls"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots"; function createSnapshot( @@ -32,10 +33,39 @@ function createSnapshot( persistence: input.persistence ?? null, title: input.title ?? null, labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"], + ...(input.controls ? { controls: input.controls } : {}), + ...(input.options ? { options: input.options } : {}), }; } describe("normalizeAgentSnapshot", () => { + it("keeps a closed agent's stored options and controls, so its Fast control draws", () => { + const speed = { + type: "select" as const, + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast" }, + ], + }; + const agent = normalizeAgentSnapshot( + createSnapshot({ + status: "closed", + controls: [speed], + options: { service_tier: "priority", sandbox_mode: "read-only" }, + }), + "server-1", + ); + + expect(agent.status).toBe("closed"); + expect(agent.controls).toEqual([speed]); + expect(agent.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" }); + expect(readSelectOption(speed, agent.options ?? {})).toBe("priority"); + expect(isControlActive(speed, agent.options ?? {})).toBe(true); + }); + it("round-trips identified active turns through the canonical snapshot boundary", () => { const snapshot = createSnapshot({ status: "running", diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index 67809aef60..38ba8bf3d1 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -397,6 +397,50 @@ describe("toAgentPayload", () => { }); }); + it("projects a closed agent's stored controls and options", () => { + const controls: AgentOptionControl[] = [ + { + type: "select", + key: "service_tier", + label: "Speed", + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + { value: "priority", label: "Fast" }, + ], + }, + ]; + const record = toStoredAgentRecord( + createManagedAgent({ + provider: "codex", + lifecycle: "closed", + controls, + config: { + provider: "codex", + providerOptions: { service_tier: "priority", sandbox_mode: "read-only" }, + }, + }), + ); + + const payload = buildStoredAgentPayload(record, ["codex"]); + + expect(payload.status).toBe("closed"); + expect(payload.controls).toEqual(controls); + expect(payload.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" }); + expect(payload.features).toEqual([ + { + type: "select", + id: "service_tier", + label: "Speed", + value: "priority", + options: [ + { id: "default", label: "Normal", isDefault: true }, + { id: "priority", label: "Fast" }, + ], + }, + ]); + }); + it("carries a stored lastError onto the payload", () => { const record = toStoredAgentRecord( createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }), diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 491635befe..c727a045b3 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -213,6 +213,21 @@ function buildStoredPersistenceHandle( return toAgentPersistenceHandle(validProviders, record.persistence); } +function buildStoredOptionControls( + record: StoredAgentRecord, +): Pick { + const controls = normalizeControls(record.controls); + const options = record.config?.providerOptions + ? sanitizeEffectiveOptions(record.config.providerOptions) + : undefined; + return { + // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. + features: featuresFromControls({ controls, options }), + ...(options ? { options } : {}), + controls, + }; +} + export function buildStoredAgentPayload( record: StoredAgentRecord, validProviders: Iterable, @@ -234,6 +249,7 @@ export function buildStoredAgentPayload( const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; const runtimeInfo = buildStoredRuntimeInfo(record); + const optionControls = buildStoredOptionControls(record); const providerAvailable = isStoredAgentProviderAvailable(record, validProviders); const persistence = projectPersistenceHandleForWire( buildStoredPersistenceHandle(record, validProviders), @@ -258,6 +274,7 @@ export function buildStoredAgentPayload( capabilities: defaultCapabilities, currentModeId: record.lastModeId ?? null, availableModes: [], + ...optionControls, pendingPermissions: [], persistence, title: record.title ?? null, @@ -463,7 +480,7 @@ function normalizeControls( return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : []; } -function sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions { +function sanitizeEffectiveOptions(options: Record): ProviderOptions { const sanitized = sanitizeOptionalJson(options); return sanitized && isJsonObject(sanitized) ? sanitized : {}; }