From c3a7547ce52c343c6f6d7983537890aea48f5a7a Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 23:42:51 +0100 Subject: [PATCH] fix: persisted options keep their full shape, and every legacy snapshot gains controls Draft setups and form preferences validated option values as booleans, strings or null, but the options they now receive come from agents and profiles and can be objects, arrays or numbers. One such value failed validation and wiped the whole saved layout or the saved preferences. Both schemas now accept any JSON value. The legacy-daemon translation skipped the snapshots carried by agent_created, agent_resumed and timeline responses, so a created agent or a timeline refresh left the SDK handle without options and controls. The profile journey asserted the create request's options field; the wire field is providerOptions. --- ...agent-profile-modeless-preferences.spec.ts | 2 +- .../preferences.test.ts | 11 +++++ .../create-agent-preferences/preferences.ts | 2 +- .../workspace-layout-persistence.test.ts | 47 +++++++++++++++++++ .../src/stores/workspace-layout-storage.ts | 2 +- .../src/compat/legacy-provider-options.ts | 24 ++++++++-- packages/client/src/daemon-client.test.ts | 46 ++++++++++++++++++ 7 files changed, 127 insertions(+), 7 deletions(-) diff --git a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts index e13f2e880b..97370bb7c8 100644 --- a/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts +++ b/packages/app/e2e/browser/agent-profile-modeless-preferences.spec.ts @@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => { expect(createAgentRequest).toMatchObject({ config: { provider: featureProviderId, - options: { fast_mode: true }, + providerOptions: { fast_mode: true }, }, }); } finally { diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 9e18f05f0a..cdec2ac3c8 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -220,6 +220,17 @@ describe("create agent preferences", () => { }); }); + it("keeps options that are not booleans or strings, such as a profile's sandbox settings", () => { + const options = { + sandbox_workspace_write: { network_access: true, writable_roots: ["/tmp"] }, + max_turns: 3, + }; + + expect( + parseFormPreferences({ provider: "codex", providerPreferences: { codex: { options } } }), + ).toEqual({ provider: "codex", providerPreferences: { codex: { options } } }); + }); + it("rejects unknown persisted fields outside the explicit legacy shape", () => { expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); }); diff --git a/packages/app/src/create-agent-preferences/preferences.ts b/packages/app/src/create-agent-preferences/preferences.ts index 7dd421d16a..24dc426729 100644 --- a/packages/app/src/create-agent-preferences/preferences.ts +++ b/packages/app/src/create-agent-preferences/preferences.ts @@ -1,7 +1,7 @@ import { z } from "zod"; import type { AgentProvider } from "@getpaseo/protocol/agent-types"; -const optionsSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])); +const optionsSchema = z.record(z.string(), z.json()); export interface ProviderPreferences { model?: string; diff --git a/packages/app/src/stores/workspace-layout-persistence.test.ts b/packages/app/src/stores/workspace-layout-persistence.test.ts index 3398199ae4..47c4ab81da 100644 --- a/packages/app/src/stores/workspace-layout-persistence.test.ts +++ b/packages/app/src/stores/workspace-layout-persistence.test.ts @@ -95,6 +95,53 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra ); }); +it("keeps every saved layout when a draft tab's setup carries a nested provider option", async () => { + const values = new Map(); + const storage: StateStorage = { + getItem: (name) => values.get(name) ?? null, + setItem: (name, value) => { + values.set(name, value); + }, + removeItem: (name) => { + values.delete(name); + }, + }; + const store = createWorkspaceLayoutStore(undefined, storage); + await store.persist.rehydrate(); + store.getState().openTab({ + workspaceKey: "server:kept", + target: { kind: "agent", agentId: "agent-1" }, + intent: "reveal", + }); + store.getState().openTab({ + workspaceKey: "server:forked", + target: { + kind: "draft", + draftId: "draft-1", + setup: { + provider: "codex", + cwd: "/repo", + modeId: null, + model: null, + thinkingOptionId: null, + providerOptions: { sandbox_workspace_write: { network_access: true }, max_turns: 3 }, + }, + }, + intent: "reveal", + }); + + const restored = createWorkspaceLayoutStore(undefined, storage); + await restored.persist.rehydrate(); + + expect(Object.keys(restored.getState().layoutByWorkspace).sort()).toEqual([ + "server:forked", + "server:kept", + ]); + expect(JSON.stringify(restored.getState().layoutByWorkspace["server:forked"])).toContain( + "network_access", + ); +}); + it.each([1, 2])( "restores the agent pane after parked focus in storage version %i", async (version) => { diff --git a/packages/app/src/stores/workspace-layout-storage.ts b/packages/app/src/stores/workspace-layout-storage.ts index fc3d22a057..1dfa9e82db 100644 --- a/packages/app/src/stores/workspace-layout-storage.ts +++ b/packages/app/src/stores/workspace-layout-storage.ts @@ -17,7 +17,7 @@ const WorkspaceDraftTabSetupStorageSchema = z.preprocess( 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()])), + providerOptions: z.record(z.string(), z.json()), }), ); const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ diff --git a/packages/client/src/compat/legacy-provider-options.ts b/packages/client/src/compat/legacy-provider-options.ts index ca095b39c8..9dcda7b1a2 100644 --- a/packages/client/src/compat/legacy-provider-options.ts +++ b/packages/client/src/compat/legacy-provider-options.ts @@ -81,20 +81,36 @@ export class LegacyProviderOptions { ...message, payload: { ...message.payload, config: this.daemonConfig(message.payload.config) }, }; - case "status": - if (message.payload.status !== "daemon_config_changed") return message; + case "fetch_agent_timeline_response": return { ...message, payload: { ...message.payload, - config: this.daemonConfig(message.payload.config as MutableDaemonConfig), + agent: message.payload.agent && this.snapshot(message.payload.agent), }, - } as SessionOutboundMessage; + }; + case "status": + return this.status(message); default: return message; } } + private status( + message: Extract, + ): SessionOutboundMessage { + const { payload } = message; + if (payload.status === "agent_created" || payload.status === "agent_resumed") { + const { agent } = payload as unknown as { agent: AgentSnapshotPayload }; + return { ...message, payload: { ...payload, agent: this.snapshot(agent) } }; + } + if (payload.status === "daemon_config_changed") { + const config = payload.config as MutableDaemonConfig; + return { ...message, payload: { ...payload, config: this.daemonConfig(config) } }; + } + return message; + } + /** * Splits a create's options for a legacy daemon: the ids it lists as features for that very * config go out as `featureValues`, which is where it reads them, and the rest stay diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 3aee37c38c..ca6b615eef 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -8984,6 +8984,52 @@ describe("provider options across daemon versions", () => { return { mock, client, sentMessages }; } + test("a legacy create and a timeline read return the agent with controls over options", async () => { + const { mock, client, sentMessages } = await connect({}); + const legacyAgent = { ...agentSnapshot, features: [speedFeature] }; + const answerLast = (reply: (requestId: string) => Record) => { + const sent = sentMessages(); + const requestId = String(sent[sent.length - 1].requestId); + mock.triggerMessage(wrapSessionMessage(reply(requestId) as never)); + }; + + const creating = client.createAgent({ provider: "codex", cwd: "/repo" }); + answerLast((requestId) => ({ + type: "status", + payload: { status: "agent_created", requestId, agentId: "agent-1", agent: legacyAgent }, + })); + const created = await creating; + + const reading = client.fetchAgentTimeline("agent-1"); + answerLast((requestId) => ({ + type: "fetch_agent_timeline_response", + payload: { + requestId, + agentId: "agent-1", + agent: legacyAgent, + direction: "tail", + projection: "projected", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 1 }, + startCursor: null, + endCursor: null, + hasOlder: false, + hasNewer: false, + entries: [], + error: null, + }, + })); + const timeline = await reading; + + expect(created.options).toEqual({ service_tier: "priority" }); + expect(created.controls).toEqual([expect.objectContaining({ key: "service_tier" })]); + expect(timeline.agent?.options).toEqual({ service_tier: "priority" }); + expect(timeline.agent?.controls).toEqual([expect.objectContaining({ key: "service_tier" })]); + }); + test("a legacy daemon's features arrive as controls over options", async () => { const { mock, client } = await connect({}); const received: Array<{ controls?: unknown; options?: unknown }> = [];