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 }> = [];