mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
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:
+22
-3
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"}'`,
|
||||
|
||||
+70
-48
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user