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-01 14:40:11 +01:00
parent f487316a9d
commit 0da2781520
25 changed files with 700 additions and 280 deletions
+22 -3
View File
@@ -30,6 +30,25 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
### Options flow
`options` on the agent snapshot is the only option state. Controls describe how to draw an
option and read `options`; they hold no value of their own. Any option can be read and set with
or without a control.
- **Create and resume.** The provider opens with the options it is given and reports nothing
about them while opening. If the session ended up different, it says so once afterwards, as an
`options_changed` event. Events emitted before the daemon subscribes are dropped, so emit that
report from `subscribe`.
- **Live apply.** `applyOptions` receives only the keys the patch set. The daemon stores the patch
after the call resolves, onto the agent's options as they are at that moment, so the last write
wins. Throwing rejects the whole patch. A result that differs from the request is reported
afterwards, not from inside the call.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed`. The daemon stores and forwards it and does not push the old value back.
- **Removal.** Removing an option drops the key and sends the provider nothing. The session
keeps its current setting until the next open.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
@@ -115,7 +134,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -478,7 +497,7 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
readonly controls?: AgentOptionControl[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -500,7 +519,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
applyOptions?(options: ProviderOptions): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null;
-5
View File
@@ -158,11 +158,6 @@ 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,7 +365,6 @@ 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({
@@ -12714,9 +12714,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);
}
}
@@ -12757,12 +12759,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 });
@@ -12773,66 +12775,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({
@@ -12845,6 +12804,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");
@@ -12951,4 +12945,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);
});
});
});
@@ -2174,6 +2174,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(),
@@ -2267,23 +2271,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,
@@ -4686,8 +4702,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 ?? [],
}),
@@ -1771,7 +1771,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" },
@@ -1799,7 +1798,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: "" } },
]);
});
@@ -1822,7 +1871,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" }),
]);
});
});
@@ -2179,7 +2228,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" }),
@@ -867,8 +867,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,
@@ -1757,7 +1755,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;
@@ -2022,6 +2023,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);
};
@@ -2209,6 +2214,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",
@@ -2334,6 +2340,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modelId,
label: "model",
});
this.reportOptionChanges();
this.pushEvent({
type: "model_changed",
provider: this.provider,
@@ -2385,6 +2392,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: thinkingOptionId,
label: "thought-level",
});
this.reportOptionChanges();
this.pushEvent({
type: "thinking_option_changed",
provider: this.provider,
@@ -2392,39 +2400,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 });
}
}
@@ -2467,14 +2486,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({
@@ -2997,7 +3015,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;
@@ -3069,6 +3086,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,
};
}
}
}
/**
@@ -3292,6 +3329,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) {
@@ -1505,6 +1505,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({
@@ -2799,7 +2799,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",
});
@@ -3454,6 +3454,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;
@@ -3589,7 +3591,6 @@ export class CodexAppServerAgentSession implements AgentSession {
get controls(): AgentOptionControl[] {
return buildCodexControls({
serviceTiers: this.currentServiceTiers(),
options: this.providerOptions,
planModeAvailable: this.hasPlanCollaborationMode(),
});
}
@@ -3649,6 +3650,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();
@@ -3861,6 +3863,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);
}
@@ -4577,6 +4595,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);
};
@@ -4657,21 +4679,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 {
@@ -360,8 +360,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)