From d86b6dec6e7e19a4d78b26374b18554fb454d172 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 23:20:19 +0100 Subject: [PATCH] fix(app): a closed agent shows its stored options, and a provider switch asks about its own options A closed agent's snapshot is projected from its stored record, which kept controls and provider options but dropped them from the payload, so the app drew no speed or other controls for it. The stored projection now carries them, with the legacy features derived from the same source. The draft read the form's provider and the model's options separately, so for the render after a manual provider change it asked the new provider about the previous provider's options. The request is now built from one snapshot of the model and is absent until the model has observed the new provider. --- .../draft-options-model.test.ts | 25 ++++++++++- .../src/agent-controls/draft-options-model.ts | 39 +++++++++++++++- .../src/hooks/use-draft-provider-options.ts | 34 ++++++-------- .../app/src/utils/agent-snapshots.test.ts | 30 +++++++++++++ .../server/agent/agent-projections.test.ts | 44 +++++++++++++++++++ .../src/server/agent/agent-projections.ts | 19 +++++++- 6 files changed, 167 insertions(+), 24 deletions(-) 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 : {}; }