fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces

- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.
This commit is contained in:
Mohamed Boudra
2026-11-01 07:53:29 +01:00
parent 6c22bc72df
commit 8e83d9346a
17 changed files with 251 additions and 114 deletions
+5
View File
@@ -158,6 +158,11 @@ export interface AgentOptionChoice {
value: string;
label: string;
description?: string;
/**
* Other stored option values that select this choice. A saved value the provider has since
* renamed still reads back as this choice.
*/
aliases?: string[];
}
interface AgentOptionControlBase {
+1
View File
@@ -365,6 +365,7 @@ export const AgentOptionChoiceSchema = z.object({
value: z.string(),
label: z.string(),
description: z.string().optional(),
aliases: z.array(z.string()).optional(),
});
export const AgentOptionToggleControlSchema = z.object({
@@ -12784,20 +12784,31 @@ describe("agent options", () => {
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
});
test("shows the option as the session resolved it while the stored intent stays", async () => {
class ResolvingSession extends OptionsSession {
displayOptions(options: ProviderOptions): ProviderOptions {
return options.service_tier === "fast" ? { ...options, service_tier: "priority" } : options;
test("the snapshot reports the configured options and resolves aliases through controls", async () => {
class AliasSession extends OptionsSession {
get controls(): AgentOptionControl[] {
return [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
],
},
];
}
}
class ResolvingClient extends OptionsClient {
class AliasClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ResolvingSession(config);
return new AliasSession(config);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const manager = new AgentManager({
clients: { codex: new ResolvingClient() },
clients: { codex: new AliasClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
@@ -12807,9 +12818,11 @@ describe("agent options", () => {
{ workspaceId: undefined },
);
const agent = manager.getAgent(created.id);
expect(agent?.config.providerOptions).toEqual({ service_tier: "fast" });
expect(agent?.effectiveOptions).toEqual({ service_tier: "priority" });
const payload = toAgentPayload(manager.getAgent(created.id)!);
expect(payload.options).toEqual({ service_tier: "fast" });
expect(payload.features).toEqual([
expect.objectContaining({ id: "service_tier", value: "priority" }),
]);
});
test("an option without a control is settable through the patch and readable in the snapshot", async () => {
@@ -1271,10 +1271,7 @@ export class AgentManager {
const session = await client.createSession(normalizedConfig);
try {
return {
controls: session.controls ?? [],
options: session.displayOptions?.(options) ?? options,
};
return { controls: session.controls ?? [], options };
} finally {
try {
await session.close();
@@ -5250,11 +5247,7 @@ export class AgentManager {
if ("session" in agent && agent.session?.controls) {
agent.controls = agent.session.controls;
}
const effective = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
agent.effectiveOptions =
"session" in agent && agent.session?.displayOptions
? agent.session.displayOptions(effective)
: effective;
agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
}
private checkAndSetAttention(agent: ManagedAgent): void {
@@ -722,12 +722,6 @@ export interface AgentSession {
* the daemon stores the patch only when this resolves.
*/
applyOptions?(options: ProviderOptions): Promise<void>;
/**
* The effective options as this session honors them, for display. The daemon keeps the
* user's stored intent; a value the provider resolves differently (a renamed tier, an
* unavailable choice) is shown as the provider resolved it.
*/
displayOptions?(options: ProviderOptions): ProviderOptions;
revertConversation?(input: { messageId: string }): Promise<void>;
revertFiles?(input: { messageId: string }): Promise<void>;
revertBoth?(input: { messageId: string }): Promise<void>;
@@ -115,8 +115,11 @@ export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(pr
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
const value = options[control.key];
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
return value;
if (typeof value === "string") {
const choice = control.choices.find(
(candidate) => candidate.value === value || candidate.aliases?.includes(value),
);
if (choice) return choice.value;
}
return control.defaultValue ?? null;
}
@@ -474,7 +474,6 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
applyOptions: inner.applyOptions?.bind(inner),
displayOptions: inner.displayOptions?.bind(inner),
revertConversation: inner.revertConversation?.bind(inner),
revertFiles: inner.revertFiles?.bind(inner),
revertBoth: inner.revertBoth?.bind(inner),
@@ -1601,29 +1601,6 @@ describe("ACPAgentSession Zed parity", () => {
});
});
test("stops auto-accepting permissions once auto_accept is removed from the options", async () => {
const session = createSessionWithConfig({ provider: "generic-acp" });
const internals = asInternals<ACPSessionInternals>(session);
internals.sessionId = "session-1";
internals.connection = {};
await session.applyOptions({ auto_accept: true });
await session.applyOptions({});
const permission = session.requestPermission({
sessionId: "session-1",
toolCall: {
toolCallId: "tool-1",
title: "Run command",
kind: "execute",
status: "pending",
},
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
} satisfies RequestPermissionRequest);
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
void permission;
});
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } });
const events: Array<{ type: string; request?: { id: string } }> = [];
@@ -1826,27 +1803,6 @@ describe("ACPAgentSession Zed parity", () => {
]);
});
test("returns a select to its session default once its option is removed", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [copilotAgentConfigOption(request.value)],
}));
const session = createCopilotSessionWithConfig();
const { internals } = prepareConfiguredOverrideSession(session, {
configOptions: [copilotAgentConfigOption("")],
connection: { setSessionConfigOption },
});
asInternals<{ recordConfigFeatureDefaults(): void }>(internals).recordConfigFeatureDefaults();
await session.applyOptions({ agent: "Probe Agent" });
await session.applyOptions({});
expect(setSessionConfigOption).toHaveBeenLastCalledWith({
sessionId: "session-1",
configId: "agent",
value: "",
});
});
test("sets Copilot custom agent through ACP config options", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [copilotAgentConfigOption("Probe Agent")],
@@ -1,3 +1,4 @@
import { featuresFromControls } from "../option-controls.js";
import pino from "pino";
import { describe, expect, test } from "vitest";
@@ -49,7 +50,7 @@ function speedControl() {
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
],
};
}
@@ -165,7 +166,7 @@ describe("Codex app-server provider controls", () => {
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
{ value: "ultrafast", label: "Ultrafast" },
],
}),
@@ -304,7 +305,7 @@ describe("Codex app-server provider controls", () => {
key: "service_tier",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
],
}),
);
@@ -522,23 +523,24 @@ describe("Codex app-server provider controls", () => {
});
});
test("shows a saved Fast preference as the tier the model resolves it to", async () => {
test("a saved Fast preference selects the model's Fast tier without rewriting the stored option", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { service_tier: "fast" },
});
// Stored intent stays "fast"; the model's Fast tier is "priority", which is what runs and what is shown.
expect(session.displayOptions?.({ service_tier: "fast" })).toEqual({
service_tier: "priority",
// The model's Fast tier is "priority". The choice names "fast" as an alias, so a client reading
// `options` + `controls` selects it, and the legacy features projection agrees.
const speed = session.controls.find((control) => control.key === "service_tier");
expect(speed).toMatchObject({
choices: expect.arrayContaining([
expect.objectContaining({ value: "priority", aliases: ["fast"] }),
]),
});
expect(session.displayOptions?.({ fast_mode: true })).toMatchObject({
service_tier: "priority",
});
expect(session.displayOptions?.({ plan_mode: true })).toEqual({ plan_mode: true });
expect(session.displayOptions?.({ service_tier: "default" })).toEqual({
service_tier: "default",
});
expect(session.displayOptions?.({ service_tier: "gone" })).toEqual({});
expect(
featuresFromControls(session.controls, { service_tier: "fast" }).find(
(feature) => feature.id === "service_tier",
),
).toMatchObject({ value: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
@@ -4673,13 +4673,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
}
displayOptions(options: ProviderOptions): ProviderOptions {
if (readCodexServiceTier(options) === null) return options;
const { service_tier: _stored, ...rest } = options;
const resolved = this.resolveServiceTier(readCodexServiceTier(options));
return resolved === null ? rest : { ...rest, service_tier: resolved };
}
private isServiceTierAvailable(tier: string | null): boolean {
if (tier === null || tier === "default") return true;
const tiers = this.currentServiceTiers();
@@ -23,7 +23,14 @@ function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[]
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
...tiers.map((tier) => ({ value: tier.id, label: tier.name })),
...tiers.map((tier) => ({
value: tier.id,
label: tier.name,
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
...(tier.id !== "fast" && tier.name.toLowerCase() === "fast"
? { aliases: ["fast"] }
: {}),
})),
],
},
];
@@ -215,7 +215,9 @@ describe("CursorACPAgentClient session start", () => {
session: {
currentModelId: string;
configOptions: SessionConfigOption[];
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>;
setSessionConfigOption?: (request: {
value: string;
}) => Promise<{ configOptions: SessionConfigOption[] }>;
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
logger = createTestLogger(),
): ACPAgentSession {
@@ -326,6 +328,52 @@ describe("CursorACPAgentClient session start", () => {
);
});
test("removing auto_accept stops answering permission requests on its own", async () => {
const session = createCursorSession({ model: "composer-2.5" });
await session.initializeNewSession();
const request = {
sessionId: "session-1",
toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" },
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
} as const;
await session.applyOptions({ auto_accept: true });
await expect(session.requestPermission({ ...request })).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-always" },
});
await session.applyOptions({});
void session.requestPermission({ ...request });
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
});
test("removing the Fast option returns the session to the value it started with", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
}));
const session = createCursorSession(
{ model: "composer-2.5" },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
setSessionConfigOption,
},
);
await session.initializeNewSession();
await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" });
// `{ fast: null }` with no config.json default reaches the provider as no key at all.
await session.applyOptions({});
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual([
"true",
"false",
]);
expect(session.controls).toContainEqual(
expect.objectContaining({ key: "fast", defaultValue: "false" }),
);
});
test("still fails session start when the provider rejects a write it should accept", async () => {
// No model switch, so the session's options came straight from session/new and the
// provider disagreeing about a feature it just advertised is a real failure.
@@ -638,6 +638,27 @@ describe("OMP agent client and session", () => {
]);
});
test("removing the fast_mode option restores the runtime's own setting", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
expect(omp.runtime().state.fastModeEnabled).toBe(false);
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.applyOptions?.({ fast_mode: true });
expect(omp.runtime().setFastModeRequests).toEqual([true]);
// `{ fast_mode: null }` with no config.json default reaches the provider as no key at all.
omp.runtime().fastModeResult = { enabled: false, active: false };
await session.applyOptions?.({});
expect(omp.runtime().setFastModeRequests).toEqual([true, false]);
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: false })]);
// A later change to an unrelated option leaves the runtime alone.
await session.applyOptions?.({});
expect(omp.runtime().setFastModeRequests).toEqual([true, false]);
});
test("shows Fast only when OMP reports its state fields", async () => {
const omp = new OmpHarness();
await omp.start();
@@ -203,6 +203,8 @@ interface OmpAgentSessionOptions {
}>;
config: AgentSessionConfig;
initialState: OmpSessionState;
/** Fast mode as the runtime reported it before any option was applied; removal restores it. */
fastModeBaseline?: boolean;
currentModeId?: string | null;
logger: Logger;
providerIdleScheduler?: OmpProviderIdleScheduler;
@@ -501,6 +503,10 @@ function isOmpSteerTransportFailure(error: unknown): boolean {
);
}
function fastModeBaselineOf(state: OmpSessionState): boolean | undefined {
return typeof state.fastModeEnabled === "boolean" ? state.fastModeEnabled : undefined;
}
function hasOmpFastMode(state: OmpSessionState): boolean {
return typeof state.fastModeEnabled === "boolean" && typeof state.fastModeActive === "boolean";
}
@@ -751,6 +757,7 @@ export class OmpAgentSession implements AgentSession {
this.restartRuntime = options.restartRuntime;
this.config = options.config;
this.state = options.initialState;
this.fastModeBaseline = options.fastModeBaseline;
this.currentModeId = options.currentModeId ?? null;
this.logger = options.logger;
this.live = options.live ?? true;
@@ -823,20 +830,28 @@ export class OmpAgentSession implements AgentSession {
}
async applyOptions(options: ProviderOptions): Promise<void> {
const value = options.fast_mode;
if (value === undefined) return;
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
if (value === this.config.providerOptions?.fast_mode) return;
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
const result = await this.runtimeSession.setFastMode(value);
this.state = {
...this.state,
fastModeEnabled: result.enabled,
fastModeActive: result.active,
};
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: result.enabled };
const requested = options.fast_mode;
if (requested !== undefined && typeof requested !== "boolean") {
throw new Error("OMP fast mode requires a boolean");
}
// An absent option means the runtime's own setting at session start.
const target = requested ?? this.fastModeBaseline;
if (target !== undefined && target !== this.state.fastModeEnabled) {
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
const result = await this.runtimeSession.setFastMode(target);
this.state = {
...this.state,
fastModeEnabled: result.enabled,
fastModeActive: result.active,
};
}
const { fast_mode: _previous, ...others } = this.config.providerOptions ?? {};
this.config.providerOptions =
requested === undefined ? others : { ...others, fast_mode: requested };
}
private readonly fastModeBaseline: boolean | undefined;
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return runProviderTurn({
prompt,
@@ -2333,12 +2348,10 @@ export class OmpAgentClient implements AgentClient {
config,
startInput.env,
);
const initialState = await this.restoreFastMode(
runtimeSession,
config,
await runtimeSession.getState(),
);
const startState = await runtimeSession.getState();
const initialState = await this.restoreFastMode(runtimeSession, config, startState);
return new OmpAgentSession({
fastModeBaseline: fastModeBaselineOf(startState),
runtimeSession,
hostTools,
restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
@@ -2410,6 +2423,7 @@ export class OmpAgentClient implements AgentClient {
};
const initialState = await this.restoreFastMode(runtimeSession, config, resumedState);
return new OmpAgentSession({
fastModeBaseline: fastModeBaselineOf(resumedState),
runtimeSession,
hostTools,
restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
@@ -180,6 +180,56 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
}
});
test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => {
const clients = createTestAgentClients();
const codex = clients.codex as AgentClient;
const applied: unknown[] = [];
// Records what reaches the provider session; behavior is the fake client's.
const recordingCodex: AgentClient = Object.create(codex, {
createSession: {
value: async (...args: Parameters<AgentClient["createSession"]>) => {
const session = await codex.createSession(...args);
const apply = session.applyOptions?.bind(session);
session.applyOptions = async (options) => {
applied.push(options);
await apply?.(options);
};
return session;
},
},
});
const daemon = await createTestPaseoDaemon({
agentClients: { ...clients, codex: recordingCodex },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Options agent",
modeId: "full-access",
model: "gpt-5.4-mini",
});
expect(agent.options?.sandbox_mode).toBeUndefined();
await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } });
expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]);
const next = await client.fetchAgent(agent.id);
expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" });
expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode");
} finally {
await client.close();
await daemon.close();
}
});
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
@@ -1348,6 +1348,35 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().agentProfiles).toBeUndefined();
});
test("reload folds a profile file written with featureValues into options, and saving it persists options", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
daemon: {
...persisted.daemon,
agentProfiles: [
{
id: "legacy",
name: "Legacy",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
},
});
await store.reload();
expect(store.get().agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
// The app reads the profile and writes the list back on its next edit.
await store.patch({ agentProfiles: store.get().agentProfiles });
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
});
test("reports a launch-controlled edit without changing live state", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
@@ -461,7 +461,16 @@ export class DaemonConfigStore {
const configChanged = !isEqualValue(this.current, next);
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
// Profiles held folded in memory may still be on disk with `featureValues`; saving them writes `options`.
const profilesStaleOnDisk =
patch.agentProfiles !== undefined &&
!isEqualValue(patch.agentProfiles, this.lastKnownPersisted.daemon?.agentProfiles);
if (
!configChanged &&
removedProviders.length === 0 &&
speechPatch === undefined &&
!profilesStaleOnDisk
) {
return this.current;
}