From 3383ee80bcc852766809e53392a668f224cbbe5c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 17:44:20 +0100 Subject: [PATCH] fix(server): opening snapshots keep the defaults the session launched with, and config-change pushes carry legacy profile values An opening resolved defaults once for the session and again at registration; an edit in between showed options the session never received. Create, resume and reload now resolve once, before the provider opens, and registration starts from that. The daemon_config_changed push skipped the featureValues projection that config replies apply, so the unchanged app lost a profile's legacy values when any setting changed. --- .../src/server/agent/agent-manager.test.ts | 25 ++++++++++ .../server/src/server/agent/agent-manager.ts | 47 ++++++++++++++++--- .../server/owned-subscriptions.e2e.test.ts | 39 +++++++++++++++ .../server/src/server/websocket-server.ts | 8 +++- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 19cf30eb07..cdc052c037 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12895,6 +12895,31 @@ describe("agent options", () => { expect(published.every((options) => options?.service_tier === "slow")).toBe(true); }); + test("a default edited while the session is opening does not change the opening snapshot", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-")); + const client = new OptionsClient(); + let tier = "default"; + client.effectiveOptions = (own) => ({ service_tier: tier, ...own }); + const open = client.createSession.bind(client); + client.createSession = async (config) => { + const session = await open(config); + // The user edits the default in config.json after the provider has launched. + tier = "fast"; + return session; + }; + const manager = new AgentManager({ + clients: { codex: client }, + registry: new AgentStorage(join(workdir, "agents"), logger), + logger, + }); + + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + + expect(agent.effectiveOptions).toEqual({ service_tier: "default" }); + }); + test("an option the session dropped when opening does not come back from a configured default", async () => { const { manager, agentId } = await createOptionsAgent(undefined, { service_tier: null }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 2c472e2ee5..5fb27d8e73 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1411,9 +1411,11 @@ export class AgentManager { ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const createOptions = this.buildCreateSessionOptions(options); + const launchedOptions = this.launchOptionsFor(client, storedConfig.providerOptions); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { + launchedOptions, labels: options.labels, initialTitle: options.initialTitle, workspaceId: options.workspaceId, @@ -1536,6 +1538,7 @@ export class AgentManager { }, ); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); + const launchedOptions = this.launchOptionsFor(client, storedConfig.providerOptions); const session = await client.resumeSession( handle, providerLaunchConfig, @@ -1544,6 +1547,7 @@ export class AgentManager { ); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); return this.registerSession(session, storedConfig, resolvedAgentId, { + launchedOptions, ...options, launchEnv, persistence: handle, @@ -1722,6 +1726,7 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); this.paseoToolPolicies.set(agentId, paseoToolPolicy); + const launchedOptions = this.launchOptionsFor(client, storedConfig.providerOptions); session = handle ? await client.resumeSession(handle, providerLaunchConfig, launchContext) : await client.createSession(providerLaunchConfig, launchContext); @@ -1740,6 +1745,7 @@ export class AgentManager { // Preserve existing labels and timeline during reload. handedToRegistration = true; return this.registerSession(session, storedConfig, agentId, { + launchedOptions, labels: existing.labels, workspaceId: existing.workspaceId, owner: existing.owner, @@ -2338,11 +2344,35 @@ export class AgentManager { }); } + /** + * Defaults merge into what the session was given; what it reports it ended up with comes + * after, so an option it dropped does not come back from a default. + */ + private openedEffectiveOptions(input: { + session: AgentSession; + openedConfig: AgentSessionConfig; + launchedOptions: ProviderOptions | undefined; + }): ProviderOptions { + const { session, openedConfig, launchedOptions } = input; + return applyProviderOptionsPatch({ + options: + launchedOptions ?? + this.effectiveOptionsFor(openedConfig.provider, openedConfig.providerOptions), + patch: session.openOptionsPatch ?? {}, + }); + } + + /** The options a client opens with: its own defaults merged under the agent's. */ + private launchOptionsFor(client: AgentClient, own: ProviderOptions | undefined): ProviderOptions { + return client.effectiveOptions?.(own) ?? own ?? {}; + } + private effectiveOptionsFor( provider: AgentProvider, own: ProviderOptions | undefined, ): ProviderOptions { - return this.clients.get(provider)?.effectiveOptions?.(own) ?? own ?? {}; + const client = this.clients.get(provider); + return client ? this.launchOptionsFor(client, own) : (own ?? {}); } async setTitle(agentId: string, title: string): Promise { @@ -3877,6 +3907,12 @@ export class AgentManager { * attention come from what was already recorded, and installing the session is not * activity in it. */ + /** + * The effective options the opening resolved before the session started. Registration + * starts from these rather than reading the defaults again, which may have been edited + * while the provider was opening. + */ + launchedOptions?: ProviderOptions; restoring?: boolean; initialTitle?: string | null; publishWhenReady?: boolean; @@ -3910,11 +3946,10 @@ export class AgentManager { resolvedAgentId, session, config, - // Defaults merge into what the session was given; what it reports it ended up with comes - // after, so an option it dropped does not come back from a default. - effectiveOptions: applyProviderOptionsPatch({ - options: this.effectiveOptionsFor(openedConfig.provider, openedConfig.providerOptions), - patch: session.openOptionsPatch ?? {}, + effectiveOptions: this.openedEffectiveOptions({ + session, + openedConfig, + launchedOptions: options?.launchedOptions, }), now, durableTimelineHasRows, diff --git a/packages/server/src/server/owned-subscriptions.e2e.test.ts b/packages/server/src/server/owned-subscriptions.e2e.test.ts index fc73e44db4..e41ccd6df5 100644 --- a/packages/server/src/server/owned-subscriptions.e2e.test.ts +++ b/packages/server/src/server/owned-subscriptions.e2e.test.ts @@ -985,6 +985,45 @@ test("config changes require their own event demand and cannot escape to an idle } }); +test("a config change push carries profiles with the legacy featureValues the unchanged app reads", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); + const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" }); + let observer: SubscriptionPeer | undefined; + try { + await admin.connect(); + await admin.patchDaemonConfig({ + agentProfiles: [ + { id: "p", name: "P", provider: "codex", options: {}, featureValues: { plan_mode: true } }, + ], + }); + observer = await SubscriptionPeer.connect(daemon.port, "config-push-session"); + await observer.request({ + type: "session.events.set_subscription.request", + requestId: "config-feed", + events: ["status.daemon_config_changed"], + }); + + await admin.patchDaemonConfig({ autoArchiveAfterMerge: true }); + + const peer = observer; + await expect + .poll(() => + peer.frames.flatMap((frame) => + frame.type === "session" && + frame.message.type === "status" && + frame.message.payload.status === "daemon_config_changed" + ? [frame.message.payload.config?.agentProfiles?.[0]?.featureValues] + : [], + ), + ) + .toEqual([{ plan_mode: true }]); + } finally { + observer?.close(); + await admin.close(); + await daemon.close(); + } +}); + test("status-shaped operation replies use actual source request provenance", async () => { const daemon = await createTestPaseoDaemon({ mcpEnabled: false }); const peers: SubscriptionPeer[] = []; diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 02a66dd64b..2c3828353e 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -34,7 +34,11 @@ import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js import type { ProjectUpdate } from "./workspace-reconciliation-service.js"; import type { ScheduleService } from "./schedule/service.js"; import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js"; -import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js"; +import { + toWireDaemonConfig, + type DaemonConfigStore, + type MutableDaemonConfig, +} from "./daemon-config-store.js"; import { type ServerInfoStatusPayload, type ScriptStatusUpdateMessage, @@ -2197,7 +2201,7 @@ export class VoiceAssistantWebSocketServer { type: "status", payload: { status: "daemon_config_changed", - config, + config: toWireDaemonConfig(config), }, }); }