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.
This commit is contained in:
Mohamed Boudra
2026-11-01 17:44:20 +01:00
parent 5cdbfef48b
commit 3383ee80bc
4 changed files with 111 additions and 8 deletions
@@ -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 });
@@ -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<void> {
@@ -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,
@@ -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[] = [];
@@ -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),
},
});
}