refactor(server): providers report option differences once, after create, resume and apply

Providers used to write their own reports into the agent's options while a session was being
created, resumed or updated, which needed a growing set of reconciliation, baseline and
suppression rules to stay in step with the daemon.

The contract is now transactional. A provider opens or updates with the options it is given and
emits nothing while doing so; if the result differs it says so once, afterwards, as
`options_changed`. Live apply hands the provider only the keys the patch sets and the daemon stores
them onto the agent's current options when the provider accepts, so the last write wins. A change
the provider makes on its own is stored and forwarded, not undone. Removing an option drops the key
and sends nothing live. Controls carry no state; `options` is the only option state.

A legacy `agent.create.before` hook's `featureValues` edits are applied as a patch over the
returned options, so its deletions and replacements hold. The unreleased `aliases` field on option
choices is gone; a pre-v0.10 Codex Fast preference is reported once as the model's tier id.
This commit is contained in:
Mohamed Boudra
2026-11-04 04:24:49 +01:00
parent 8fe018a00f
commit b240d628a3
25 changed files with 700 additions and 280 deletions
@@ -12939,9 +12939,11 @@ describe("agent options", () => {
class OptionsSession extends TestAgentSession {
readonly applied: ProviderOptions[] = [];
rejectWith: Error | null = null;
applyGate: Promise<void> | null = null;
async applyOptions(options: ProviderOptions): Promise<void> {
if (this.rejectWith) throw this.rejectWith;
await this.applyGate;
this.applied.push(options);
}
}
@@ -12982,12 +12984,12 @@ describe("agent options", () => {
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true });
});
test("setAgentOptions on a running agent replaces top-level keys and applies the effective options", async () => {
test("setAgentOptions hands the provider only the keys the patch sets", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent({ fast_mode: true });
await manager.setAgentOptions(agentId, { service_tier: "fast" });
expect(client.session?.applied).toEqual([{ service_tier: "fast", fast_mode: true }]);
expect(client.session?.applied).toEqual([{ service_tier: "fast" }]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ fast_mode: true, service_tier: "fast" });
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", fast_mode: true });
@@ -12998,66 +13000,23 @@ describe("agent options", () => {
});
});
test("setAgentOptions removes a key with null so the config.json default applies again", async () => {
test("setAgentOptions removes a key with null and sends the provider nothing", async () => {
const { manager, client, agentId } = await createOptionsAgent({ service_tier: "fast" });
await manager.setAgentOptions(agentId, { service_tier: null });
expect(client.session?.applied).toEqual([{ service_tier: "default" }]);
expect(client.session?.applied).toEqual([]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toBeUndefined();
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
});
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 AliasClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new AliasSession(config);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const manager = new AgentManager({
clients: { codex: new AliasClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { service_tier: "fast" } },
undefined,
{ workspaceId: undefined },
);
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 () => {
const { manager, client, storage, agentId } = await createOptionsAgent();
await manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
expect(client.session?.applied).toEqual([
{ service_tier: "default", sandbox_mode: "read-only" },
]);
expect(client.session?.applied).toEqual([{ sandbox_mode: "read-only" }]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ sandbox_mode: "read-only" });
expect(toAgentPayload(agent!).options).toEqual({
@@ -13070,6 +13029,41 @@ describe("agent options", () => {
});
});
test("concurrent patches both land: each is applied to the options as they are when its provider call resolves", async () => {
const { manager, client, agentId } = await createOptionsAgent();
let release!: () => void;
client.session!.applyGate = new Promise<void>((resolve) => {
release = resolve;
});
const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
client.session!.applyGate = null;
await manager.setAgentOptions(agentId, { fast_mode: true });
release();
await slow;
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
sandbox_mode: "read-only",
fast_mode: true,
});
});
test("a stored agent that is not loaded still reports its effective options", async () => {
const { manager, agentId } = await createOptionsAgent({ fast_mode: true });
await manager.closeAgent(agentId);
const states: ManagedAgent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === agentId) states.push(event.agent);
},
{ replayState: false },
);
await manager.markAgentUnread(agentId);
expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
});
test("a provider that rejects an option leaves the stored options unchanged", async () => {
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false });
client.session!.rejectWith = new Error("fast mode unavailable");
@@ -13176,4 +13170,52 @@ describe("agent options", () => {
plan_mode: false,
});
});
describe.each([
{
name: "deletes a key",
edit: (values: Record<string, unknown> | undefined) => {
const { fast_mode: _removed, ...rest } = values ?? {};
return { featureValues: rest };
},
expected: { plan_mode: true },
},
{
name: "empties featureValues",
edit: () => ({ featureValues: {} }),
expected: {},
},
{
name: "drops featureValues",
edit: () => ({ featureValues: undefined }),
expected: {},
},
])("agent.create.before hook that $name", ({ edit, expected }) => {
test("removes those options from the new agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const manager = new AgentManager({
clients: { codex: new OptionsClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
pluginLifecycle: {
emit: () => {},
before: async (name, request) =>
name === "agent.create.before"
? {
...request,
config: { ...request.config, ...edit(request.config.featureValues) },
}
: request,
},
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { fast_mode: true, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual(expected);
});
});
});
@@ -2191,6 +2191,10 @@ export class AgentManager {
updatedAt,
availableModes: [],
controls: record.controls,
effectiveOptions: this.effectiveOptionsFor(
record.provider,
record.config?.providerOptions ?? undefined,
),
currentModeId: record.lastModeId ?? null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
@@ -2285,23 +2289,35 @@ export class AgentManager {
}
/**
* Patches the agent's own options and hands the provider the resulting effective options. The
* provider applies what it can live and reads the rest at next open; a provider that rejects a
* value leaves the stored options unchanged.
* Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider
* gets only the keys the patch sets, each resolved against the `config.json` defaults; a
* removal sends it nothing and it keeps its current setting until the session is next opened.
* The patch is stored once the provider accepts it, over the options as they are at that
* moment, so concurrent writes to one agent resolve by arrival order.
*/
async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void> {
const agent = this.requireAgent(agentId);
const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch);
if (agent.session.applyOptions) {
await agent.session.applyOptions(this.effectiveOptionsFor(agent.provider, ownOptions));
await this.drainSessionEvents(agentId);
const set = Object.fromEntries(
Object.entries(patch).filter(([, value]) => value !== null && value !== undefined),
);
if (agent.session.applyOptions && Object.keys(set).length > 0) {
const resolved = this.effectiveOptionsFor(agent.provider, set);
await agent.session.applyOptions(
Object.fromEntries(Object.keys(set).map((key) => [key, resolved[key]])),
);
}
agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined;
this.storeOptionsPatch(agent, patch);
// Differences the provider reports after applying arrive now and are stored as reported.
await this.drainSessionEvents(agentId);
this.touchUpdatedAt(agent);
this.emitState(agent);
}
private storeOptionsPatch(agent: ManagedAgent, patch: ProviderOptionsPatch): void {
const own = applyProviderOptionsPatch(agent.config.providerOptions, patch);
agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined;
}
private effectiveOptionsFor(
provider: AgentProvider,
own: ProviderOptions | undefined,
@@ -4709,8 +4725,7 @@ export class AgentManager {
flags: StreamEventFlags;
}): void {
const { agent, patch, flags } = params;
const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch);
agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined;
this.storeOptionsPatch(agent, patch);
flags.shouldDispatchEvent = false;
this.emitState(agent);
}
@@ -429,7 +429,13 @@ export type AgentStreamEvent =
provider: AgentProvider;
thinkingOptionId: string | null;
}
/** The provider changed options on its own; `null` removes the agent's own value. */
/**
* The provider changed options on its own: after `createSession`/`resumeSession` or
* `applyOptions` it ended up different from what it was given, or later it changed a setting
* itself. The daemon stores the patch and forwards it; it does not undo it. `null` removes the
* agent's own value. Emit it once, after the operation resolves and the daemon is subscribed
* (sessions typically emit the open report from `subscribe`), never while the operation runs.
*/
| { type: "options_changed"; provider: AgentProvider; patch: Record<string, unknown> }
| {
type: "turn_failed";
@@ -717,9 +723,12 @@ export interface AgentSession {
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
/**
* The agent's effective options changed (defaults merged with the agent's own, after the
* patch). Apply what can change live and read the rest at next open. Throw to reject a value;
* the daemon stores the patch only when this resolves.
* Apply the options a patch just set. `options` holds only those keys, resolved against the
* `config.json` defaults, never the agent's whole record. Leave every other setting alone.
* Removing an option sends nothing: the session keeps its current setting until the next open.
* Throw to reject a value; the daemon stores the patch only when this resolves, so the call is
* all-or-nothing. Emit nothing from here. If the result differs from what was asked for,
* report it afterwards as `options_changed`.
*/
applyOptions?(options: ProviderOptions): Promise<void>;
revertConversation?(input: { messageId: string }): Promise<void>;
@@ -771,11 +780,17 @@ export interface ResolveAgentDefaultModeInput {
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
/**
* Create the agent with `config.providerOptions` (defaults already merged in). Report nothing
* about options while creating. If the agent ended up different from the options, say so once,
* afterwards, as `options_changed`.
*/
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession>;
/** Same contract as `createSession`: open with the options, report any difference once, afterwards. */
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
@@ -252,12 +252,12 @@ describe("AgentStorage", () => {
expect(persisted?.config).not.toHaveProperty("featureValues");
expect(persisted).not.toHaveProperty("features");
expect(persisted?.controls).toEqual([
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
{ type: "toggle", key: "fast_mode", label: "Fast mode", defaultValue: true },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
defaultValue: "fast",
choices: [
{ value: "default", label: "Normal" },
{ value: "fast", label: "Fast" },
@@ -54,8 +54,9 @@ export function foldFeatureValues<
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* A plugin written against the previous SDK reads and edits `config.featureValues`, so a
* hook sees the config's options there. Keys the hook changed come back as options and
* override what the config already held; keys it left alone do not.
* hook sees a copy of the config's options there. What the hook did to that copy (set,
* replaced or deleted a key, or dropped the field) is applied as a patch over the options it
* returned; keys it left alone change nothing.
*/
export function exposeFeatureValuesToHook<
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
@@ -68,13 +69,21 @@ export function foldHookFeatureValues<
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
>(returned: T, sent: T): Omit<T, "featureValues"> {
const { featureValues, ...rest } = returned;
if (!featureValues) return rest;
const before = sent.featureValues ?? {};
const edits: Record<string, unknown> = {};
for (const [key, value] of Object.entries(featureValues)) {
if (!isDeepStrictEqual(value, before[key])) edits[key] = value;
const before = sent.featureValues;
if (!before) {
// No copy was sent, so everything the hook put in `featureValues` is new.
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } };
}
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions, ...edits } };
const after = featureValues ?? {};
const patch: ProviderOptionsPatch = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (isDeepStrictEqual(after[key], before[key])) continue;
patch[key] = key in after ? after[key] : null;
}
return {
...rest,
providerOptions: applyProviderOptionsPatch(rest.providerOptions ?? undefined, patch),
};
}
interface ProfileOptionFields {
@@ -116,9 +125,7 @@ export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(pr
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
const value = options[control.key];
if (typeof value === "string") {
const choice = control.choices.find(
(candidate) => candidate.value === value || candidate.aliases?.includes(value),
);
const choice = control.choices.find((candidate) => candidate.value === value);
if (choice) return choice.value;
}
return control.defaultValue ?? null;
@@ -175,6 +182,10 @@ export function featuresFromControls(
return (controls ?? []).map((control) => featureFromControl(control, effective));
}
/**
* A recorded feature's value is the control's unset value: it is what the agent showed before
* any option was stored, and an explicit effective option still wins over it.
*/
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
const descriptor = {
key: feature.id,
@@ -182,14 +193,15 @@ function controlFromFeature(feature: AgentFeature): AgentOptionControl {
...describe(feature),
};
if (feature.type === "toggle") {
return { type: "toggle", ...descriptor };
return { type: "toggle", ...descriptor, defaultValue: feature.value };
}
const defaultChoice = feature.options.find((option) => option.isDefault);
const unsetValue = feature.value ?? defaultChoice?.id;
return {
type: "select",
...descriptor,
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
...(defaultChoice ? { defaultValue: defaultChoice.id } : {}),
...(unsetValue != null ? { defaultValue: unsetValue } : {}),
choices: feature.options.map((option) => ({
value: option.id,
label: option.label,
@@ -999,6 +999,78 @@ describe("PluginAgentClientRegistry", () => {
await agent.session?.close();
});
test("reports a setting the plugin opened differently once, and applies only the options it is given", async () => {
const settings = (voice: string) => [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: false },
];
const state = { voice: "direct" };
const harness = createProviderHarness({
async handleInput(input, emit) {
if (input.type === "session.open") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: CAPABILITIES,
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
emit({
type: "session.config",
sessionId: input.sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: settings(state.voice),
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
return false;
},
});
const logger = createTestLogger();
const plugins = new PluginAgentClientRegistry(logger);
plugins.replace([harness.registration]);
const client = plugins.clients()["plugin-direct"]!;
const session = await client.createSession({
provider: "plugin-direct",
cwd: tmpdir(),
providerOptions: { voice: "other", loud: false },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
session.subscribe((event) => events.push(event));
// The plugin opened on "direct" although "other" was asked for: said once, on subscribe.
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { voice: "direct" } },
]);
await session.applyOptions?.({ loud: true });
const configure = harness.inputs.findLast((input) => input.type === "session.configure");
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
expect(Object.keys((configure as { changes: { settings: object } }).changes.settings)).toEqual([
"loud",
]);
await session.close();
});
test.each([
{ options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
{ params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
@@ -1516,7 +1588,6 @@ describe("PluginAgentClientRegistry", () => {
type: "select",
key: "voice",
label: "Voice",
defaultValue: "direct",
choices: [{ value: "direct", label: "Direct" }],
},
]);
@@ -1,4 +1,5 @@
import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { PluginSessionHistory } from "./plugin-session-history.js";
import { resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
@@ -1133,6 +1134,7 @@ class PluginAgentClient implements AgentClient {
});
},
bind,
config.settings,
);
bind(bridge);
return session;
@@ -1201,6 +1203,9 @@ class PluginAgentSession implements AgentSession {
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the settings it was given; reported at first subscribe. */
private openOptionReport: Record<string, JsonValue> = {};
private knownSettings: Record<string, JsonValue> = {};
private currentTurnId: string | null = null;
private closed = false;
private replay: {
@@ -1221,8 +1226,13 @@ class PluginAgentSession implements AgentSession {
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
givenSettings: Readonly<Record<string, JsonValue>>,
) {
this.subagentIdsBySession.set(bridge.id, null);
this.openOptionReport = Object.fromEntries(
Object.entries(this.settingDifferences(givenSettings)).filter(([id]) => id in givenSettings),
);
this.knownSettings = this.currentSettings();
for (const event of bridge.history) this.accept(event, false);
// Opening notices arrive before the manager subscribes to the session.
// Commit them on registration and replay the same timestamps from history.
@@ -1256,14 +1266,12 @@ class PluginAgentSession implements AgentSession {
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
defaultValue: setting.value,
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
...(setting.value !== null ? { defaultValue: setting.value } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
@@ -1329,6 +1337,10 @@ class PluginAgentSession implements AgentSession {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.listeners.add(callback);
if (Object.keys(this.openOptionReport).length > 0) {
callback({ type: "options_changed", provider: this.provider, patch: this.openOptionReport });
this.openOptionReport = {};
}
return () => this.listeners.delete(callback);
}
@@ -1430,13 +1442,12 @@ class PluginAgentSession implements AgentSession {
const settings: Record<string, JsonValue> = {};
for (const setting of this.bridge.config.settings) {
const value = options[setting.id];
if (value !== undefined && value !== setting.value) {
settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`);
}
}
if (Object.keys(settings).length > 0) {
await this.bridge.configure({ settings });
if (value === undefined) continue;
settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`);
}
if (Object.keys(settings).length === 0) return;
Object.assign(this.knownSettings, settings);
await this.bridge.configure({ settings });
}
async revertConversation(input: { messageId: string }): Promise<void> {
@@ -1618,10 +1629,35 @@ class PluginAgentSession implements AgentSession {
};
}
private currentSettings(): Record<string, JsonValue> {
return Object.fromEntries(
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
);
}
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
private settingDifferences(
known: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentSettings())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
}
return patch;
}
private reportSettingChanges(): AgentStreamEvent[] {
const patch = this.settingDifferences(this.knownSettings);
if (Object.keys(patch).length === 0) return [];
this.knownSettings = this.currentSettings();
return [{ type: "options_changed", provider: this.provider, patch }];
}
private translateConfig(
event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] {
return [
...this.reportSettingChanges(),
{
type: "mode_changed",
provider: this.provider,
@@ -1470,6 +1470,22 @@ describe("ProviderSnapshotManager public surface", () => {
});
expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
// A parent whose Auto Accept comes from the provider's config.json default has no own
// option; it is classified on its effective options.
const inheriting = await manager.resolveCreateConfig({
cwd: "/tmp/project",
provider: "opencode",
requestedMode: "base",
providerOptions: undefined,
parent: {
...parent,
config: { provider: "opencode", cwd: "/tmp/project" },
effectiveOptions: { auto_accept: true },
},
unattended: false,
});
expect(inheriting).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
} finally {
manager.destroy();
}
@@ -744,7 +744,12 @@ export class ProviderSnapshotManager {
modeId: parent.currentModeId,
isUnattended: definition.isCreateConfigUnattended({
modeId: parent.currentModeId,
config: parent.config,
// Provider defaults from `config.json` apply to the parent too, so classify on the
// effective options while the stored overrides stay as they are.
config: {
...parent.config,
providerOptions: parent.effectiveOptions ?? parent.config.providerOptions,
},
controls: parent.controls,
availableModes: parent.availableModes ?? definition.modes ?? [],
}),
@@ -1792,7 +1792,6 @@ describe("ACPAgentSession Zed parity", () => {
description: "Use a Copilot custom agent profile",
tooltip: "Select Copilot agent",
icon: undefined,
defaultValue: "",
choices: [
{ value: "", label: "Default", description: undefined },
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
@@ -1820,7 +1819,57 @@ describe("ACPAgentSession Zed parity", () => {
});
expect(session.controls).toEqual([
expect.objectContaining({ key: "auto_accept" }),
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
expect.objectContaining({ key: "agent" }),
]);
});
test("reports once, at subscribe, a select the provider opened differently from the options", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [copilotAgentConfigOption("")],
}));
const session = createCopilotSessionWithConfig(null, { agent: "Probe Agent" });
const { internals } = prepareConfiguredOverrideSession(session, {
configOptions: [copilotAgentConfigOption("")],
connection: { setSessionConfigOption },
});
const emitted: AgentStreamEvent[] = [];
session.subscribe((event) => emitted.push(event));
await internals.applyConfiguredOverrides();
expect(emitted.filter((event) => event.type === "options_changed")).toEqual([]);
const first: AgentStreamEvent[] = [];
session.subscribe((event) => first.push(event));
const second: AgentStreamEvent[] = [];
session.subscribe((event) => second.push(event));
expect(first.filter((event) => event.type === "options_changed")).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "" } },
]);
expect(second.filter((event) => event.type === "options_changed")).toEqual([]);
});
test("forwards a select the provider changes on its own, and applying a patch emits nothing", async () => {
const setSessionConfigOption = vi.fn(async () => ({
configOptions: [copilotAgentConfigOption("Probe Agent")],
}));
const session = createCopilotSessionWithConfig();
const { internals } = prepareConfiguredOverrideSession(session, {
configOptions: [copilotAgentConfigOption("")],
connection: { setSessionConfigOption },
});
await internals.applyConfiguredOverrides();
const emitted: AgentStreamEvent[] = [];
session.subscribe((event) => emitted.push(event));
await session.applyOptions({ agent: "Probe Agent" });
expect(emitted.filter((event) => event.type === "options_changed")).toEqual([]);
asInternals<ACPSessionInternals>(session).translateSessionUpdate({
sessionUpdate: "config_option_update",
configOptions: [copilotAgentConfigOption("")],
});
expect(emitted.filter((event) => event.type === "options_changed")).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "" } },
]);
});
@@ -1843,7 +1892,7 @@ describe("ACPAgentSession Zed parity", () => {
});
expect(session.controls).toEqual([
expect.objectContaining({ key: "auto_accept" }),
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
expect.objectContaining({ key: "agent" }),
]);
});
});
@@ -2200,7 +2249,6 @@ describe("ACPAgentClient config features", () => {
expect.objectContaining({
type: "select",
key: "agent",
defaultValue: "Probe Agent",
choices: [
expect.objectContaining({ value: "", label: "Default" }),
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
@@ -881,8 +881,6 @@ export function deriveControlsFromACP(
description: featureOption.description,
tooltip: featureOption.tooltip,
icon: featureOption.icon,
// The agent reports its own current value; an unset option stands for it.
...(option.currentValue != null ? { defaultValue: option.currentValue } : {}),
choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({
value: choice.id,
label: choice.label,
@@ -1771,7 +1769,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private currentTitle: string | null = null;
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
private readonly configFeatureDefaults = new Map<string, string>();
/** The value of each select as Paseo last knew it; a different value is the provider's change. */
private readonly knownFeatureValues = new Map<string, string>();
/** How the opened session differs from the options it was given; reported at first subscribe. */
private openOptionReport: Record<string, unknown> = {};
private cachedCommands: AgentSlashCommand[] = [];
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
@@ -2067,6 +2068,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
sessionId: this.sessionId,
});
}
if (Object.keys(this.openOptionReport).length > 0) {
callback({ type: "options_changed", provider: this.provider, patch: this.openOptionReport });
this.openOptionReport = {};
}
return () => {
this.subscribers.delete(callback);
};
@@ -2254,6 +2259,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId,
label: "mode",
});
this.reportOptionChanges();
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
@@ -2379,6 +2385,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modelId,
label: "model",
});
this.reportOptionChanges();
this.pushEvent({
type: "model_changed",
provider: this.provider,
@@ -2430,6 +2437,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: thinkingOptionId,
label: "thought-level",
});
this.reportOptionChanges();
this.pushEvent({
type: "thinking_option_changed",
provider: this.provider,
@@ -2437,39 +2445,50 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
}
/**
* The value a select reported before Paseo wrote to it. An option removed from the agent's
* options returns the select to this value.
*/
private recordConfigFeatureDefaults(): void {
/** Applies only the options the patch sets. Differences are reported after this resolves. */
async applyOptions(options: ProviderOptions): Promise<void> {
for (const [id, value] of Object.entries(options)) {
const featureOption = this.configFeatureOptions.find((candidate) => candidate.id === id);
if (!featureOption) continue;
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (!option || value !== option.currentValue) {
await this.setConfigFeature(id, value);
}
this.knownFeatureValues.set(id, String(value));
this.config.providerOptions = { ...this.config.providerOptions, [id]: value };
}
// Auto Accept is Paseo's own setting, so it changes only once every write has been accepted.
if (ACP_AUTO_ACCEPT_FEATURE_ID in options) {
this.config.providerOptions = {
...this.config.providerOptions,
[ACP_AUTO_ACCEPT_FEATURE_ID]: options[ACP_AUTO_ACCEPT_FEATURE_ID] === true,
};
}
setImmediate(() => this.reportOptionChanges());
}
/** Reports selects the provider changed on its own since Paseo last knew their values. */
private reportOptionChanges(): void {
const patch: Record<string, unknown> = {};
for (const featureOption of this.configFeatureOptions) {
if (this.configFeatureDefaults.has(featureOption.id)) continue;
const current = findSelectConfigFeatureOption(
this.configOptions,
featureOption,
)?.currentValue;
if (current != null) this.configFeatureDefaults.set(featureOption.id, current);
}
}
async applyOptions(options: ProviderOptions): Promise<void> {
await this.setConfigFeature(
ACP_AUTO_ACCEPT_FEATURE_ID,
options[ACP_AUTO_ACCEPT_FEATURE_ID] === true,
);
for (const featureOption of this.configFeatureOptions) {
const explicit = Object.prototype.hasOwnProperty.call(options, featureOption.id);
const value = explicit
? options[featureOption.id]
: this.configFeatureDefaults.get(featureOption.id);
if (value === undefined) continue;
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (option && value === option.currentValue) continue;
if (!explicit && (!option || !findSelectConfigChoice({ option, value: String(value) }))) {
// A default recorded under a previous model may not exist on the current one.
continue;
const known = this.knownFeatureValues.get(featureOption.id);
if (current == null || known === current) continue;
this.knownFeatureValues.set(featureOption.id, current);
if (known === undefined) continue;
patch[featureOption.id] = current;
if (this.config.providerOptions && featureOption.id in this.config.providerOptions) {
this.config.providerOptions = {
...this.config.providerOptions,
[featureOption.id]: current,
};
}
await this.setConfigFeature(featureOption.id, value);
}
if (Object.keys(patch).length > 0) {
this.pushEvent({ type: "options_changed", provider: this.provider, patch });
}
}
@@ -2512,14 +2531,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
value: requestedValue,
}),
);
const currentValue = this.applyConfigOptionResponse({
this.applyConfigOptionResponse({
response,
configId: option.id,
category: featureOption.category,
requestedValue,
label: featureOption.label,
});
this.config.providerOptions = { ...this.config.providerOptions, [featureId]: currentValue };
}
private applyConfigOptionResponse({
@@ -3043,7 +3061,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
: response;
this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []);
this.recordConfigFeatureDefaults();
const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions);
this.availableModes = modeInfo.modes;
@@ -3115,6 +3132,26 @@ export class ACPAgentSession implements AgentSession, ACPClient {
);
}
}
this.recordOpenedFeatureValues(configuredFeatureValues);
}
/** Remembers what each select ended at, and what differs from the options the session was given. */
private recordOpenedFeatureValues(given: Record<string, unknown>): void {
for (const featureOption of this.configFeatureOptions) {
const current = findSelectConfigFeatureOption(
this.configOptions,
featureOption,
)?.currentValue;
if (current == null) continue;
this.knownFeatureValues.set(featureOption.id, current);
if (featureOption.id in given && given[featureOption.id] !== current) {
this.openOptionReport[featureOption.id] = current;
this.config.providerOptions = {
...this.config.providerOptions,
[featureOption.id]: current,
};
}
}
}
/**
@@ -3341,6 +3378,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentMode = nextMode ?? this.currentMode;
this.currentModel = nextModel ?? this.currentModel;
this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId;
this.reportOptionChanges();
const events: AgentStreamEvent[] = [];
if (nextMode !== null) {
@@ -1695,6 +1695,29 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("a patch that does not set fast_mode leaves Fast on", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
});
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
await session.applyOptions?.({ fast_mode: true });
queryMock.applyFlagSettings.mockClear();
// Removing fast_mode, or setting another option, sends only the keys set: Fast stays on.
await session.applyOptions?.({ allowedTools: ["Read"] });
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
await session.close();
});
test("retries a fast mode change the runtime rejected", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -2806,7 +2806,10 @@ class ClaudeAgentSession implements AgentSession {
async applyOptions(options: ProviderOptions): Promise<void> {
const providerOptions =
validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {};
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
...this.config.providerOptions,
...options,
}) ?? {};
if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) {
throw new Error(
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
@@ -1,4 +1,3 @@
import { featuresFromControls } from "../option-controls.js";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -8,7 +7,12 @@ import { describe, expect, test, vi } from "vitest";
import { AgentManager } from "../agent-manager.js";
import { toAgentPayload } from "../agent-projections.js";
import { AgentStorage } from "../agent-storage.js";
import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import type {
AgentClient,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "../agent-sdk-types.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
@@ -44,7 +48,7 @@ const TEST_SPEED_MODELS = [
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
}));
function speedControl(defaultValue = "default") {
function speedControl() {
return {
type: "select",
key: "service_tier",
@@ -53,10 +57,10 @@ function speedControl(defaultValue = "default") {
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
defaultValue,
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
{ value: "priority", label: "Fast" },
],
};
}
@@ -172,7 +176,7 @@ describe("Codex app-server provider controls", () => {
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
{ value: "priority", label: "Fast" },
{ value: "ultrafast", label: "Ultrafast" },
],
}),
@@ -311,7 +315,7 @@ describe("Codex app-server provider controls", () => {
key: "service_tier",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
{ value: "priority", label: "Fast" },
],
}),
);
@@ -500,8 +504,19 @@ describe("Codex app-server provider controls", () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { fast_mode: true, plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
session.subscribe((event) => events.push(event));
expect(session.controls).toEqual([speedControl("priority"), PLAN_CONTROL]);
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
// A saved pre-v0.10 Fast preference opens as the model's Fast tier and says so once.
expect(events.filter((event) => event.type === "options_changed")).toEqual([
{
type: "options_changed",
provider: "codex",
patch: { service_tier: "priority", fast_mode: null },
},
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -512,6 +527,20 @@ describe("Codex app-server provider controls", () => {
});
});
test("a patch that sets another option keeps the running sandbox and an explicit speed", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { sandbox_mode: "read-only", service_tier: "default", fast_mode: true },
});
await session.applyOptions?.({ plan_mode: true });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
sandboxPolicy: expect.objectContaining({ type: "readOnly" }),
});
});
test("applyOptions accepts an option that has no control", async () => {
const { session } = await createConnectedSession();
@@ -529,24 +558,20 @@ describe("Codex app-server provider controls", () => {
});
});
test("a saved Fast preference selects the model's Fast tier without rewriting the stored option", async () => {
test("a saved service_tier: fast runs as the model's Fast tier and is reported as that tier", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { service_tier: "fast" },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
// 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(
featuresFromControls(session.controls, { service_tier: "fast" }).find(
(feature) => feature.id === "service_tier",
),
).toMatchObject({ value: "priority" });
expect(events.filter((event) => event.type === "options_changed")).toEqual([
{
type: "options_changed",
provider: "codex",
patch: { service_tier: "priority", fast_mode: null },
},
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
@@ -658,57 +683,54 @@ describe("Codex app-server provider controls", () => {
return { manager, create, resume, shownSpeed, runTurn };
}
test("is stored as set, shows Fast, and sends the Fast tier", async () => {
const { create, shownSpeed, runTurn } = createLegacyFastHarness();
const FAST = { selected: "priority", feature: "priority" };
test("opens as the model's Fast tier and is stored as that tier", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toEqual({
options: { fast_mode: true },
own: { fast_mode: true },
selected: "priority",
feature: "priority",
expect(shownSpeed(agentId)).toMatchObject({
options: { service_tier: "priority" },
own: { service_tier: "priority" },
...FAST,
});
await runTurn(agentId, "priority");
});
test("shows Fast after a stored record is resumed", async () => {
const { resume, shownSpeed, runTurn } = createLegacyFastHarness();
test("is shown as Fast after a stored record is resumed", async () => {
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await resume({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toEqual({
options: { fast_mode: true },
own: { fast_mode: true },
selected: "priority",
feature: "priority",
});
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("from config.json defaults shows Fast and leaves the agent's own options empty", async () => {
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
test("from config.json defaults runs Fast and shows it through the agent's own tier", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
const agentId = await create();
await manager.flush();
expect(shownSpeed(agentId)).toEqual({
options: { fast_mode: true },
own: undefined,
selected: "priority",
feature: "priority",
});
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("set live shows Fast at once, and an explicit service_tier then wins", async () => {
test("set live runs Fast at once, and a later service_tier replaces it", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create();
expect(shownSpeed(agentId).selected).toBe("default");
await manager.setAgentOptions(agentId, { fast_mode: true });
expect(shownSpeed(agentId)).toMatchObject({ selected: "priority", feature: "priority" });
// The provider names the tier it resolved on the next tick, after the patch is stored.
await new Promise((resolve) => setImmediate(resolve));
await manager.flush();
expect(shownSpeed(agentId)).toMatchObject(FAST);
await runTurn(agentId, "priority");
await manager.setAgentOptions(agentId, { service_tier: "default" });
expect(shownSpeed(agentId)).toMatchObject({
own: { fast_mode: true, service_tier: "default" },
own: { service_tier: "default" },
selected: "default",
feature: "default",
});
@@ -3498,6 +3498,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private threadRollbackAvailable = true;
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
private startupEvents: AgentStreamEvent[] | null = null;
/** How the opened session differs from the options it was given; reported at first subscribe. */
private openOptionReport: Record<string, unknown> = {};
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
@@ -3642,7 +3644,6 @@ export class CodexAppServerAgentSession implements AgentSession {
get controls(): AgentOptionControl[] {
return buildCodexControls({
serviceTiers: this.currentServiceTiers(),
options: this.providerOptions,
planModeAvailable: this.hasPlanCollaborationMode(),
});
}
@@ -3702,6 +3703,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
this.normalizeLegacySpeed();
await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes();
await this.loadSkills();
@@ -3914,6 +3916,22 @@ export class CodexAppServerAgentSession implements AgentSession {
return selectedModel?.serviceTiers ?? [];
}
/**
* COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast
* preferences have migrated. A saved `fast_mode: true` or `service_tier: "fast"` runs as the
* model's Fast tier; the session says so once, at subscribe, so the options and the control agree.
*/
private normalizeLegacySpeed(): void {
const requested = this.requestedServiceTier;
const resolved = this.resolveServiceTier(requested);
const legacy = this.providerOptions.fast_mode !== undefined || requested !== resolved;
if (!legacy || resolved === null) return;
const { fast_mode: _legacy, ...rest } = this.providerOptions;
this.providerOptions = { ...rest, service_tier: resolved };
this.requestedServiceTier = resolved;
this.openOptionReport = { service_tier: resolved, fast_mode: null };
}
private get serviceTier(): string | null {
return this.resolveServiceTier(this.requestedServiceTier);
}
@@ -4633,6 +4651,10 @@ export class CodexAppServerAgentSession implements AgentSession {
const startupEvents = this.startupEvents;
this.startupEvents = null;
for (const event of startupEvents ?? []) this.notifySubscribers(event);
if (Object.keys(this.openOptionReport).length > 0) {
callback({ type: "options_changed", provider: CODEX_PROVIDER, patch: this.openOptionReport });
this.openOptionReport = {};
}
return () => {
this.subscribers.delete(callback);
};
@@ -4713,21 +4735,50 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
/** Applies only the options the patch sets; the rest of the running options stay as they are. */
async applyOptions(options: ProviderOptions): Promise<void> {
const next = validateProviderOptions("codex", CodexProviderOptionsSchema, options) ?? {};
const requestedTier = readCodexServiceTier(next);
if (
requestedTier !== this.requestedServiceTier &&
!this.isServiceTierAvailable(requestedTier)
) {
throw new Error(
`Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`,
);
const { fast_mode: _legacyFast, ...running } = this.providerOptions;
const next =
validateProviderOptions("codex", CodexProviderOptionsSchema, {
...running,
...options,
}) ?? {};
let requestedTier = this.requestedServiceTier;
if ("service_tier" in options || "fast_mode" in options) {
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
requestedTier = this.requestedTierFrom(options);
if (
requestedTier !== this.requestedServiceTier &&
!this.isServiceTierAvailable(requestedTier)
) {
throw new Error(
`Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`,
);
}
}
this.providerOptions = next;
this.providerOptions = { ...next, ...(requestedTier ? { service_tier: requestedTier } : {}) };
this.requestedServiceTier = requestedTier;
this.setPlanMode(next.plan_mode === true);
if ("plan_mode" in options) this.setPlanMode(options.plan_mode === true);
this.cachedRuntimeInfo = null;
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
if ("fast_mode" in options && !("service_tier" in options)) {
setImmediate(() => this.reportSpeed({ fast_mode: null }));
}
}
private requestedTierFrom(options: ProviderOptions): string | null {
if ("service_tier" in options) return readCodexServiceTier(options);
return options.fast_mode ? "fast" : "default";
}
private reportSpeed(extra: Record<string, unknown>): void {
const tier = this.serviceTier;
if (tier === null) return;
this.emitEvent({
type: "options_changed",
provider: CODEX_PROVIDER,
patch: { service_tier: tier, ...extra },
});
}
private isServiceTierAvailable(tier: string | null): boolean {
@@ -9,28 +9,8 @@ export const CodexServiceTierSchema = z.object({
});
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
// Which speed applies, for the runtime and for this control: an explicit `service_tier` wins;
// the pre-v0.10 `fast_mode: true` boolean counts only while `service_tier` is absent
// (`readCodexServiceTier`). The control shows it as its default, because a client selects the
// option's value when `service_tier` is set and the default when it is not.
function buildCodexSpeedControl(
tiers: CodexServiceTier[],
options: Record<string, unknown> | undefined,
): AgentOptionControl[] {
function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[] {
if (tiers.length === 0) return [];
const choices = [
{ value: "default", label: "Normal" },
...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"] } : {}),
})),
];
const implied = options?.service_tier === undefined ? readCodexServiceTier(options) : null;
const defaultChoice = choices.find(
(choice) => choice.value === implied || choice.aliases?.includes(implied ?? ""),
);
return [
{
type: "select",
@@ -40,8 +20,11 @@ function buildCodexSpeedControl(
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
defaultValue: defaultChoice?.value ?? "default",
choices,
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
...tiers.map((tier) => ({ value: tier.id, label: tier.name })),
],
},
];
}
@@ -57,10 +40,9 @@ export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = {
export function buildCodexControls(input: {
serviceTiers: CodexServiceTier[];
options?: Record<string, unknown>;
planModeAvailable?: boolean;
}): AgentOptionControl[] {
const controls = buildCodexSpeedControl(input.serviceTiers, input.options);
const controls = buildCodexSpeedControl(input.serviceTiers);
if (input.planModeAvailable !== false) {
controls.push({ ...CODEX_PLAN_MODE_CONTROL });
}
@@ -183,7 +183,6 @@ describe("CursorACPAgentClient model discovery", () => {
description: "Cursor fast mode",
tooltip: "Select Cursor fast mode",
icon: "zap",
defaultValue: "false",
choices: [
{ value: "false", label: "Off" },
{ value: "true", label: "Fast" },
@@ -328,7 +327,7 @@ describe("CursorACPAgentClient session start", () => {
);
});
test("removing auto_accept stops answering permission requests on its own", async () => {
test("removing auto_accept sends nothing, so the session keeps answering permission requests", async () => {
const session = createCursorSession({ model: "composer-2.5" });
await session.initializeNewSession();
const request = {
@@ -338,16 +337,31 @@ describe("CursorACPAgentClient session start", () => {
} as const;
await session.applyOptions({ auto_accept: true });
// The patch for a removal carries no key; the session stays as it was until next open.
await session.applyOptions({});
await expect(session.requestPermission({ ...request })).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-always" },
});
});
await session.applyOptions({});
void session.requestPermission({ ...request });
test("a rejected patch leaves auto_accept as it was", async () => {
const session = createCursorSession({ model: "kimi-k3" });
await session.initializeNewSession();
await expect(
session.applyOptions({ auto_accept: true, [CURSOR_FAST_FEATURE_OPTION.id]: "true" }),
).rejects.toThrow("acp does not expose ACP feature 'fast'");
void 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" }],
});
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
});
test("removing the Fast option returns the session to the value it started with", async () => {
test("applies only the selects the patch sets", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
}));
@@ -362,16 +376,9 @@ describe("CursorACPAgentClient session start", () => {
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" }),
);
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual(["true"]);
});
test("still fails session start when the provider rejects a write it should accept", async () => {
@@ -615,7 +615,7 @@ describe("OMP agent client and session", () => {
);
expect(omp.requireSession().controls).toEqual([
expect.objectContaining({
defaultValue: true,
key: "fast_mode",
description: expect.stringMatching(/does not apply/i),
}),
]);
@@ -631,32 +631,46 @@ describe("OMP agent client and session", () => {
expect(session.controls).toEqual([
expect.objectContaining({
key: "fast_mode",
defaultValue: true,
description: expect.stringMatching(/does not apply to this model/i),
tooltip: expect.stringMatching(/does not apply to this model/i),
}),
]);
});
test("removing the fast_mode option restores the runtime's own setting", async () => {
test("applies only the options it is given and leaves the runtime alone otherwise", 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 };
// Removing the option sends nothing: the patch carries no key, so the runtime keeps Fast.
await session.applyOptions?.({});
expect(omp.runtime().setFastModeRequests).toEqual([true]);
});
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("reports a Fast change the runtime made on its own", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.applyOptions?.({ fast_mode: true });
expect(omp.eventTypes()).not.toContain("options_changed");
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
omp.runtime().queueStateReports([
{
...omp.runtime().state,
model: omp.runtime().setModelResult,
fastModeEnabled: false,
fastModeActive: false,
},
]);
await session.setModel?.("openrouter/other/model");
expect(omp.eventTypes().filter((type) => type === "options_changed")).toHaveLength(1);
});
test("shows Fast only when OMP reports its state fields", async () => {
@@ -683,7 +697,6 @@ describe("OMP agent client and session", () => {
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.applyOptions?.({ fast_mode: true });
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]);
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
omp.runtime().queueStateReports([
@@ -697,7 +710,7 @@ describe("OMP agent client and session", () => {
await session.setModel?.("openrouter/other/model");
expect(session.controls).toEqual([
expect.objectContaining({
defaultValue: true,
key: "fast_mode",
description: expect.stringMatching(/does not apply/i),
}),
]);
@@ -203,8 +203,6 @@ 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;
@@ -503,10 +501,6 @@ 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";
}
@@ -757,7 +751,7 @@ export class OmpAgentSession implements AgentSession {
this.restartRuntime = options.restartRuntime;
this.config = options.config;
this.state = options.initialState;
this.fastModeBaseline = options.fastModeBaseline;
this.openOptionReport = this.fastModeDifference(options.config.providerOptions?.fast_mode);
this.currentModeId = options.currentModeId ?? null;
this.logger = options.logger;
this.live = options.live ?? true;
@@ -820,8 +814,6 @@ export class OmpAgentSession implements AgentSession {
key: "fast_mode",
label: "Fast",
icon: "zap",
// OMP owns fast mode; an unset option stands for what the runtime reports.
defaultValue: this.state.fastModeEnabled === true,
...(this.state.fastModeEnabled && !this.state.fastModeActive
? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
: {}),
@@ -831,26 +823,29 @@ export class OmpAgentSession implements AgentSession {
async applyOptions(options: ProviderOptions): Promise<void> {
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);
if (requested === undefined) return;
if (typeof requested !== "boolean") throw new Error("OMP fast mode requires a boolean");
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
if (requested !== this.state.fastModeEnabled) {
const result = await this.runtimeSession.setFastMode(requested);
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 };
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: requested };
}
private readonly fastModeBaseline: boolean | undefined;
/** The option patch when the runtime's fast mode is not what `given` stands for; unset is off. */
private fastModeDifference(given: unknown): Record<string, unknown> {
if (!hasOmpFastMode(this.state)) return {};
const enabled = this.state.fastModeEnabled === true;
return (given ?? false) === enabled ? {} : { fast_mode: enabled };
}
/** How the opened session differs from the options it was given; reported at first subscribe. */
private openOptionReport: Record<string, unknown>;
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return runProviderTurn({
@@ -1012,6 +1007,10 @@ export class OmpAgentSession implements AgentSession {
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.subscribers.add(callback);
if (Object.keys(this.openOptionReport).length > 0) {
callback({ type: "options_changed", provider: this.provider, patch: this.openOptionReport });
this.openOptionReport = {};
}
return () => {
this.subscribers.delete(callback);
};
@@ -2246,7 +2245,15 @@ export class OmpAgentSession implements AgentSession {
}
private async refreshState(): Promise<void> {
const before = this.state.fastModeEnabled;
this.state = await this.runtimeSession.getState();
if (this.state.fastModeEnabled !== before && typeof this.state.fastModeEnabled === "boolean") {
this.emit({
type: "options_changed",
provider: this.provider,
patch: { fast_mode: this.state.fastModeEnabled },
});
}
}
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
@@ -2348,10 +2355,12 @@ export class OmpAgentClient implements AgentClient {
config,
startInput.env,
);
const startState = await runtimeSession.getState();
const initialState = await this.restoreFastMode(runtimeSession, config, startState);
const initialState = await this.restoreFastMode(
runtimeSession,
config,
await runtimeSession.getState(),
);
return new OmpAgentSession({
fastModeBaseline: fastModeBaselineOf(startState),
runtimeSession,
hostTools,
restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
@@ -2423,7 +2432,6 @@ 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),
@@ -290,7 +290,7 @@ describe("OpenCode auto_accept feature", () => {
await session.close();
});
test("stops auto-approving once auto_accept is removed from the options", async () => {
test("keeps auto-approving after auto_accept is removed from the options", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
@@ -305,14 +305,12 @@ describe("OpenCode auto_accept feature", () => {
providerOptions: { auto_accept: true },
});
// The daemon passes effective options; with the key removed and no config default it is absent.
// Removal sends the provider nothing: it keeps its current setting until next open.
await session.applyOptions?.({});
await session.run("Run verification");
expect(openCodeClient.calls.permissionReply).toEqual([]);
expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({ id: "permission-1" }),
]);
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
expect(session.getPendingPermissions()).toEqual([]);
await session.close();
});
@@ -4964,7 +4964,9 @@ class OpenCodeAgentSession implements AgentSession {
}
async applyOptions(options: ProviderOptions): Promise<void> {
this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true);
if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) {
this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true);
}
}
private setAutoAccept(enabled: boolean): void {
@@ -368,8 +368,8 @@ export class OpenCodeV2Session implements AgentSession {
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
}
async applyOptions(options: ProviderOptions) {
// An absent key is the default: approvals are asked for.
const value = options["auto_accept"] ?? false;
const value = options["auto_accept"];
if (value === undefined) return;
if (typeof value !== "boolean") throw new Error("OpenCode auto_accept requires a boolean");
this.config.providerOptions = { ...this.config.providerOptions, auto_accept: value };
if (value && !this.config.toolPolicy)