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.
This commit is contained in:
Mohamed Boudra
2026-11-01 23:42:51 +01:00
parent d86b6dec6e
commit c3a7547ce5
7 changed files with 127 additions and 7 deletions
@@ -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 {
@@ -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({});
});
@@ -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;
@@ -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<string, string>();
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) => {
@@ -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", [
@@ -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, { type: "status" }>,
): 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
+46
View File
@@ -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<string, unknown>) => {
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 }> = [];