feat: plugin API for agent options, metadata, composer and New workspace (#8744)

* feat(protocol): option controls, options patch, and provider.controls.list

Add AgentOptionControl descriptors, snapshot options/controls, an options patch on
agent.config.apply, provider.controls.list RPC, and server_info.features.providerOptionControls.
featureValues stays accepted on the wire (COMPAT).

* feat(server): fold featureValues into providerOptions and describe composer controls

Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.

* fix(server): address Phase 1 audit findings on option controls

- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.

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

- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.

* fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way

A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed
Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean
at construction and reports the new shape through its existing options_changed event.

* fix(server): show Fast for a stored Codex fast_mode: true without rewriting the record

Reverts the storage migration from a987b4a43. The Codex speed control now takes its default from
the session's current options with the same rule the runtime uses (an explicit service_tier wins;
fast_mode: true applies only while service_tier is absent). This holds on create, resume, live
apply and for config.json defaults, and stored options stay as set.

* chore(website): regenerate the config schema for profile options

* 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.

* refactor(server): providers return what apply and open ended with instead of reporting it afterwards

applyOptions returns the options the session ended up with and the session
exposes its open difference as openOptionsPatch, so the manager stores the
patch and the provider's answer in one step. Removal is an ordinary patch:
the provider receives the default the key falls back to, or null, and returns
what it kept. Controls no longer take a default from a stored legacy feature
value.

* fix(server): apply an old app's profile featureValues over the profile's stored options

The unchanged app's profile form only knows the controls, so a name-only
edit dropped options it never shows, such as sandbox_mode. Its
featureValues now apply as a patch over the options already stored for the
profile (null removes a key), and a write with no featureValues keeps them.

* fix(server): ACP reports the selects it moved during open or apply as that call's result

A config_option_update that arrived before the write response was emitted
as an event on top of the result the manager stores. Notifications during
open or apply now fold into the open patch or apply result, and a rejected
apply reports what the agent moved since nothing is stored. Live options
are validated with the creation schema. Tests drive a stdio ACP agent.

* fix(server): providers validate live option patches and Claude reports Fast dropped by a model switch

Live patches go through the schema used at creation. A Claude model switch
that cannot keep Fast reports fast_mode as removed so the stored options
match the session.

* fix(server): Claude reports Fast as off, not removed, when a model switch drops it

A removed key lets a config.json default of fast_mode: true fill back in
while Fast is off at runtime.

* fix(server): fold an old app's profile featureValues by the provider and options the write carries

A write with options folds featureValues over those options. A write
without them patches the stored options, unless the profile moved to
another provider, which starts from no options.

* docs(server): a rejected apply reports what the provider moved after the call ends

* test(server): a patch key is resolved against nested configured defaults

* test(server): a setting a plugin changes on its own is forwarded as options_changed

* test(server): assert the forwarded plugin setting without nesting callbacks

* test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch

* fix(server): Codex returns the tier it resolved and drops a speed the new model lacks

A live service_tier: fast is stored as the catalog's Fast tier id instead of
a value the control cannot show, and a model switch to a model without the
stored speed reports Normal so choosing a supporting model again does not
bring the paid tier back.

* fix(server): Claude applies only the keys a patch sets over its current options

A Fast patch kept a copy of the options taken before the runtime answered, and
assigning it afterwards dropped a patch that landed meanwhile.

* fix(server): a plugin that moves settings and then refuses an apply reports what moved

* refactor(server): the legacy featureValues folds take one named-input object

* fix(server): a live patch is rejected for an option the session only reads at start, and opening reports every option it ended up with

A stored option has to describe what runs. OpenCode, OMP and ACP now reject a live change
to a key they apply only when the agent opens; Claude applies such a change from the next
turn and rejects it while a turn is running. Opening reports every select or setting the
session came up with, including ones nobody set, so a native default (Cursor opening with
Fast on) is stored as option state like any other.

* fix(server): Claude rejects a live change to an option it reads when the query starts

Replacing a started query to apply the option would end its background shells and
tasks. Once the query exists only Fast can change; before the first prompt every option
still applies, because it is read when the query is built.

* fix(server): a live options patch is rejected when the session cannot apply it

A session with no applyOptions (Pi) now rejects a patch instead of storing a value that
fails on reopen. Codex applies only service_tier, plan_mode and fast_mode live, since the
approval policy and sandbox are fixed when the thread starts. Claude also rejects a launch
option change while its first query is starting, after the options have been copied.

* fix(server): plugins reject keys they do not report as settings, and Codex opening reports the speed it runs

A plugin session applied the settings it knew and silently stored the rest; it now rejects
a patch that names a key the plugin does not report. Codex opening on a model without the
saved speed ran Normal while the stored options still said priority; opening now reports
the tier it runs, as a later model switch does.

* refactor(server): option helpers take named inputs, control types come from their schemas, and a plugin accepts launch options it opened with

A whole profile is applied by the unchanged app, so a launch-only plugin option whose value
has not changed must not fail the apply. applyProviderOptionsPatch and featuresFromControls
take one object, and the protocol control types are inferred from the wire schemas.

* fix(server): ACP reports a select that appears after startup with its native value

A model switch can introduce a select (Cursor Fast) that the session did not have at open.
Its value is now stored like any other native selection instead of being skipped because
the previous snapshot did not name it.

* fix(server): a loaded agent's options stay what its session opened with when a provider default is edited

effectiveOptions was recomputed from the current config.json defaults on every state emission, so editing a default changed the snapshot of an agent whose session never received it. It is now computed when the session opens and moves only through an apply or an options_changed. Editing a default affects agents the next time they open.

* fix(server): a live apply shows the merged defaults the provider was given in the options snapshot

The snapshot took the raw patch, so a nested patch over configured defaults dropped the defaults the provider had accepted. The snapshot now takes what the provider was sent plus what it reports back; stored options still keep the raw patch.

* fix(server): an option a session drops when opening is not brought back by a configured default

The opening snapshot merged defaults over the options after the session's openOptionsPatch had removed a key. Defaults now merge into what the session was given and the patch applies after.

* fix(server): Codex keeps the speed it opened with when the legacy Fast key is removed

Opening moves a legacy fast_mode into service_tier, so removing fast_mode afterwards changed the runtime speed to Normal while the stored options still said Fast. Removing the alias now changes no speed.

* fix(server): opening snapshots keep the defaults the session launched with, and config-change pushes carry legacy profile values

An opening resolved defaults once for the session and again at registration; an edit in between showed options the session never received. Create, resume and reload now resolve once, before the provider opens, and registration starts from that. The daemon_config_changed push skipped the featureValues projection that config replies apply, so the unchanged app lost a profile's legacy values when any setting changed.

* fix(server): every opening path hands registration the options it launched with

Registration took the launch options as optional and reread the defaults when a path did not supply them, as provider-session import did. It is now required, so create, resume, reload and import each resolve once before opening. The option helpers take named inputs.

* fix(server): a saved legacy Codex Fast preference wins over a configured service_tier default

The defaults merge put a configured service_tier next to the agent's own legacy fast_mode, and Codex reads service_tier first, so the agent opened Normal. The agent's own alias now replaces the default of the key it stands for.

* fix(server): Codex keeps a Fast chosen by alias as the catalog's tier id

A live fast_mode or service_tier "fast" was stored as the alias while the apply reported the catalog id, so a later switch to a model with a different Fast id ran Fast under an option that said otherwise. The session now stores the id it accepted and drops the folded fast_mode.

* fix(server): the legacy Codex Fast alias folds into one speed on apply and in configured defaults

A patch with both speed keys left the alias in the stored options, so a reload started Fast again after the speed was removed; the apply now reports the folded alias. A configured fast_mode default now supplies the service_tier default, so removing an agent's own speed falls back to the configured Fast like opening does.

* fix(server): an OMP replacement runtime starts with the Fast the session has now

A restart (for example an approval-mode change) restored Fast from the options the session opened with, turning back on a Fast the user had switched off. The session now keeps the Fast it applied or the provider changed, which is what a replacement reads.

* refactor(server): Codex's legacy fast_mode converts to service_tier where old data enters

Stored records, config.json defaults, profiles, older clients' requests, MCP settings and
plugin hooks go through convertLegacyOptions as they come in. Past that point the defaults
merge, the manager and the Codex session never see fast_mode for Codex, so the alias branch
in the defaults merge and Codex's fast_mode handling are deleted.

* fix(server): a legacy hook's replaced speed and an apply request's options convert before they are compared or applied

A hook that swaps the canonical speed it was sent for fast_mode is diffed in canonical form, so
its replacement is not read as a deletion. An apply request carrying featureValues and options
sends one options patch with options winning, so a superseded legacy value is never applied.

* refactor(server): the agent manager converts Codex's legacy fast_mode for the adapter behind the provider id

The rename is adapter-specific, so it lives where the provider id resolves to its adapter:
AgentManager converts an agent's own options on create, resume, import, reload, apply and
draft controls, following derivedFromProviderId, so a custom provider extending codex keeps
working with a saved or requested fast_mode. Storage, MCP, schedules, hub and profiles pass
options through. The featureValues-to-options fold stays provider-agnostic at the legacy edges.

* fix(server): a rejected OpenCode auto_accept leaves the session asking for permission

applyOptions committed the new flag before approving pending requests, so a failed reply left
the session auto-approving while the stored option stayed off. The flag is restored when the
apply throws.

* fix(server): legacy Fast reaches draft discovery and saved profiles, and overlapping or replaced options keep the state they committed

Draft controls and commands convert the config they hand to the provider, so inspecting a
legacy Fast preference no longer fails Codex's strict options. A profile naming codex converts
its saved fast_mode for the app's controls. OpenCode serializes auto_accept applies so a failed
one restores exactly what it changed, and a replacement OMP runtime starts from the Fast the
session opened with.

* fix(server): an OMP replacement runtime that refuses the session's Fast publishes what it accepted

The approval-mode restart installed the new runtime's state without comparing its Fast result to the session's, so a refusal never reached options_changed, the remembered value, or storage. The replacement now goes through the same comparison as refreshState.

* fix(server): OMP turn completion publishes a Fast change, and Claude opens with Fast off on a model that has none

OMP installed runtime state in several places and only one compared Fast; every state read from the runtime now goes through one install step. Claude kept a Fast default on a model without Fast, which made unrelated options reject until the user cleared it; the session now opens with Fast off and reports it in openOptionsPatch, as the model-switch path already does.

* fix(server): a malformed Codex fast_mode is rejected, and profile options are renamed for clients by the agent manager's conversion

convertLegacyOptions read any truthy fast_mode as Fast, so the string "false" ran priority. It now accepts only true, false or null. The profile store no longer renames keys: a literal codex profile was renamed in storage while an extends-codex profile was not. The config API now converts each profile's options with the manager's adapter-aware conversion, so codex and my-codex profiles read the same.

* fix(server): a profile is stored in the current option keys, converted for the adapter its provider extends

Profiles in config.json are old data entering, so the store converts them (stored value and incoming patch) with the same conversion the agent manager uses. Storage and clients now share one vocabulary: resetting a legacy Codex profile's speed removes what was shown, and a malformed fast_mode rejects the write instead of breaking later config reads. The config API goes back to copying the field shape only. The extends chain is followed by one function over either the registry's definitions or config.json's providers.

* fix(server): profile options convert against the provider graph a write commits

A write that edited a provider and a profile together folded the profile against a shallow merge that dropped the provider's extends, so its legacy Fast was not converted. The fold now sees the same deep-merged providers the write commits.

* fix(server): draft speed shows the tier the first turn will run with

Draft discovery reported the options as requested, so a Fast configured as a legacy fast_mode or the fast alias read as Normal while creating sent the model's own Fast tier. The draft now reports the options the scratch session opened with, as registration does.

* fix(server): ACP option applies run one at a time

Two overlapping applies shared one transaction guard, so when the first was refused after the agent had moved a select, its report was dropped as 'still in a transaction' and the second saw no difference. Applies are serialized, as OpenCode's are, so each reconciles against the state the previous one left.

* fix(server): an ACP removal reports the choice the select lands on, and the Codex plan tests enable plan mode

A removal asks the select for its empty choice and compared the result against that
same empty choice, so applyOptions returned nothing and the manager stored the removal
as null while the session ran the Default choice. The difference is now computed against
what was asked (null), so the options hold the value the session runs with.

The Codex plan-mode end-to-end tests called the removed session.setFeature through an
optional call and silently skipped plan mode; they apply the option and fail loudly if
the method is gone. The MCP parity wrapper forwards listControls instead of the removed
listFeatures.

* fix(server): plugin option applies run one at a time, and OpenCode reports the Auto Accept legacy full-access turns on

Plugin sessions let two applyOptions overlap. The second finished and re-recorded the
settings the plugin held, which included a change the first had already made and not yet
reported; when the first then failed, nothing was left to report and the stored options
kept the old value while the plugin ran the new one. Applies now queue on the session,
the way the ACP adapter's do, so each reconciles against what the one before left.

OpenCode normalizes the legacy full-access mode to build with Auto Accept on, but the
session never said so, so the options the manager keeps did not hold Auto Accept and a
reopen turned it off. The session now reports it through openOptionsPatch on create and
resume.

* refactor(server): openCodeOpenOptionsPatch takes one named-fields object

* feat(app): the client and app work on options and controls

The agent snapshot's `options` are the state and `controls` only draw them.
Running and closed agents draw from the snapshot; drafts and profile forms ask
`provider.controls.list.request` with their `providerOptions`, so aliases such
as a legacy Fast profile's `service_tier: "fast"` are resolved once, by the
daemon, and the draft keeps Fast highlighted. A draft adds only the user's
edits and each provider's last-used control values; applying a profile seeds
its non-control options (e.g. `sandbox_mode`) into the draft, and a profile
form saves them back instead of pruning to the controls it can draw.

A daemon without `server_info.features.providerOptionControls` still sends
`features` and `featureValues`. `LegacyProviderOptions` in the client package
is the one place that translates snapshots, draft listings, create and apply
requests, and daemon-config profiles, so the app never branches on it.
Persisted preferences and tab layouts rename `featureValues` through tagged
COMPAT preprocessors.

The features/controls bridge moves from the server into the protocol package
so the client can reuse it.

* fix(app): drafts and profile forms send every option layer to the daemon

A cold create against a daemon without providerOptionControls routed control
options (Claude fast_mode, Codex service_tier) as providerOptions, which that
daemon does not read for them. The client now lists the provider's features
before the first create or workspace create that carries options, so callers
never prime a cache.

Draft options moved into a plain model. Applying a profile replaces provider
and options in one step, so the form's provider change that follows no longer
clears a cross-provider profile's non-control options; choosing a provider by
hand still starts over. The model sends its complete options (seed, last-used
values once the provider says it still draws them, edits) to the daemon and
the controls draw only the answer, so a remembered legacy fast is drawn as
what it runs as. A toggle shows over the last answer until its own arrives.
The profile form does the same: its edits go through resolution too.

* fix(client): a legacy create routes its options from the controls of its own config

Which options a legacy daemon exposes as features depends on the model, mode
and thinking level, not only the provider: a Haiku snapshot with no features
says nothing about Opus. The provider-keyed memory of control keys sent an
Opus create's fast_mode as providerOptions, which that daemon does not read.

A create that carries options now lists the controls for its own draft
config and splits on that answer. The memory, and the snapshot and listing
hooks that fed it, are gone.

* fix(app): a closed agent shows its stored options, and a provider switch asks about its own options

A closed agent's snapshot is projected from its stored record, which kept
controls and provider options but dropped them from the payload, so the
app drew no speed or other controls for it. The stored projection now
carries them, with the legacy features derived from the same source.

The draft read the form's provider and the model's options separately, so
for the render after a manual provider change it asked the new provider
about the previous provider's options. The request is now built from one
snapshot of the model and is absent until the model has observed the new
provider.

* fix: persisted options keep their full shape, and every legacy snapshot gains controls

Draft setups and form preferences validated option values as booleans,
strings or null, but the options they now receive come from agents and
profiles and can be objects, arrays or numbers. One such value failed
validation and wiped the whole saved layout or the saved preferences. Both
schemas now accept any JSON value.

The legacy-daemon translation skipped the snapshots carried by
agent_created, agent_resumed and timeline responses, so a created agent or
a timeline refresh left the SDK handle without options and controls.

The profile journey asserted the create request's options field; the wire
field is providerOptions.

* fix(client): a legacy profile with both featureValues and options keeps both

A legacy daemon keeps an unknown `options` field on a profile it stored
(profiles are passthrough), for example after a downgrade, while its own app
keeps editing `featureValues`. Reading such a profile took `options` and
dropped `featureValues`, so a Fast setting there was lost and the next save
replaced it. Both are merged on read with an explicit `options` key winning,
the same rule the daemon applies when it folds `featureValues`.

The draft form input of draftControlsRequest is a named type.

* fix(app): a draft stops sending a toggle the model in use does not offer

A toggle made on one model stayed in the draft's edits after the form moved
to a model whose controls do not include it, and was sent with the create.
On a legacy daemon that key is not a feature for the new model, so it went
out as a native provider option the daemon rejects. The draft now sends an
edit only while the latest answer draws a control for it, and sends it again
when a control for it returns.

* fix(server): a closed agent's options include the provider defaults, like a live one's

The stored snapshot projected only the options the agent owns, so an agent
that ran with a config.json default (for example Claude fast_mode: true)
showed the option as missing, and its legacy Fast control as off, until it
was resumed. The projection takes the same resolver the live agent uses
(the provider's defaults under the agent's own options).

* fix(app): a draft keeps sending an option no control has drawn

The previous change stopped sending any edit the latest answer drew no control
for, which also dropped options set without a control (a plugin setting a
token budget on the draft). Only an edit of a key an earlier answer for this
draft drew as a control stops applying when a later answer draws none; every
other edit always applies.

* test(server): the MCP test agent manager resolves effective options

* fix(app): a profile stops saving a toggle the model in use does not offer

Switching a profile from Opus to Haiku kept the Fast edit, so the saved
profile carried fast_mode that no control draws and an old daemon's
provider rejects at create. The profile editor now drops a key an earlier
answer drew as a control and the latest one does not, the rule the draft
already follows; options no control ever drew are never touched.

* fix(client): resetting an option on an old daemon writes the default it can take

setAgentOptions({ service_tier: null }) sent a feature write with value
null, which the old Codex setter rejects, leaving the paid Fast tier on.
A toggle now resets to off and a choice to its default; an option with
no control to name a default fails with the host-update message before
anything is written.

* fix(client): applying a config with a reset option on an old daemon writes the default it can take

applyAgentConfig({ options: { service_tier: null } }) reached an old
daemon as featureValues with null, which its Codex setter rejects. The
config now takes the same translation as setAgentOptions: a toggle goes
off, a choice goes to its default, and an option with no named default
fails with the host-update message before anything is sent.

* feat(plugin): provider plugins declare option controls and receive option changes

A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.

* fix(server): a plugin provider's reported options are stored like any provider's

A control's `defaultValue` only says what the control shows for a key with no
value. Opening and applying store every option the session reports, defaults
and values nobody set included, as built-in providers do; the special case that
left a value at the default unset is removed.

* fix(server): a plugin decides which options it applies live

A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.

* fix(server): a rewound plugin session reopens with its options as they stand

The reopen merged the plugin's reported options over the launch record, so a
key the plugin reset by leaving it out came back at its launch value. The
session now keeps the committed options, removals included. The provider-direct
example also refuses a combined request before changing anything.

* fix(server): a plugin that stops reporting an option has reset it

The adapter only visited keys present in a new report, so a key the plugin
dropped to return to its default kept its stored value and its rewind value.
A previously reported key that disappears is now reported as removed.

* fix(server): an apply reports the options the plugin reset along the way

Applying and unsolicited reports now share one conversion from the plugin's
report to a patch, so a key the plugin stops reporting is a removal on both.

* fix(server): an apply never reads a requested option as one the plugin dropped

A plugin may run an option it never reports. Only a key it reported before can be
reported as removed, and a rewind reopens with the accepted request plus what the
plugin reported back.

* test(server): an apply to a plugin that reports controls sends its changes as provider options

* feat(plugins): agents and workspaces carry plugin metadata

Plugins had nowhere to keep their own data on an agent or workspace, so
Phase 5 and 6 surfaces would each have invented a side store. Both now
hold an opaque Record<string, JsonValue> that Paseo stores, snapshots and
hands to hooks without reading a key.

Set at creation or changed with agent.metadata.update.request /
workspace.metadata.update.request (top-level keys replace, null removes).
Persisted on the agent and workspace records, so it survives a restart.
Reaches agent.create.before (which may add to it), agent.open.before
(read-only), agent.create.after and workspace.create.before/after.

Gated once on server_info.features.entityMetadata; the client throws
"Update the host" instead of sending to a host without it.

* fix(plugins): an agent.open.before result may omit metadata

The result schema required metadata, so a plugin written before metadata
existed that returns its own object instead of spreading the request
failed validation and the session open failed, even for agents with no
metadata. An omitted metadata now means unchanged and is filled back in
from the request so later plugins in the chain still see it; a present
value that differs is still rejected.

The restart test now sets metadata through the PaseoApi handles and
checks that agent.open.before sees the patched value after the restart.

* fix(plugins): omitted metadata in a hook result means unchanged

An agent.create.before hook that returned its own { config, env } dropped
metadata that an earlier hook in the chain had added, because only the
caller's original metadata was restored at the end. The omitted field is
now carried forward at each hook handoff, as agent.open.before already
does.

The agent.open.before result type also required metadata, so an older
TypeScript plugin that returned its own object no longer compiled even
though the daemon accepted it. The result type now makes it optional.

* fix(plugins): omitted metadata in a workspace.create.before result means unchanged

Same handoff as agent.create.before: a legacy hook that returned only
{ source, ... } dropped the caller's metadata and anything an earlier
hook added, and workspace provisioning then defaulted it to {}. The
omitted field is carried forward at each handoff; an explicit {} still
replaces.

* test(server): fixtures for agents and workspaces carry metadata

Agent snapshots and workspace records now always carry metadata, so the
hand-built fixtures and expectations in three existing suites name it.

* docs(plugins): the hook result comment names metadata as the field that stays

* fix(server): reopening a closed agent keeps metadata saved just before

The loader reads the agent record, then queues a resume. A setMetadata
queued ahead of that resume saves a newer record, and the open hooks saw
it, but registration took metadata from the loader's older read and wrote
it back over the saved patch. Registration now uses the metadata read
inside the lifecycle lane.

* docs(server): name the metadata gap when a handle-less agent is recreated on load

* docs(server): the handle-less recreate gap spans the agent.create.before hook

* docs(plugins): an agent.open.before hook starts writes to its own agent without awaiting

An agent.open.before hook that awaits setMetadata (or any write) on the
agent being opened deadlocks: the write joins the agent's lifecycle lane
behind the open, and the open waits on the hook until it times out. The
hooks reference now says so and shows the supported shape, a write
started without awaiting, which runs right after the open.

Deferred, not in this stack: reject such a write immediately instead of
letting the hook time out. The host knows which plugin hook it is
awaiting for the agent and which channel the write arrives on, so the
rejection can be keyed on "this plugin is inside an open hook for this
agent" without cross-process tagging, and an unrelated client's write
would still wait as it does today.

* feat(plugins): composer items and controls in all three composers

A plugin registers a component with addComposerItem and reads and changes the
composer through useComposer(): provider, model, mode, thinking, options,
prompt, attachments and agent metadata. The agent, draft and New workspace
composers provide the same ComposerInput and ComposerAgent halves, so a
command reaches the right model without the plugin knowing which composer
it sits in.

Toggle, Select and Action are drawn by one ComposerControl renderer that
the thinking select and provider option controls now use too. Controls sit in
the toolbar when there is room and become rows in the existing overflow sheet
otherwise, so on a phone they appear in the overflow. Plugin items count in
the density presence so the composer condenses to make room. Model and mode
stay specialized but share the trigger, density and overflow.

Draft metadata is kept locally, sent at creation and reset when the prompt is
sent. PluginAgentSnapshot.metadata, deferred from the metadata phase, lands
here. The unused provider badge is removed.

vitest resolves @floating-ui/react-native to its ESM build: the CJS entry
requires the real react-native, whose Flow sources do not parse.

* fix(plugins): composer commands run on current state, report failures, and reach the overflow sheet

Draft commands read the state captured at render, so a plugin's
`await setProvider(); await setModel()` acted on a stale provider. The draft
form and options are now plain stores that commands read live, and
`setProvider`/`setModel` reject an unavailable provider or a model the loaded
catalog does not list. Draft `options` include the plugin's pending edits.

Shared composer controls own the pending state and show a rejected command as
an error toast, like the built-in controls. Callback payloads are objects
(`{ value }`, `onPress(event)`) so they can grow without breaking plugins.

The overflow sheet's content portals to the app root and lost the composer's
agent, input, toast and query contexts, so plugin items drew nothing in it.
`useComposerSheetBridge` carries them across.

* test(plugins): composer commands tested on the models, overflow sheet in Playwright

The draft composer's command rules (unavailable provider, unlisted model,
edits belonging to the provider the form has now) moved from the React hooks
into the form store and the draft options model. They take the host's catalog
as a plain input, so the command tests run on the real models with no module
mocks and no renderer.

The overflow-sheet test faked the portal it was meant to verify. It is
replaced by a Playwright spec that installs the composer-controls example and
checks its rows in the narrow composer's Features sheet; it fails when the
sheet's context bridge is removed.

* test(plugins): lock draft option ownership without a subscription, name the overflow-sheet steps

The draft option test subscribed the options model to the form, which in the
app happens in a render effect, so it passed without the model observing the
form's provider itself. A case with no subscription now fails when that
observation is removed.

The overflow-sheet spec reads as steps through a helper.

* fix(plugins): composer option resets and model checks match the documented commands

A draft kept `null` as an option value, so `setOptions({ key: null })` sent the null to the
provider and creation failed. A null edit now removes the key from every draft layer, as the patch
does on a running agent. A running agent's `setModel` accepted ids its catalog does not list; it
now rejects them the way a draft does.

The composer controls imported the toast host through the toast context, which pulled the host's
animation module into every module that loads plugins. They use the API-only context instead.
The migration guide's registration table gets the `addComposerItem` row its test requires.

* fix(plugins): draft metadata survives a remount, plugin commands follow the composer lock

Draft metadata was component state while the prompt lived in the draft store, so a composer that
remounted across the compact breakpoint kept its text and lost the plugin's choices. It now lives
in the draft store beside the prompt, is cleared when the draft is sent, and is not cleared when
the prompt is emptied. It is not persisted across app restarts.

While the New workspace composer is creating the agent, the built-in controls are disabled but
plugin controls and commands were not. They read the composer's own lock: Paseo controls render
disabled, and setPrompt, insertText and the agent commands refuse.

The options and metadata a plugin reads are frozen copies, through the same helper the timeline
projection uses. The composer control test mounted components in JSDOM, which the testing rules
ban; it now runs in the browser project.

* fix(plugins): a kept command honors the composer lock, and useAgent metadata is frozen

A plugin action that awaits an RPC holds the composer it was created with. The lock was captured
at that render, so a submit that started meanwhile did not stop its insertText. Commands now ask
the composer whether it is locked when they run.

The agent snapshot a plugin gets from useAgent froze only the outer metadata record. It now uses
the same deep frozen copy as the composer.

* fix(plugins): attachment commands honor the composer lock

The creation request captures the attachments when it starts, so an attachment a plugin added or
removed afterwards was lost or left in the request. They refuse like the prompt commands.

* fix(plugins): a built-in select in the compact sheet keeps its action name

The shared control named every icon-only select by its value ("Speed: Fast"),
including in the compact sheet, where the row already shows the value. The sheet
row is named by its action again ("Select speed"), as before the shared renderer.

* test(app): composer item test installation carries the subscribe main added to InstalledPlugin

Main's plugin streaming (#8527) made subscribe a required field of InstalledPlugin; the
composer item model test builds its own installation and predates it.

* feat(plugins): New workspace items set the branch and workspace metadata

addNewWorkspaceItem mounts a plugin field on the New workspace screen, and
useNewWorkspace() exposes host, project, isolation, base ref, branch and
metadata with commands that follow the composer item rules: they act on the
screen's state when they run, resolve once applied, reject while the screen
is locked, and take JSON payloads. Host and project are read-only because a
command that switched them would unmount the item that called it.

The screen's pickable state (base selection, manual isolation, branch,
metadata) moves into one plain store so pickers and plugin commands write to
the same place; the screen reports derived facts to it on each render. The
branch is sent as source.branchName only for a branch-off worktree, and
metadata on workspace.create.

Items render through ComposerControl with a new "field" surface (badge on
desktop, full-width field when compact). Combobox ships here: Paseo owns the
loading and error states, keeps previous results, and drops superseded
searches.

* fix(plugins): New workspace commands work from mount effects, and branch and base ref match what creation uses

A command called from a New workspace item's mount effect rejected because the
setup model only received its inputs and the pickers' handlers from a passive
effect on the screen, which runs after a child's. The model now takes its
inputs at construction, picks the base and isolation itself, and is refreshed
in a layout effect; the screen supplies the side effects of a pick (the PR
attachment, the remembered isolation) as listeners.

The branch a plugin sets was dropped when no base was resolved, such as on a
detached HEAD, because it was sent only for an explicit branch-off. It now
applies whenever the worktree cuts a new branch, decided in one place that
mirrors the daemon.

baseRef.name was the display-shortened branch name, so setting back what was
read based the worktree on the local branch instead of the origin one. It is
now the exact ref creation uses, and host and baseRef are frozen like project
and metadata.

* fix(plugins): New workspace commands have the same effects as a pick, even from a mount effect

The screen attached the pick side effects (remembering the isolation, syncing the picker's PR
attachment) as listeners in its own useEffect, which runs after a child item's mount effect. A
mount-time setIsolation("worktree") switched the screen but was not saved, and setBaseRef skipped
the attachment sync. The effects are now injected when the setup model is built, so a command and
a pick run the same code from the first render. Closing the picker stays in the picker's handler.

* fix(plugins): a Combobox shows a failed refresh instead of the old results

The query keeps its cached data when a reopened list fails to refresh, so the old items stayed
selectable and the error never showed. A failed search now clears the list so its error shows.
The migration guide maps addNewWorkspaceItem, which its registration-coverage test requires, and
the superseded-search test waits on a later search instead of sleeping.

* fix(plugins): a branch and metadata chosen for one project are not sent for another

The setup model outlives the project and host it was chosen for, so an issue picked for project A
was sent on workspace.create after the user switched to project B. The model now drops the branch
and metadata when the project changes (project ids are host-scoped, so a host switch counts). A
project that is still resolving is not a switch. The base selection is left alone: the built-in
picker keeps a branch pick across a project switch, and that behavior is unchanged.

* test(plugins): the project-switch spec expects the other project's metadata to be empty

* fix(plugins): a host switch drops the branch and metadata even when the project id repeats

A remote repository keeps the same project id on every host, so comparing the project id alone
missed a switch from host A to host B for the same repository. The target is now the host and the
project id together.

* fix(server): a branch the caller named is not renamed from the first agent context

The daemon marked every new branch-off worktree for automatic naming, recording the chosen branch
as the placeholder, so the first agent's prompt could rename a branch a plugin or client had
named through branchName. Only a branch the daemon derived from the worktree slug is a
placeholder now.

* fix(plugins): New workspace items remount when the project or host changes

An item stayed mounted across a project switch, and its Combobox kept the previous project's
results (same query key, cached data) selectable until the next search returned, so an issue from
the old project could set the branch and metadata on the new one. A Combobox cannot detect that
its inline search now means another project, so the item host keys the items by host and project
id. Items and their search results start fresh for the new target, and a mount effect sets up
again after the model clears the branch and metadata.

* docs(plugins): a layout-effect command on mount also races the reset on a project switch

* fix(plugins): a New workspace item's commands stop working once its project or host is left

Every item received the same command functions, which act on the current screen state. A lookup
that finished after the user switched projects could still call setBranch or setMetadata and
overwrite the new project's choices; the reset on a switch only clears what was already written.

The setup model now hands out commands per target with commandsFor({ serverId, projectId }).
They reject with a "moved to another project or host" error when the screen is on another target,
like the lock rejects, so the promise only resolves when the change took effect.

* feat(plugins): reach a running agent's composer by reference

useComposers(selector) in any plugin component and composers(selector) on every command context return the composer of a running agent, named by id or by focus, whether or not its tab is open.

A running agent now has one store-backed composer model apart from any mounted tab. The mounted composer plugs its cursor-aware insert in while mounted; otherwise insertText and setPrompt write the agent's saved prompt. model and thinking resolve through the provider catalog with the picker's own function, so a read equals what the picker shows.

The host reads one ComposerModel from context and no longer knows whether it is in an agent, a draft or New workspace. The daemon rejects an unlisted model or thinking option for a running agent, replacing the app-side guard.

* fix(plugins): composers by reference follow the focused agent and reach retained composers

- `{ focused: true }` is the agent Command Center hands to agent items, published by its
  registration through plugins/focused-agent.ts and empty outside a workspace. The composer source
  reads that module instead of the remembered workspace selection, the navigation store and the
  layout store, which also removes the require cycles those imports added.
- A composer the app retains in a workspace the user left has no live input. `insertText` and
  `setPrompt` now store the text with `replaceDraftText`, which asks the mounted input to show it;
  before, the text reached the store but the retained input never displayed it.
- `model` and `thinking` read exactly what the picker resolves before the catalog loads (no
  models), then re-resolve when it lands, instead of reading null.
- The daemon validates a thinking option against the model the agent runs: the runtime model when
  the provider lists it, else the configured one.
- The composer sources subscribe to query-cache updates only. A query another component creates
  during its render is announced as `added`, and a subscriber that updates then made React warn
  about updating a component while rendering a different one. A catalog read no longer starts its
  load synchronously either.

* test(plugins): a Command Center agent item reaches the composer through the real client state

The command-path test now reads workspace and agent through
createPluginClientStateSource over seeded session stores, so an empty
lookup, which makes the Command Center return before calling the item,
would fail it. Also records at the focused-agent publisher that it
publishes in an effect.

* fix(plugins): a running agent's composer validates and refreshes against the agent's own catalog

setAgentModel and setAgentThinkingOption checked the provider's global catalog,
while the picker reads the catalog of the agent's directory, so a model only
the project lists was rejected. The validation input now carries the directory.

The composer read the catalog once with no observer, so reconnect repair, which
refetches only active queries, never refreshed it and a failed load never
retried. While anything watches, the composer now observes the catalog query.

The shared composer map kept every agent's composer for the session. It is now
dropped when nothing watches it and no composer is mounted.

* fix(plugins): useComposers holds the composer it names, and a composer lives as long as its agent

PluginComposersSource.subscribe watched the stores directly and never the
composer model, so a component using useComposers did not keep the model's
catalog observer alive and reconnect repair skipped it. subscribe now takes the
selector and holds the model it names, following focus.

Dropping a composer when it went idle left a command a plugin had saved calling
a dead model after the tab reopened. A composer is now dropped when its agent
leaves the session and nothing watches it or has one mounted.

* fix(plugins): a composer refreshes a stale catalog on resumed demand, shares its store watches, and is released with its last holder

Watching again, or reading once, after the catalog was invalidated or failed
while nothing watched now starts one deduplicated refresh; the observer
refetches on mount. Reconnect repair only refetches watched queries, so an
unwatched catalog stayed stale.

Each subscribe installed its own session and draft store callbacks, so N
watchers received N squared notifications, including for other agents' drafts.
One pair of subscriptions now serves all listeners, and the draft callback
ignores drafts of other agents.

A composer whose agent was removed while it was held was only released on the
next session change. The last holder letting go now checks too, and an archived
agent's idle composer is releasable.

* test(server): live model and thinking switches use values the provider lists

The daemon rejects a model or thinking option its provider does not list, so the live-preferences
e2e can no longer switch to a made-up id. A provider that lists nothing to switch to skips the case.

* test(server): the test providers list two models with thinking options, and the live switches run

The fake providers listed one model and no thinking options, so once the daemon validated
selections against the catalog the live model and thinking switches had nothing valid to switch to
and skipped. Every fake provider now lists two models with thinking options, and the switches run
for all three providers without depending on installed binaries.

* fix(plugins): a retained composer that is not showing takes plugin writes through the draft

On native a retained composer keeps its input mounted under display none, so the ref alone said
the input was live and a plugin insertText replaced the stale selection instead of appending to
the draft. The input is live only while its retained panel is showing.

* test(plugins): the retained input liveness test runs in real Chromium

* fix(plugins): a model or thinking change, an unreported option key, and a draft model reach the provider unchecked

Paseo no longer checks these in front of the provider. A running agent's model or thinking
change no longer waits for the catalog or checks it, a plugin provider is sent every option
key and refuses what it cannot apply, and a draft setModel keeps the id as the model picker
does.

* fix(plugins): a plugin reporting settings is sent every key of an options patch

* Run option, model, thinking and mode changes in the agent's lifecycle lane

The agent manager owns all sequencing for one agent: applyOptions, setModel,
setThinkingOption and setMode now run in the same lane as open, close, archive,
labels and metadata, so a provider session never sees two of them at once.
Provider-side serialization (the applying chains in the ACP, plugin and OpenCode
sessions, and Claude's apply-over-current-options handling) is deleted.

* Reopen a handleless agent inside its lifecycle lane

A stored agent with no resumable session is now reopened by
AgentManager.createAgentFromRecord, which rereads the record inside the agent's
lane before creating the first session. A metadata or label write queued ahead
is in the record it reads; one queued behind applies to the live agent. The
loader no longer passes a record it read outside the lane. createAgent itself
also runs in the lane, like resume.

* Return the draft's options from AgentClient.listControls

listControls now answers { controls, options } like AgentManager.listDraftControls:
the options a session opened from the draft would run with, with config
defaults merged in and anything the provider resolves natively for the ones
nobody set. ACP fills unset feature selects from the probe session's
currentValue, so a fresh Cursor draft shows the Fast selection the agent opens
with instead of no selection. The legacy features response and the MCP draft
listing already project from the manager's result, so they follow.

* Send a profile's options on every write, empty included

The profile form always sends options. The daemon reads a write without them as
one from an app that predates options and folds featureValues over the stored
ones; a write that carries them, empty included, replaces them. A profile left
with no options is stored without the key, so removing the last one persists.

* Read a remembered Codex fast_mode as its service tier

The create-agent preferences convert the legacy key on read, keyed on the
built-in codex provider id, so the Codex draft shows the Fast selection the user
last used. Tagged COMPAT(featureValues) with the other legacy-option shims.

* Document one option change at a time per agent

The agent manager runs option, model, thinking and mode changes in the agent's
lifecycle lane; providers do no sequencing. The hook deadlock warning now covers
agent.create.before and option writes.

* Flatten the profile fold's base selection and drop an unused import

* Carry the New workspace context through portal hosts and the draft's metadata through retry

* Convert legacy options when resolving a stored agent's effective options

* Keep stored-record reads tolerant of a legacy option the conversion rejects

* Keep plugin attachment items separate from the draft's own

* Hand plugin command inputs to the host as its own copies
This commit is contained in:
Mohamed Boudra
2026-11-09 10:10:16 +07:00
committed by GitHub
parent 8cc8e12007
commit 28c188513e
305 changed files with 17170 additions and 3347 deletions
@@ -143,6 +143,90 @@ test("resuming a stored agent keeps its unread flag and its last-activity time",
}
});
test("reopening an agent with no resumable session keeps a metadata write that overlaps it", async () => {
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-handleless-metadata-"));
const logger = createTestLogger();
const storage = new AgentStorage(path.join(root, "agents"), logger);
const base = createTestAgentClients().codex!;
// A provider whose sessions leave nothing to resume (Codex before its first prompt).
const client: AgentClient = {
provider: base.provider,
capabilities: base.capabilities,
createSession: async (config, context) => {
const session = await base.createSession(config, context);
session.describePersistence = () => null;
const runtimeInfo = session.getRuntimeInfo.bind(session);
session.getRuntimeInfo = async () => {
const { sessionId: _sessionId, ...rest } = await runtimeInfo();
return rest;
};
return session;
},
resumeSession: (handle, overrides, context, options) =>
base.resumeSession(handle, overrides, context, options),
fetchCatalog: (options) => base.fetchCatalog(options),
isAvailable: () => base.isAvailable(),
};
let holdCreate: Promise<void> | null = null;
let createEntered!: () => void;
const entered = new Promise<void>((resolve) => {
createEntered = resolve;
});
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
pluginLifecycle: {
emit: () => {},
before: async (name, request) => {
if (name === "agent.create.before" && holdCreate) {
createEntered();
await holdCreate;
}
return request;
},
},
});
const agentId = "00000000-0000-4000-8000-000000000701";
try {
await manager.createAgent({ provider: "codex", cwd: root }, agentId, {
workspaceId: "workspace-a",
metadata: { first: 1 },
});
await manager.closeAgent(agentId);
await manager.flush();
await storage.flush();
expect((await storage.get(agentId))?.persistence ?? null).toBeNull();
// The reopen is held in its create hook while a plugin writes metadata to the same agent.
let release!: () => void;
holdCreate = new Promise<void>((resolve) => {
release = resolve;
});
const reopened = ensureAgentLoaded(agentId, {
agentManager: manager,
agentStorage: storage,
logger,
});
await entered;
const saved = manager.setMetadata(agentId, { second: 2 });
release();
expect(await saved).toEqual({ first: 1, second: 2 });
await reopened;
expect(manager.getAgent(agentId)?.metadata).toEqual({ first: 1, second: 2 });
await manager.flush();
await storage.flush();
expect((await storage.get(agentId))?.metadata).toEqual({ first: 1, second: 2 });
} finally {
await manager.closeAgent(agentId).catch(() => undefined);
await manager.flush().catch(() => undefined);
await storage.flush().catch(() => undefined);
await rm(root, { recursive: true, force: true });
}
});
test.each([false, true])(
"loads history after its working directory is removed (archived: %s)",
async (archived) => {
@@ -5,7 +5,6 @@ import type { AgentManager, ManagedAgent } from "./agent-manager.js";
import type { AgentStorage } from "./agent-storage.js";
import {
buildConfigOverrides,
buildSessionConfig,
extractAttention,
extractTimestamps,
isStoredAgentProviderAvailable,
@@ -28,7 +27,7 @@ const pendingAgentInitializations = new Map<string, PendingAgentInitialization>(
export type AgentLoaderManager = Pick<
AgentManager,
| "createAgent"
| "createAgentFromRecord"
| "getAgent"
| "getRegisteredProviderIds"
| "hydrateTimelineFromProvider"
@@ -125,20 +124,9 @@ export async function ensureAgentLoaded(
} else {
// No provider handle to resume: this starts the agent's first session rather than
// bringing one back, so it stamps activity and carries no stored attention. Records
// without a handle never got far enough to accumulate either.
const config = buildSessionConfig(record, {
validProviders,
});
if (!config) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
snapshot = await deps.agentManager.createAgent(config, agentId, {
labels: record.labels,
workspaceId: record.workspaceId,
owner: record.owner,
background: record.background,
env: record.launchEnv,
});
// without a handle never got far enough to accumulate either. The manager rereads the
// record inside the agent's lane, so a write that lands after this read is not lost.
snapshot = await deps.agentManager.createAgentFromRecord(agentId);
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
}
File diff suppressed because it is too large Load Diff
+416 -125
View File
@@ -1,10 +1,12 @@
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
import { ProviderOptionsValidationError } from "./provider-options.js";
import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js";
import { AgentNotFoundError, ensureAgentLoaded } from "./agent-loading.js";
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import { applyRecordPatch } from "../record-patch.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
import { randomUUID } from "node:crypto";
import { basename, resolve } from "node:path";
@@ -33,7 +35,8 @@ import {
type AgentCreateSessionOptions,
type AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentFeature,
type AgentDraftControls,
type AgentOptionControl,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
@@ -59,7 +62,17 @@ import {
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { toStoredAgentRecord } from "./agent-projections.js";
import {
applyProviderOptionsPatch,
exposeFeatureValuesToHook,
convertLegacyOptions,
MalformedLegacyOptionError,
resolveAdapter,
foldHookFeatureValues,
type ProviderOptionsPatch,
} from "./option-controls.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
import {
@@ -100,7 +113,7 @@ import {
} from "./provider-subagents/store.js";
import { AgentBackgroundWorkStore, type AgentBackgroundWorkItem } from "./background-work/index.js";
import { withTimeout } from "../../utils/promise-timeout.js";
import { extractAttention } from "../persistence-hooks.js";
import { buildSessionConfig, extractAttention } from "../persistence-hooks.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -234,9 +247,6 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
if (record.config.thinkingOptionId != null) {
config.thinkingOptionId = record.config.thinkingOptionId;
}
if (record.config.featureValues != null) {
config.featureValues = record.config.featureValues;
}
if (record.config.providerOptions != null) {
config.providerOptions = record.config.providerOptions;
}
@@ -342,6 +352,7 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
export interface CreateAgentOptions {
labels?: Record<string, string>;
metadata?: AgentMetadata;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
@@ -467,7 +478,9 @@ interface ManagedAgentBase {
createdAt: Date;
updatedAt: Date;
availableModes: AgentMode[];
features?: AgentFeature[];
controls?: AgentOptionControl[];
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
effectiveOptions?: ProviderOptions;
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
bufferedPermissionResolutions: Map<
@@ -500,6 +513,8 @@ interface ManagedAgentBase {
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
*/
labels: Record<string, string>;
/** Plugin-owned data. Paseo persists and forwards it and never reads a key. */
metadata: AgentMetadata;
}
type ManagedAgentWithSession = ManagedAgentBase & {
@@ -559,6 +574,7 @@ type ActiveManagedAgent =
type LiveManagedAgent = ActiveManagedAgent;
type AgentLabelPatch = Record<string, string | null>;
type AgentMetadata = Record<string, JsonValue>;
function attachManagedTurnIdentity(
agent: ActiveManagedAgent,
@@ -606,6 +622,7 @@ interface WriteLabelsResult {
interface AgentMetadataPatch {
title?: string;
labels?: AgentLabelPatch;
metadata?: Record<string, JsonValue | null>;
}
const SYSTEM_ERROR_PREFIX = "[System Error]";
@@ -664,6 +681,11 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
/** The user labels and plugin metadata an agent carries; both default to empty. */
function agentEntityData(source?: { labels?: Record<string, string>; metadata?: AgentMetadata }) {
return { labels: source?.labels ?? {}, metadata: source?.metadata ?? {} };
}
// PASEO_WORKSPACE_ID lets an agent rename or archive its own workspace; several workspaces can
// share one cwd, so the agent can't work it out from PASEO_AGENT_CWD.
function agentIdentityEnv({
@@ -702,21 +724,6 @@ function validateAgentId(agentId: string, source: string): string {
return result.data;
}
function applyLabelPatch(
labels: Record<string, string>,
patch: AgentLabelPatch,
): Record<string, string> {
const nextLabels = { ...labels };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete nextLabels[key];
} else {
nextLabels[key] = value;
}
}
return nextLabels;
}
function buildExplicitTimelineSeedForRegister(
now: Date,
options:
@@ -1319,11 +1326,15 @@ export class AgentManager {
}
}
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listDraftControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
const client = this.requireClient(normalizedConfig.provider);
if (!normalizedConfig.model && !client.listFeatures) {
return [];
const options = this.effectiveOptionsFor(
normalizedConfig.provider,
normalizedConfig.providerOptions,
);
if (!normalizedConfig.model && !client.listControls) {
return { controls: [], options };
}
const available = await client.isAvailable();
if (!available) {
@@ -1332,13 +1343,18 @@ export class AgentManager {
);
}
if (client.listFeatures) {
return await client.listFeatures(normalizedConfig);
if (client.listControls) {
return await client.listControls(normalizedConfig);
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
// The options a session opens with, as create would report them, so the controls'
// selection matches what the first turn runs with.
return {
controls: session.controls ?? [],
options: this.openedEffectiveOptions({ session, launchedOptions: options }),
};
} finally {
try {
await session.close();
@@ -1453,28 +1469,75 @@ export class AgentManager {
return this.providerSubagents.fetchTimeline(parentAgentId, subagentId, options);
}
createAgent(
async createAgent(
config: AgentSessionConfig,
agentId: string | undefined,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(this.createAgentInternal(config, agentId, options));
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
return this.trackAgentRegistrationOperation(
this.runLifecycleMutation(resolvedAgentId, () =>
this.createAgentInternal(config, resolvedAgentId, options),
),
);
}
/**
* Opens a stored agent whose provider left no session to resume: its first session, created
* from the record as it is when the lane reaches it. A write queued ahead (`setMetadata`,
* `setLabels`) is in that record; one queued behind applies to the live agent.
*/
createAgentFromRecord(agentId: string): Promise<ManagedAgent> {
const resolvedAgentId = validateAgentId(agentId, "createAgentFromRecord");
return this.trackAgentRegistrationOperation(
this.runLifecycleMutation(resolvedAgentId, async () => {
const record = await this.requireRegistry().get(resolvedAgentId);
if (!record) throw new AgentNotFoundError(resolvedAgentId);
const config = buildSessionConfig(record, {
validProviders: this.getRegisteredProviderIds(),
});
if (!config) {
throw new Error(
`Agent ${resolvedAgentId} references unavailable provider '${record.provider}'`,
);
}
return this.createAgentInternal(config, resolvedAgentId, {
labels: record.labels,
metadata: record.metadata,
workspaceId: record.workspaceId,
owner: record.owner,
background: record.background,
env: record.launchEnv,
});
}),
);
}
private async createAgentInternal(
config: AgentSessionConfig,
agentId: string | undefined,
resolvedAgentId: string,
options: CreateAgentOptions,
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
config = this.withConvertedOptions(config);
if (this.pluginLifecycle && !config.internal) {
const sent = exposeFeatureValuesToHook(config);
const request = await this.pluginLifecycle.before("agent.create.before", {
config,
config: sent,
env: options.env,
metadata: options.metadata ?? {},
});
config = { ...request.config, internal: config.internal };
options = { ...options, env: request.env };
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
// against the previous SDK edits `config.featureValues`.
config = {
...foldHookFeatureValues({
returned: request.config,
sent,
convert: (values) => this.convertLegacyOptions(request.config.provider, values),
}),
internal: config.internal,
};
options = { ...options, env: request.env, metadata: request.metadata ?? options.metadata };
}
await this.deleteAgentState(resolvedAgentId);
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
@@ -1499,14 +1562,18 @@ export class AgentManager {
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
metadata: options.metadata ?? {},
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
launchedOptions,
labels: options.labels,
metadata: options.metadata,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
@@ -1541,6 +1608,7 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
@@ -1574,6 +1642,7 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
@@ -1626,10 +1695,11 @@ export class AgentManager {
purpose,
workspaceId,
title: record?.title ?? mergedConfig.title ?? null,
labels: record?.labels ?? options?.labels ?? {},
...agentEntityData(record ?? options),
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
const session = await client.resumeSession(
handle,
providerLaunchConfig,
@@ -1639,7 +1709,11 @@ export class AgentManager {
if (purpose === "history") this.historySessions.add(session);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
launchedOptions,
...options,
// The caller read its metadata before this turn in the lane; a setMetadata queued ahead of
// it has saved a newer record, the one the open hooks saw.
...(record ? { metadata: record.metadata } : {}),
workspaceId,
launchEnv,
persistence: handle,
@@ -1696,6 +1770,7 @@ export class AgentManager {
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
const imported = await client.importSession(
{
providerHandleId: input.providerHandleId,
@@ -1719,6 +1794,7 @@ export class AgentManager {
handedToRegistration = true;
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
launchedOptions,
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
@@ -1801,6 +1877,7 @@ export class AgentManager {
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
metadata: existing.metadata,
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1825,6 +1902,7 @@ export class AgentManager {
this.assertAcceptingAgentRegistrations();
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
session = handle
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext);
@@ -1843,7 +1921,9 @@ export class AgentManager {
// Preserve existing labels and timeline during reload.
handedToRegistration = true;
return this.registerSession(session, storedConfig, agentId, {
launchedOptions,
labels: existing.labels,
metadata: existing.metadata,
workspaceId: existing.workspaceId,
owner: existing.owner,
background: existing.background,
@@ -2288,7 +2368,11 @@ export class AgentManager {
createdAt: new Date(record.createdAt),
updatedAt,
availableModes: [],
features: record.features,
controls: record.controls,
effectiveOptions: this.effectiveOptionsFor(
record.provider,
record.config?.providerOptions ?? undefined,
),
currentModeId: record.lastModeId ?? null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
@@ -2309,93 +2393,212 @@ export class AgentManager {
internal: record.internal,
background: record.background,
labels: record.labels,
metadata: record.metadata,
},
});
}
// Mode, model, thinking and options change inside the agent's lifecycle lane, like every other
// mutation of one agent. A provider session therefore never has two of `setMode`, `setModel`,
// `setThinkingOption` and `applyOptions` in flight at once, and none of them overlaps an open,
// reload or close; providers do no sequencing of their own.
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
const agent = await this.ensureInteractiveSession(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
// Update runtimeInfo to reflect the new mode
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const notice = (await agent.session.setMode(modeId)) ?? null;
await this.drainSessionEvents(agentId);
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
agent.config.modeId = currentMode ?? undefined;
agent.currentModeId = currentMode;
// Update runtimeInfo to reflect the new mode
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, modeId: currentMode };
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
});
}
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
const agent = await this.ensureInteractiveSession(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const normalizedModelId =
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
const selection = await agent.session.setModel?.(normalizedModelId);
await this.drainSessionEvents(agentId);
const selection = await agent.session.setModel?.(normalizedModelId);
await this.drainSessionEvents(agentId);
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
// A reset clears the preference even when the provider retains its active model.
let effectiveModelId = normalizedModelId;
if (normalizedModelId !== null && selection) effectiveModelId = selection.model;
agent.config.model = effectiveModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
agent.config.model = effectiveModelId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = { ...agent.runtimeInfo, model: effectiveModelId };
}
this.refreshSessionPersistence(agent);
this.touchUpdatedAt(agent);
this.emitState(agent);
});
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null,
): Promise<AgentProviderNotice | null> {
const agent = await this.ensureInteractiveSession(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const normalizedThinkingOptionId =
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
? thinkingOptionId
: null;
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
}
await this.drainSessionEvents(agentId);
let notice: AgentProviderNotice | null = null;
if (agent.session.setThinkingOption) {
notice = (await agent.session.setThinkingOption(normalizedThinkingOptionId)) ?? null;
}
await this.drainSessionEvents(agentId);
let effectiveThinkingOptionId = normalizedThinkingOptionId;
const runtimeInfo = await agent.session.getRuntimeInfo();
if (runtimeInfo.thinkingOptionId !== undefined) {
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
}
let effectiveThinkingOptionId = normalizedThinkingOptionId;
const runtimeInfo = await agent.session.getRuntimeInfo();
if (runtimeInfo.thinkingOptionId !== undefined) {
effectiveThinkingOptionId = runtimeInfo.thinkingOptionId;
}
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: effectiveThinkingOptionId,
};
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
agent.config.thinkingOptionId = effectiveThinkingOptionId ?? undefined;
if (agent.runtimeInfo) {
agent.runtimeInfo = {
...agent.runtimeInfo,
thinkingOptionId: effectiveThinkingOptionId,
};
}
this.touchUpdatedAt(agent);
this.emitState(agent);
return notice;
});
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
const agent = await this.ensureInteractiveSession(agentId);
/**
* Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider
* gets each key the patch touches, resolved against the `config.json` defaults, with `null`
* where nothing is set. The patch and whatever the provider says it ended up with are stored in
* one step once the provider accepts. Patches to one agent run one at a time, in arrival order.
*/
async setAgentOptions(agentId: string, requested: ProviderOptionsPatch): Promise<void> {
return this.runLifecycleMutation(agentId, async () => {
const agent = await this.ensureInteractiveSessionUnlocked(agentId);
const patch = this.convertLegacyOptions(agent.provider, requested);
let ended: ProviderOptionsPatch = {};
let sent: ProviderOptionsPatch = {};
if (Object.keys(patch).length > 0) {
// A provider that cannot apply options to a running agent must not store them either.
if (!agent.session.applyOptions) {
throw new ProviderOptionsValidationError(
agent.provider,
Object.keys(patch).map((key) => ({
path: [key],
message: "cannot change on a running session; it applies when the agent is opened",
})),
);
}
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
const resolved = this.effectiveOptionsFor(agent.provider, set);
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
ended = (await agent.session.applyOptions(sent)) ?? {};
// Changes the provider made on its own while applying are older than its final answer.
await this.drainSessionEvents(agentId);
}
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
this.updateEffectiveOptions({ agent, sent, reported: ended });
this.touchUpdatedAt(agent);
this.emitState(agent);
});
}
if (!agent.session.setFeature) {
throw new Error("Agent session does not support setting features");
/** The config as the opened session left it: its own difference from the options it was given. */
private withOpenedOptions(input: {
config: AgentSessionConfig;
session: AgentSession;
}): AgentSessionConfig {
const { config } = input;
const opened = input.session.openOptionsPatch;
if (!opened || Object.keys(opened).length === 0) return config;
const own = applyProviderOptionsPatch({ options: config.providerOptions, patch: opened });
return { ...config, providerOptions: Object.keys(own).length > 0 ? own : undefined };
}
private storeOptionsPatch(input: { agent: ManagedAgent; patch: ProviderOptionsPatch }): void {
const { agent } = input;
const own = applyProviderOptionsPatch({
options: agent.config.providerOptions,
patch: input.patch,
});
agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined;
}
/**
* What the running session has: the options it opened with, moved only by what an apply sent
* it (defaults merged in, `null` where a removal left nothing) and by what the provider reports
* back. The agent's own stored options keep only the raw patch.
*/
private updateEffectiveOptions(input: {
agent: ManagedAgent;
sent: ProviderOptionsPatch;
reported: ProviderOptionsPatch;
}): void {
input.agent.effectiveOptions = applyProviderOptionsPatch({
options: input.agent.effectiveOptions,
patch: { ...input.sent, ...input.reported },
});
}
/**
* Defaults merge into what the session was given; what it reports it ended up with comes
* after, so an option it dropped does not come back from a default.
*/
private openedEffectiveOptions(input: {
session: AgentSession;
launchedOptions: ProviderOptions;
}): ProviderOptions {
return applyProviderOptionsPatch({
options: input.launchedOptions,
patch: input.session.openOptionsPatch ?? {},
});
}
/** The options a client opens with: its own defaults merged under the agent's. */
private launchOptionsFor(input: {
client: AgentClient;
own: ProviderOptions | undefined;
}): ProviderOptions {
return input.client.effectiveOptions?.(input.own) ?? input.own ?? {};
}
/**
* What an agent of this provider runs with, given its own options: the defaults under them.
* Stored records are read through here before they are reopened, so the legacy conversion
* runs here too; a record converted on open passes through unchanged. A legacy value the
* conversion cannot read stays as stored: this describes the record, and opening or changing
* the agent is what rejects the value.
*/
effectiveOptionsFor(provider: AgentProvider, own: ProviderOptions | undefined): ProviderOptions {
const client = this.clients.get(provider);
const converted = this.convertStoredOptions(provider, own);
return client ? this.launchOptionsFor({ client, own: converted }) : (converted ?? {});
}
private convertStoredOptions(
provider: AgentProvider,
own: ProviderOptions | undefined,
): ProviderOptions | undefined {
try {
return this.convertLegacyOptions(provider, own);
} catch (error) {
if (error instanceof MalformedLegacyOptionError) return own;
throw error;
}
await agent.session.setFeature(featureId, value);
await this.drainSessionEvents(agentId);
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
this.touchUpdatedAt(agent);
this.emitState(agent);
}
async setTitle(agentId: string, title: string): Promise<void> {
@@ -2423,10 +2626,30 @@ export class AgentManager {
});
}
/** Applies a plugin-metadata patch (null removes a key) and returns the stored result. */
async setMetadata(
agentId: string,
patch: Record<string, JsonValue | null>,
): Promise<AgentMetadata> {
return this.runLifecycleMutation(agentId, async () => {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.metadata = applyRecordPatch<JsonValue>(liveAgent.metadata, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
return liveAgent.metadata;
}
const record = await this.writeStoredMetadata(agentId, { metadata: patch });
if (!record.internal) this.dispatchStoredAgentState(record);
return record.metadata;
});
}
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
liveAgent.labels = applyRecordPatch(liveAgent.labels, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
@@ -2451,7 +2674,8 @@ export class AgentManager {
const nextRecord = {
...record,
...(patch.title ? { title: patch.title } : {}),
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
...(patch.labels ? { labels: applyRecordPatch(record.labels, patch.labels) } : {}),
...(patch.metadata ? { metadata: applyRecordPatch(record.metadata, patch.metadata) } : {}),
updatedAt: this.nextStoredUpdatedAt(record),
};
await registry.upsert(nextRecord);
@@ -2648,7 +2872,7 @@ export class AgentManager {
const nextRecord: StoredAgentRecord = {
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
...(updates?.labels ? { labels: applyRecordPatch(record.labels, updates.labels) } : {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
};
@@ -2921,22 +3145,25 @@ export class AgentManager {
private async ensureInteractiveSession(agentId: string): Promise<ActiveManagedAgent> {
const agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
await this.trackAgentRegistrationOperation(
this.runLifecycleMutation(agentId, async () => {
const record = await this.registry?.get(agentId);
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
const current = this.requireSessionAgent(agentId);
if (this.historySessions.has(current.session)) {
// Recheck inside the lane: another prompt may already have upgraded it.
// Reload validates the directory before releasing readable history.
await this.reloadAgentSessionInternal(agentId);
}
}),
await this.runLifecycleMutation(agentId, () =>
this.ensureInteractiveSessionUnlocked(agentId),
);
}
return this.requireSessionAgent(agentId);
}
/** Inside the agent's lane: a history session is upgraded before anything is applied to it. */
private async ensureInteractiveSessionUnlocked(agentId: string): Promise<ActiveManagedAgent> {
const agent = this.requireSessionAgent(agentId);
if (this.historySessions.has(agent.session)) {
const record = await this.registry?.get(agentId);
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
// Reload validates the directory before releasing readable history.
await this.trackAgentRegistrationOperation(this.reloadAgentSessionInternal(agentId));
}
return this.requireSessionAgent(agentId);
}
private async *streamAfterHistoryLoad(
agentId: string,
prompt: AgentPromptInput,
@@ -3969,13 +4196,14 @@ export class AgentManager {
private async registerSession(
session: AgentSession,
config: AgentSessionConfig,
openedConfig: AgentSessionConfig,
agentId: string,
options?: {
options: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
@@ -3989,6 +4217,12 @@ export class AgentManager {
* attention come from what was already recorded, and installing the session is not
* activity in it.
*/
/**
* The effective options the opening resolved before the session started. Registration
* starts from these rather than reading the defaults again, which may have been edited
* while the provider was opening.
*/
launchedOptions: ProviderOptions;
restoring?: boolean;
initialTitle?: string | null;
publishWhenReady?: boolean;
@@ -4001,6 +4235,7 @@ export class AgentManager {
let registered = false;
try {
this.assertAcceptingAgentRegistrations();
const config = this.withOpenedOptions({ config: openedConfig, session });
const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
@@ -4022,6 +4257,10 @@ export class AgentManager {
resolvedAgentId,
session,
config,
effectiveOptions: this.openedEffectiveOptions({
session,
launchedOptions: options.launchedOptions,
}),
now,
durableTimelineHasRows,
background: options?.background === true,
@@ -4167,6 +4406,7 @@ export class AgentManager {
resolvedAgentId: string;
session: AgentSession;
config: AgentSessionConfig;
effectiveOptions: ProviderOptions;
now: Date;
durableTimelineHasRows: boolean;
background: boolean;
@@ -4176,6 +4416,7 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
historyPrimed?: boolean;
lastUsage?: AgentUsage;
lastError?: string;
@@ -4187,7 +4428,15 @@ export class AgentManager {
}
| undefined;
}): ActiveManagedAgent {
const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
const {
resolvedAgentId,
session,
config,
effectiveOptions,
now,
durableTimelineHasRows,
options,
} = params;
return {
id: resolvedAgentId,
provider: config.provider,
@@ -4198,6 +4447,8 @@ export class AgentManager {
session,
capabilities: session.capabilities,
config,
// What the session opened with. Provider defaults edited later reach it when it next opens.
effectiveOptions,
runtimeInfo: undefined,
lifecycle: "initializing",
createdAt: options?.createdAt ?? now,
@@ -4225,7 +4476,7 @@ export class AgentManager {
attention: resolveInitialAttention(options?.attention),
internal: config.internal ?? false,
background: params.background,
labels: options?.labels ?? {},
...agentEntityData(options),
} as ActiveManagedAgent;
}
@@ -4513,7 +4764,7 @@ export class AgentManager {
agent.pendingPermissions.clear();
}
this.syncFeaturesFromSession(agent);
this.syncControls(agent);
await this.refreshRuntimeInfo(agent, options);
}
@@ -4859,6 +5110,18 @@ export class AgentManager {
);
}
private onStreamOptionsChanged(params: {
agent: ActiveManagedAgent;
patch: ProviderOptionsPatch;
flags: StreamEventFlags;
}): void {
const { agent, patch, flags } = params;
this.storeOptionsPatch({ agent, patch });
this.updateEffectiveOptions({ agent, sent: {}, reported: patch });
flags.shouldDispatchEvent = false;
this.emitState(agent);
}
private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent;
event: AgentStreamEvent;
@@ -4910,6 +5173,9 @@ export class AgentManager {
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "options_changed":
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
return undefined;
case "timeline":
return this.onStreamTimelineEvent({ agent, event, options, flags });
case "turn_completed":
@@ -5387,7 +5653,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
this.syncFeaturesFromSession(agent);
this.syncControls(agent);
this.logger.trace(
{
@@ -5409,9 +5675,9 @@ export class AgentManager {
});
}
private syncFeaturesFromSession(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.features) {
agent.features = agent.session.features;
private syncControls(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.controls) {
agent.controls = agent.session.controls;
}
}
@@ -5674,7 +5940,7 @@ export class AgentManager {
config: AgentSessionConfig,
options: NormalizeConfigOptions = {},
): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...config };
const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) };
// Always resolve cwd to absolute path for consistent history file lookup
if (normalized.cwd) {
@@ -5746,6 +6012,30 @@ export class AgentManager {
}
}
/** The adapter a provider id runs on: a custom provider is named by the one it extends. */
private adapterOf(provider: AgentProvider): AgentProvider {
return resolveAdapter({
provider,
extendsOf: (id) => this.providerDefinitions.get(id)?.derivedFromProviderId,
});
}
/**
* Options older data carries are converted for the adapter the provider runs on, before
* defaults merge and before anything is stored; nothing past the manager sees the old keys.
*/
private convertLegacyOptions<T extends ProviderOptions | undefined>(
provider: AgentProvider,
options: T,
): T {
return convertLegacyOptions({ provider: this.adapterOf(provider), options }) as T;
}
private withConvertedOptions(config: AgentSessionConfig): AgentSessionConfig {
const providerOptions = this.convertLegacyOptions(config.provider, config.providerOptions);
return providerOptions === config.providerOptions ? config : { ...config, providerOptions };
}
private async prepareSessionConfig(
config: AgentSessionConfig,
agentId: string,
@@ -5798,6 +6088,7 @@ export class AgentManager {
workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
},
): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
@@ -5815,7 +6106,7 @@ export class AgentManager {
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
labels: opening?.labels ?? {},
...agentEntityData(opening),
tools: [],
mcpServers: {},
};
@@ -11,13 +11,16 @@ import {
} from "./agent-projections.js";
import type { AgentSession } from "./agent-sdk-types.js";
import type {
AgentFeature,
AgentOptionControl,
ImportableProviderSession,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
} from "./agent-sdk-types.js";
/** A provider with no defaults of its own: the effective options are the agent's own. */
const ownOptionsOnly = (_provider: string, own: Record<string, unknown> | undefined) => own ?? {};
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
@@ -129,16 +132,6 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
return { ...base, ...overrides };
}
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
return {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: true,
...overrides,
};
}
describe("toStoredAgentRecord", () => {
it("captures lifecycle metadata, config, and persistence", () => {
const agent = createManagedAgent({
@@ -392,7 +385,7 @@ describe("toAgentPayload", () => {
}),
);
const payload = buildStoredAgentPayload(record, ["codex"]);
const payload = buildStoredAgentPayload(record, ["codex"], ownOptionsOnly);
expect(record.persistence?.metadata).toEqual({
conversationId: "conversation-stored",
@@ -408,15 +401,81 @@ describe("toAgentPayload", () => {
});
});
it("projects a closed agent's stored controls and options", () => {
const controls: AgentOptionControl[] = [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
},
];
const record = toStoredAgentRecord(
createManagedAgent({
provider: "codex",
lifecycle: "closed",
controls,
config: {
provider: "codex",
providerOptions: { service_tier: "priority", sandbox_mode: "read-only" },
},
}),
);
const payload = buildStoredAgentPayload(record, ["codex"], ownOptionsOnly);
expect(payload.status).toBe("closed");
expect(payload.controls).toEqual(controls);
expect(payload.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" });
expect(payload.features).toEqual([
{
type: "select",
id: "service_tier",
label: "Speed",
value: "priority",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
],
},
]);
});
it("projects a closed agent's options with the provider defaults under its own, like a live one", () => {
const record = toStoredAgentRecord(
createManagedAgent({
provider: "claude",
lifecycle: "closed",
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
config: { provider: "claude", providerOptions: {} },
}),
);
const withConfigDefaults = (_provider: string, own: Record<string, unknown> | undefined) => ({
fast_mode: true,
...own,
});
const payload = buildStoredAgentPayload(record, ["claude"], withConfigDefaults);
expect(payload.options).toEqual({ fast_mode: true });
expect(payload.features).toEqual([
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
]);
});
it("carries a stored lastError onto the payload", () => {
const record = toStoredAgentRecord(
createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }),
);
expect(buildStoredAgentPayload(record, ["codex"]).lastError).toBe("boom");
expect(buildStoredAgentPayload({ ...record, lastError: null }, ["codex"])).not.toHaveProperty(
"lastError",
);
expect(buildStoredAgentPayload(record, ["codex"], ownOptionsOnly).lastError).toBe("boom");
expect(
buildStoredAgentPayload({ ...record, lastError: null }, ["codex"], ownOptionsOnly),
).not.toHaveProperty("lastError");
});
it("omits lastUsage when not available", () => {
@@ -475,13 +534,44 @@ describe("toAgentPayload", () => {
});
});
it("includes features in the snapshot payload", () => {
const features = [createFeature()];
const agent = createManagedAgent({ features });
it("projects controls, effective options, and legacy features from the same source", () => {
const controls: AgentOptionControl[] = [
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "fast", label: "Fast" },
],
},
];
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
// Options without a control are part of the snapshot too.
const agent = createManagedAgent({
controls,
effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" },
});
const payload = toAgentPayload(agent);
expect(payload.features).toEqual(features);
expect(payload.controls).toEqual(controls);
expect(payload.options).toEqual({ fast_mode: true, sandbox_mode: "workspace-write" });
expect(payload.features).toEqual([
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
{
type: "select",
id: "service_tier",
label: "Speed",
value: "default",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
},
]);
});
});
@@ -7,7 +7,7 @@ import type {
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type {
AgentCapabilityFlags,
AgentFeature,
AgentOptionControl,
AgentMetadata,
AgentMode,
AgentPermissionRequest,
@@ -18,7 +18,9 @@ import type {
AgentUsage,
ImportableProviderSession,
} from "./agent-sdk-types.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { ManagedAgent } from "./agent-manager.js";
import { featuresFromControls } from "@getpaseo/protocol/option-controls";
import type { JsonValue } from "../json-utils.js";
import {
isStoredAgentProviderAvailable,
@@ -92,12 +94,13 @@ export function toStoredAgentRecord(
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
title: options?.title ?? null,
labels: agent.labels,
metadata: agent.metadata,
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo,
features: normalizeFeatures(agent.features),
controls: normalizeControls(agent.controls),
persistence,
lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention,
@@ -144,11 +147,17 @@ export function toAgentPayload(
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
availableModes: cloneAvailableModes(agent.availableModes),
features: normalizeFeatures(agent.features),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
features: featuresFromControls({ controls: agent.controls, options: agent.effectiveOptions }),
...(agent.effectiveOptions
? { options: sanitizeEffectiveOptions(agent.effectiveOptions) }
: {}),
controls: normalizeControls(agent.controls),
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: projectPersistenceHandleForWire(agent.persistence),
title: options?.title ?? null,
labels: agent.labels,
metadata: agent.metadata,
archivedAt: options?.archivedAt ?? null,
...visibilityFlags(agent),
};
@@ -218,9 +227,32 @@ function visibilityFlags(agent: {
};
}
/** The options an agent runs with, given the options it owns; see `toAgentPayload`. */
export type EffectiveOptionsResolver = (
provider: AgentProvider,
own: ProviderOptions | undefined,
) => ProviderOptions;
function buildStoredOptionControls(
record: StoredAgentRecord,
effectiveOptionsFor: EffectiveOptionsResolver,
): Pick<AgentSnapshotPayload, "features" | "options" | "controls"> {
const controls = normalizeControls(record.controls);
const options = sanitizeEffectiveOptions(
effectiveOptionsFor(record.provider, record.config?.providerOptions ?? undefined),
);
return {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
features: featuresFromControls({ controls, options }),
options,
controls,
};
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
validProviders: Iterable<AgentProvider>,
effectiveOptionsFor: EffectiveOptionsResolver,
): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
@@ -239,6 +271,7 @@ export function buildStoredAgentPayload(
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const runtimeInfo = buildStoredRuntimeInfo(record);
const optionControls = buildStoredOptionControls(record, effectiveOptionsFor);
const providerAvailable = isStoredAgentProviderAvailable(record, validProviders);
const persistence = projectPersistenceHandleForWire(
buildStoredPersistenceHandle(record, validProviders),
@@ -263,6 +296,7 @@ export function buildStoredAgentPayload(
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
...optionControls,
pendingPermissions: [],
persistence,
title: record.title ?? null,
@@ -272,6 +306,7 @@ export function buildStoredAgentPayload(
archivedAt: record.archivedAt ?? null,
...visibilityFlags(record),
labels: normalizeLabels(record.labels),
metadata: record.metadata,
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
...(providerAvailable ? {} : { providerUnavailable: true }),
};
@@ -388,12 +423,6 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.thinkingOptionId) {
serializable.thinkingOptionId = config.thinkingOptionId;
}
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
const featureValues = sanitizeMetadata(config.featureValues);
if (featureValues !== undefined) {
serializable.featureValues = featureValues;
}
}
if (config.providerOptions !== undefined) {
const providerOptions = sanitizeOptionalJson(config.providerOptions);
if (providerOptions && isJsonObject(providerOptions)) {
@@ -469,8 +498,15 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
return modes.map((mode) => ({ ...mode }));
}
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
function normalizeControls(
controls: AgentOptionControl[] | null | undefined,
): AgentOptionControl[] {
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
}
function sanitizeEffectiveOptions(options: Record<string, unknown>): ProviderOptions {
const sanitized = sanitizeOptionalJson(options);
return sanitized && isJsonObject(sanitized) ? sanitized : {};
}
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
@@ -1,7 +1,7 @@
import type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
AgentProviderNotice,
AgentTaskItem,
JsonValue,
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
AgentProviderNotice,
AgentTaskItem,
};
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
export interface ResolveAgentCreateConfigInput {
provider: AgentProvider;
requestedMode: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
parent: AgentCreateConfigParent | null;
unattended: boolean;
availableModes: AgentMode[] | undefined;
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
export interface ResolveAgentCreateConfigResult {
modeId: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
}
export interface AgentCreateConfigUnattendedInput {
modeId: string | null;
config: AgentSessionConfig;
features?: AgentFeature[];
controls?: AgentOptionControl[];
availableModes: AgentMode[];
}
@@ -429,6 +429,14 @@ export type AgentStreamEvent =
provider: AgentProvider;
thinkingOptionId: string | null;
}
/**
* 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. Only for changes that happen on the provider's own initiative: the result
* of opening goes in `openOptionsPatch` and the result of `applyOptions` is its return value.
*/
| { type: "options_changed"; provider: AgentProvider; patch: Record<string, unknown> }
| {
type: "turn_failed";
provider: AgentProvider;
@@ -616,7 +624,6 @@ export interface AgentSessionConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
title?: string | null;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
@@ -685,7 +692,14 @@ export interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
/** Composer controls over top-level option keys. Values come from the agent's effective options. */
readonly controls?: AgentOptionControl[];
/**
* How the session ended up different from the options it was opened with, as a patch (`null`
* removes a key). Set by the time `createSession`/`resumeSession` resolves; the daemon stores it
* before the agent is published.
*/
readonly openOptionsPatch?: Record<string, unknown>;
/** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[];
@@ -725,7 +739,20 @@ export interface AgentSession {
* Returning void means the requested selection was accepted. No status read is required. */
setModel?(modelId: string | null): Promise<void | { model: string | null }>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
/**
* Apply an options patch. `options` holds only the keys the patch touches, resolved against
* the `config.json` defaults; `null` means nothing is set for the key, so return it to the
* session's own default if that can be done. Leave every other setting alone. Throw to reject a
* value, or a key the running session cannot apply (one the provider reads only when it
* opens): the daemon stores the patch only when this resolves, so a stored option always
* describes what runs, and the call is all-or-nothing.
* Emit nothing from here. Return the options the session ended up with when they differ from
* the request (a patch, `null` removes a key), for example a value it kept because it could not
* be reverted; the daemon stores it together with the patch. When a throw leaves the session's
* state moved anyway, report the moved values once as `options_changed` after the call has
* ended, so the stored options still match what runs.
*/
applyOptions?(options: ProviderOptions): Promise<Record<string, unknown> | void>;
revertConversation?(input: { messageId: string }): Promise<void>;
revertFiles?(input: { messageId: string }): Promise<void>;
revertBoth?(input: { messageId: string }): Promise<void>;
@@ -772,14 +799,25 @@ export interface ResolveAgentDefaultModeInput {
signal?: AbortSignal;
}
export interface AgentDraftControls {
controls: AgentOptionControl[];
options: ProviderOptions;
}
export interface AgentClient {
readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags;
/**
* Create the agent with `config.providerOptions` (defaults already merged in). Emit nothing
* about options while creating. If the agent ended up different from the options, say so in the
* session's `openOptionsPatch`.
*/
createSession(
config: AgentSessionConfig,
launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions,
): Promise<AgentSession>;
/** Same contract as `createSession`: open with the options, report any difference in `openOptionsPatch`. */
resumeSession(
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
@@ -808,7 +846,14 @@ export interface AgentClient {
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
/**
* The controls of a draft, with the options a session opened from `config` would run with:
* `config.providerOptions` (defaults already merged in) plus what the provider resolves
* natively for the ones nobody set.
*/
listControls?(config: AgentSessionConfig): Promise<AgentDraftControls>;
/** Effective options: provider defaults from `config.json` deep-merged with the agent's own. */
effectiveOptions?(own: ProviderOptions | undefined): ProviderOptions;
listImportableSessions?(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os";
import path from "node:path";
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
import { promises as fs } from "node:fs";
import { Session as InspectorSession } from "node:inspector/promises";
import type { HeapProfiler } from "node:inspector";
@@ -46,9 +46,6 @@ function buildManagedAgentConfig(
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
config.featureValues = configOverrides.featureValues;
}
return config;
}
@@ -134,6 +131,14 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
};
}
function findRecordFile(baseDir: string, agentId: string): string {
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
const candidate = path.join(baseDir, entry.name, `${agentId}.json`);
if (entry.isDirectory() && existsSync(candidate)) return candidate;
}
throw new Error(`No stored record for ${agentId}`);
}
describe("AgentStorage", () => {
let tmpDir: string;
let storagePath: string;
@@ -292,62 +297,77 @@ describe("AgentStorage", () => {
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
});
test("applySnapshot stores and reloads featureValues when present", async () => {
test("applySnapshot stores and reloads composer values as providerOptions", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-feature-values",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-feature-values");
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-feature-values");
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
});
test("applySnapshot keeps featureValues absent when they were never set", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-no-feature-values",
id: "agent-provider-options",
config: { providerOptions: { fast_mode: true } },
}),
);
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-no-feature-values");
expect(persisted?.config?.featureValues).toBeUndefined();
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
});
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-resume-overrides",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-resume-overrides");
expect(record).not.toBeNull();
expect(buildConfigOverrides(record!)).toMatchObject({
const persisted = await reloaded.get("agent-provider-options");
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
expect(buildConfigOverrides(persisted!)).toMatchObject({
cwd: "/tmp/project",
featureValues: {
fast_mode: true,
},
providerOptions: { fast_mode: true },
});
});
test("a record stored with featureValues loads them in providerOptions", async () => {
await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
const file = findRecordFile(storagePath, "agent-legacy");
const stored = JSON.parse(await fs.readFile(file, "utf8"));
stored.config.providerOptions = { allowedTools: ["Read"], fast_mode: false };
stored.config.featureValues = { fast_mode: true, service_tier: "fast" };
delete stored.controls;
stored.features = [
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
{
type: "select",
id: "service_tier",
label: "Speed",
value: "fast",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
},
];
await fs.writeFile(file, JSON.stringify(stored));
const reloaded = new AgentStorage(storagePath, createTestLogger());
const persisted = await reloaded.get("agent-legacy");
// An explicit providerOptions key wins over the same featureValues key.
expect(persisted?.config?.providerOptions).toEqual({
allowedTools: ["Read"],
fast_mode: false,
service_tier: "fast",
});
expect(persisted?.config).not.toHaveProperty("featureValues");
expect(persisted).not.toHaveProperty("features");
expect(persisted?.controls).toEqual([
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "fast", label: "Fast" },
],
},
]);
await reloaded.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
const rewritten = JSON.parse(await fs.readFile(file, "utf8"));
expect(rewritten.config).not.toHaveProperty("featureValues");
expect(rewritten).not.toHaveProperty("features");
});
test("applySnapshot preserves original createdAt timestamp", async () => {
const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
@@ -2,19 +2,23 @@ import { promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { z } from "zod";
import type { Logger } from "pino";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { writeJsonFileAtomic } from "../atomic-file.js";
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
import { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
import { controlsFromFeatures } from "@getpaseo/protocol/option-controls";
import { foldFeatureValues } from "./option-controls.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
.object({
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; folded on parse.
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
toolPolicy: z
@@ -42,7 +46,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
.nullable()
.optional();
const STORED_AGENT_SCHEMA = z.object({
const RAW_STORED_AGENT_SCHEMA = z.object({
id: z.string(),
provider: z.string(),
cwd: z.string(),
@@ -53,6 +57,7 @@ const STORED_AGENT_SCHEMA = z.object({
lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
@@ -68,6 +73,8 @@ const STORED_AGENT_SCHEMA = z.object({
extra: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
controls: z.array(AgentOptionControlSchema).optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; becomes controls.
features: z.array(AgentFeatureSchema).optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
lastError: z.string().nullable().optional(),
@@ -85,14 +92,32 @@ export type SerializableAgentConfig = Pick<
| "modeId"
| "model"
| "thinkingOptionId"
| "featureValues"
| "providerOptions"
| "toolPolicy"
| "systemPrompt"
| "mcpServers"
>;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
type RawStoredAgentRecord = z.infer<typeof RAW_STORED_AGENT_SCHEMA>;
type RawStoredAgentConfig = NonNullable<RawStoredAgentRecord["config"]>;
/** Records carry `providerOptions` and `controls` only; legacy fields are folded when read. */
export type StoredAgentRecord = Omit<RawStoredAgentRecord, "config" | "features"> & {
config?: Omit<RawStoredAgentConfig, "featureValues"> | null;
};
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
function foldLegacyRecordFields(record: RawStoredAgentRecord): StoredAgentRecord {
const { features, config, controls, ...rest } = record;
return {
...rest,
...(config ? { config: foldFeatureValues(config) } : { config }),
...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}),
};
}
const STORED_AGENT_SCHEMA = RAW_STORED_AGENT_SCHEMA.transform(foldLegacyRecordFields);
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
return STORED_AGENT_SCHEMA.parse(value);
}
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
availableModes: availableModeIds,
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
}),
featureValues: input.featureValues,
providerOptions: input.providerOptions,
};
}
@@ -147,7 +147,7 @@ test("session create applies the resolved mode from the provider create config",
const stub = createProviderSnapshotManagerStub();
stub.resolveCreateConfig.mockResolvedValue({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const dependencies: Parameters<typeof createAgentCommand>[0] = {
agentManager: {
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
}),
undefined,
expect.anything(),
@@ -26,6 +26,8 @@ import {
inheritWorkspaceBackground,
resolveCreateAgentIntent,
} from "./intent.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { foldFeatureValues, type LegacyFeatureValues } from "../option-controls.js";
/** How a workspace this creation makes inherits its creator's visibility. */
interface McpWorkspaceIntent {
@@ -57,7 +59,8 @@ export interface CreateAgentFromSessionInput {
kind: "session";
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
agentId?: string;
config: AgentSessionConfig;
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
config: AgentSessionConfig & LegacyFeatureValues;
workspaceId: string;
background: boolean;
initialPrompt?: string;
@@ -67,6 +70,7 @@ export interface CreateAgentFromSessionInput {
images?: Array<{ data: string; mimeType: string }>;
attachments?: AgentAttachment[];
labels: Record<string, string>;
metadata?: CreateAgentOptions["metadata"];
env?: Record<string, string>;
provisionalTitle: string | null;
firstAgentContext: FirstAgentContext;
@@ -81,7 +85,7 @@ export interface CreateAgentFromMcpInput {
cwd?: string;
workspaceId?: string;
thinking?: string;
features?: Record<string, unknown>;
providerOptions?: ProviderOptions;
labels?: Record<string, string>;
mode?: string;
unattended?: boolean;
@@ -231,19 +235,19 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim();
const builtSessionConfig = input.config;
const builtSessionConfig = foldFeatureValues(input.config);
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: builtSessionConfig.cwd,
provider: builtSessionConfig.provider,
requestedMode: builtSessionConfig.modeId,
featureValues: builtSessionConfig.featureValues,
providerOptions: builtSessionConfig.providerOptions,
parent: null,
unattended: false,
});
const sessionConfig: AgentSessionConfig = {
...builtSessionConfig,
modeId: resolvedCreateConfig.modeId,
featureValues: resolvedCreateConfig.featureValues,
providerOptions: resolvedCreateConfig.providerOptions,
};
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
@@ -261,6 +265,7 @@ async function resolveSessionCreateAgent(
config: sessionConfig,
createOptions: {
labels: input.labels,
metadata: input.metadata,
initialPrompt: trimmedPrompt,
env: input.env,
initialTitle: input.provisionalTitle,
@@ -341,7 +346,7 @@ async function resolveMcpCreateAgent(
resolvedCwd: intent.cwd,
trimmedPrompt,
resolvedMode: resolvedCreateConfig.modeId,
resolvedFeatures: resolvedCreateConfig.featureValues,
resolvedProviderOptions: resolvedCreateConfig.providerOptions,
}),
createOptions: {
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
@@ -380,18 +385,23 @@ async function resolveMcpProviderCreateConfig(params: {
provider: string;
resolvedCwd: string;
parentAgent: ManagedAgent | null;
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
const passthroughConfig = params.input.config;
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: params.resolvedCwd,
provider: params.provider,
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
featureValues: params.input.features ?? passthroughConfig?.featureValues,
providerOptions: mergeMcpProviderOptions(params.input),
parent: params.parentAgent,
unattended: params.input.unattended ?? false,
});
}
function mergeMcpProviderOptions(input: CreateAgentFromMcpInput): ProviderOptions | undefined {
if (!input.providerOptions) return input.config?.providerOptions;
return { ...input.config?.providerOptions, ...input.providerOptions };
}
function buildMcpSessionConfig(params: {
input: CreateAgentFromMcpInput;
resolvedProviderModel: ResolvedProviderModel;
@@ -399,14 +409,14 @@ function buildMcpSessionConfig(params: {
resolvedCwd: string;
trimmedPrompt: string;
resolvedMode?: string;
resolvedFeatures?: Record<string, unknown>;
resolvedProviderOptions?: ProviderOptions;
}): AgentSessionConfig {
const passthroughConfig = params.input.config;
const { provisionalTitle } = resolveCreateAgentTitles({
configTitle: passthroughConfig?.title ?? params.input.title,
initialPrompt: params.trimmedPrompt,
});
const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
const config: AgentSessionConfig = {
...passthroughConfig,
provider: params.provider,
@@ -419,8 +429,8 @@ function buildMcpSessionConfig(params: {
if (provisionalTitle) {
config.title = provisionalTitle;
}
if (featureValues) {
config.featureValues = featureValues;
if (providerOptions) {
config.providerOptions = providerOptions;
}
return config;
}
@@ -55,7 +55,8 @@ beforeEach(async () => {
projectLive: () => {
throw new Error("No live agents in this suite");
},
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
projectStored: (record) =>
buildStoredAgentPayload(record, ["codex"], (_provider, own) => own ?? {}),
isProviderVisible: () => true,
isStoredProviderAvailable: () => true,
});
@@ -123,6 +123,7 @@ function outcome(status: "idle" | "running" | "closed" | "error" = "closed"): Wa
lastStatus: status,
},
["codex"],
(_provider, own) => own ?? {},
),
lastMessage: "Partial work saved",
error: null,
@@ -43,6 +43,7 @@ class Host implements HostObservation {
updatedAt: "2026-01-01",
},
["codex"],
(_provider, own) => own ?? {},
),
event,
"Last response",
@@ -133,6 +134,7 @@ test("late initial permission snapshot cannot resurrect an already resolved requ
updatedAt: "2026-01-01",
},
["codex"],
(_provider, own) => own ?? {},
);
if (requestEvent.type !== "permission_requested") throw new Error("Expected permission");
final.pendingPermissions = [requestEvent.request];
@@ -34,7 +34,11 @@ export function createFinishObservation(input: {
const stored = await agentStorage.get(agentId);
return {
status: stored?.lastStatus === "error" ? "error" : "idle",
final: stored ? buildStoredAgentPayload(stored, [stored.provider]) : null,
final: stored
? buildStoredAgentPayload(stored, [stored.provider], (provider, own) =>
agentManager.effectiveOptionsFor(provider, own),
)
: null,
lastMessage: await agentManager.getLastAssistantMessage(agentId),
error: stored?.lastError ?? null,
};
@@ -93,7 +97,14 @@ export function createFinishObservation(input: {
type: "turn_started",
provider: current.provider,
});
else publish(stored ? buildStoredAgentPayload(stored, [stored.provider]) : null);
else
publish(
stored
? buildStoredAgentPayload(stored, [stored.provider], (provider, own) =>
agentManager.effectiveOptionsFor(provider, own),
)
: null,
);
const stop = () => unsubscribe();
signal.addEventListener("abort", stop, { once: true });
return async () => {
@@ -661,7 +661,7 @@ class ProviderImportHarness {
notifyAgentState: () => {},
getAgent: () => this.activeAgent,
getRegisteredProviderIds: () => ["codex"],
createAgent: async () => {
createAgentFromRecord: async () => {
throw new Error("Stored provider imports must resume their persisted session");
},
resumeAgentFromPersistence: async (
@@ -186,8 +186,8 @@ function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
if (client.listCommands) {
wrappedClient.listCommands = async (config) => await client.listCommands!(config);
}
if (client.listFeatures) {
wrappedClient.listFeatures = async (config) => await client.listFeatures!(config);
if (client.listControls) {
wrappedClient.listControls = async (config) => await client.listControls!(config);
}
if (client.listImportableSessions) {
wrappedClient.listImportableSessions = async (options) =>
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
setAgentMode: vi.fn().mockResolvedValue(undefined),
setAgentModel: vi.fn().mockResolvedValue(undefined),
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
setAgentFeature: vi.fn().mockResolvedValue(undefined),
setAgentOptions: vi.fn().mockResolvedValue(undefined),
setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined),
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
@@ -271,6 +271,7 @@ function buildAgentManagerSpies() {
appendTimelineItem: vi.fn().mockResolvedValue(undefined),
emitLiveTimelineItem: vi.fn().mockResolvedValue(undefined),
getPluginTools: () => [],
effectiveOptionsFor: (_provider: string, own?: Record<string, unknown>) => own ?? {},
reloadStalePluginSession: async () => {},
hasInFlightRun: vi.fn().mockReturnValue(false),
steerOrReplaceActiveTurn: async () => ({ status: "inactive" as const }),
@@ -282,7 +283,7 @@ function buildAgentManagerSpies() {
cancelAgentRun: vi.fn(),
getPendingPermissions: vi.fn(),
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
listDraftFeatures: vi.fn(),
listDraftControls: vi.fn(),
};
}
@@ -474,9 +475,9 @@ function configureOpenCodeProviderStub(
const opts = input as {
provider: AgentProvider;
requestedMode: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: Record<string, unknown> | undefined;
};
return { modeId: opts.requestedMode, featureValues: opts.featureValues };
return { modeId: opts.requestedMode, providerOptions: opts.providerOptions };
});
}
@@ -564,6 +565,7 @@ function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent
pendingPermissions: new Map(),
persistence: null,
labels: {},
metadata: {},
attention: { requiresAttention: false },
...overrides,
} as ManagedAgent;
@@ -1818,7 +1820,7 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Feature test", featureValues: { fast_mode: true } },
config: { title: "Feature test", providerOptions: { fast_mode: true } },
} as ManagedAgent);
const server = await createAgentMcpServer({
@@ -1847,7 +1849,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
}),
undefined,
{ workspaceId: "workspace-created", background: false },
@@ -3045,12 +3047,12 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child", featureValues: { fast_mode: true } },
config: { title: "Child", providerOptions: { fast_mode: true } },
} as ManagedAgent);
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { featureValues: Record<string, unknown> | undefined };
return { modeId: undefined, featureValues: opts.featureValues };
const opts = input as { providerOptions: Record<string, unknown> | undefined };
return { modeId: undefined, providerOptions: opts.providerOptions };
});
const server = await createAgentMcpServer({
@@ -3078,7 +3080,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
}),
undefined,
{
@@ -3281,7 +3283,7 @@ describe("create_agent MCP tool", () => {
provStub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { requestedMode: string | undefined };
expect(opts.requestedMode).toBe("dynamic");
return { modeId: "dynamic", featureValues: undefined };
return { modeId: "dynamic", providerOptions: undefined };
});
const server = await createAgentMcpServer({
agentManager,
@@ -3315,12 +3317,12 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: { title: "Child", featureValues: { auto_accept: true } },
config: { title: "Child", providerOptions: { auto_accept: true } },
} as ManagedAgent);
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const server = await createAgentMcpServer({
agentManager,
@@ -3340,7 +3342,7 @@ describe("create_agent MCP tool", () => {
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }),
undefined,
{ workspaceId: "workspace-created", background: false },
);
@@ -3368,7 +3370,7 @@ describe("create_agent MCP tool", () => {
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "resolver-mode",
featureValues: { resolver_feature: true },
providerOptions: { resolver_feature: true },
});
const server = await createAgentMcpServer({
@@ -3392,7 +3394,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
modeId: "resolver-mode",
featureValues: { resolver_feature: true },
providerOptions: { resolver_feature: true },
}),
undefined,
expect.any(Object),
@@ -4725,7 +4727,7 @@ describe("update_agent MCP tool", () => {
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true });
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Updated agent",
labels: { role: "worker" },
@@ -4750,12 +4752,12 @@ describe("update_agent MCP tool", () => {
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled();
});
it("does not update metadata when runtime settings fail", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature"));
const server = await createAgentMcpServer({
agentManager,
agentStorage,
@@ -5073,7 +5075,7 @@ describe("create_schedule MCP tool", () => {
config: {
title: "Parent agent",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
} as ManagedAgent);
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
@@ -5101,7 +5103,7 @@ describe("create_schedule MCP tool", () => {
cwd: REPO_CWD,
modeId: "build",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
});
});
@@ -5875,7 +5877,7 @@ describe("agent profile listing MCP tool", () => {
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
options: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
];
@@ -5890,7 +5892,21 @@ describe("agent profile listing MCP tool", () => {
const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles });
// The config store holds profiles with `options` only; the tool reports them as stored.
expect(response.structuredContent).toEqual({
profiles: [
{
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
options: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
],
});
});
it("returns an empty array when no profiles are configured", async () => {
@@ -5942,14 +5958,10 @@ describe("provider MCP tools", () => {
it("inspects provider features for a draft agent configuration", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listDraftFeatures.mockResolvedValue([
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
]);
spies.agentManager.listDraftControls.mockResolvedValue({
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
options: { fast_mode: true },
});
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
const codexEntry = buildSnapshotEntry({
@@ -5982,13 +5994,13 @@ describe("provider MCP tools", () => {
const response = await tool.handler(input);
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({
provider: "codex",
cwd: expect.stringContaining("repo"),
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
expect(response.structuredContent).toEqual({
provider: "codex",
@@ -6003,7 +6015,7 @@ describe("provider MCP tools", () => {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
value: true,
},
],
});
@@ -6219,14 +6231,8 @@ describe("agent snapshot MCP serialization", () => {
description: "Default coding mode",
},
],
features: [
{
type: "toggle",
id: "web-search",
label: "Web search",
value: true,
},
],
controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
effectiveOptions: { "web-search": true },
pendingPermissions: new Map(),
persistence: {
provider: "codex",
@@ -0,0 +1,62 @@
import { describe, expect, test } from "vitest";
import { convertLegacyOptions, foldHookFeatureValues } from "./option-controls.js";
describe("convertLegacyOptions rejects a malformed Codex fast_mode", () => {
test.each(["false", 1, {}])("%j", (value) => {
expect(() =>
convertLegacyOptions({ provider: "codex", options: { fast_mode: value } }),
).toThrow(/fast_mode must be true, false or null/);
});
});
describe("convertLegacyOptions", () => {
test.each([
{ legacy: true, tier: "fast" },
{ legacy: false, tier: "default" },
{ legacy: null, tier: null },
])("Codex fast_mode: $legacy becomes service_tier: $tier", ({ legacy, tier }) => {
expect(
convertLegacyOptions({ provider: "codex", options: { fast_mode: legacy, plan_mode: true } }),
).toEqual({ plan_mode: true, service_tier: tier });
});
test("a service_tier in the same input wins", () => {
expect(
convertLegacyOptions({
provider: "codex",
options: { service_tier: "default", fast_mode: true },
}),
).toEqual({ service_tier: "default" });
});
test("other providers keep fast_mode", () => {
const options = { fast_mode: true };
expect(convertLegacyOptions({ provider: "claude", options })).toBe(options);
});
});
describe("foldHookFeatureValues", () => {
const convert = (options: Record<string, unknown>) =>
convertLegacyOptions({ provider: "codex", options });
test("a hook that edits featureValues.fast_mode sets service_tier", () => {
const sent = { providerOptions: {}, featureValues: {} };
const returned = { providerOptions: {}, featureValues: { fast_mode: true } };
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
service_tier: "fast",
});
});
test("a hook that replaces the speed it was sent with fast_mode selects Fast", () => {
const sent = {
providerOptions: { service_tier: "default" },
featureValues: { service_tier: "default" },
};
const returned = { ...sent, featureValues: { fast_mode: true } };
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
service_tier: "fast",
});
});
});
@@ -0,0 +1,208 @@
import { isDeepStrictEqual } from "node:util";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
/**
* A patch over an agent's own options: top-level keys replace, `null` removes the key so the
* `config.json` default (or the provider's own default) applies again.
*/
export type ProviderOptionsPatch = Record<string, unknown>;
export function applyProviderOptionsPatch(input: {
options: ProviderOptions | undefined;
patch: ProviderOptionsPatch;
}): ProviderOptions {
const { options, patch } = input;
const next: ProviderOptions = { ...options };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete next[key];
} else if (value !== undefined) {
next[key] = value;
}
}
return next;
}
/** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. */
export interface LegacyFeatureValues {
featureValues?: Record<string, unknown>;
}
/**
* COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast
* preferences have migrated. Codex used to take `fast_mode`; it takes `service_tier` now. Old
* data (stored records, config.json defaults, profiles, older clients, hooks) is converted here
* as it enters, so nothing past this point sees `fast_mode` for Codex. A `service_tier` in the
* same input wins. A patch's `null` stays `null`, which removes the speed.
*/
interface LegacyOptionsInput<O> {
provider: string | undefined;
options: O;
}
/** A legacy option value the conversion cannot read. */
export class MalformedLegacyOptionError extends Error {
constructor(message: string) {
super(message);
this.name = "MalformedLegacyOptionError";
}
}
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown>>,
): Record<string, unknown>;
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
): Record<string, unknown> | undefined;
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
): Record<string, unknown> | undefined {
const { provider, options } = input;
if (provider !== "codex" || !options || !("fast_mode" in options)) return options;
const { fast_mode: legacy, ...rest } = options;
if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") {
throw new MalformedLegacyOptionError("Codex fast_mode must be true, false or null");
}
if ("service_tier" in rest || legacy === undefined) return rest;
if (legacy === null) return { ...rest, service_tier: null };
return { ...rest, service_tier: legacy ? "fast" : "default" };
}
/**
* The built-in adapter a provider id runs on: a custom provider is named by the one it
* `extends`, followed until a provider that extends nothing. `extendsOf` is where the caller
* reads that link (the registry's definitions, or `config.json`'s `providers` section).
*/
export function resolveAdapter(input: {
provider: string;
extendsOf: (provider: string) => string | null | undefined;
}): string {
const seen = new Set<string>();
let current = input.provider;
for (;;) {
seen.add(current);
const base = input.extendsOf(current);
if (!base || seen.has(base)) return current;
current = base;
}
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Older clients, stored records, profiles, and plugin hooks carry composer values in
* `featureValues`. This is the one place they become `providerOptions`; an explicit
* `providerOptions` key wins over a `featureValues` key of the same name.
*/
export function foldFeatureValues<
T extends {
providerOptions?: ProviderOptions | null;
featureValues?: Record<string, unknown> | null;
},
>(config: T): Omit<T, "featureValues"> {
const { featureValues, ...rest } = config;
if (!featureValues || Object.keys(featureValues).length === 0) return rest;
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } };
}
/**
* 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 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> },
>(config: T): T {
if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config;
return { ...config, featureValues: { ...config.providerOptions } };
}
export function foldHookFeatureValues<
T extends {
providerOptions?: ProviderOptions | null;
featureValues?: Record<string, unknown>;
},
>(input: {
returned: T;
sent: T;
/** Brings what the hook returned to the form of the copy it was sent. */
convert: (options: Record<string, unknown>) => Record<string, unknown>;
}): Omit<T, "featureValues"> {
const { returned, sent, convert } = input;
const { featureValues, ...rest } = returned;
const before = sent.featureValues;
if (!before) {
// No copy was sent, so everything the hook put in `featureValues` is new.
return foldFeatureValues(returned);
}
// A hook may return an older key where it was sent the current one; comparing in the same
// form keeps a replaced value from reading as a deletion.
const after = convert(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({
options: rest.providerOptions ?? undefined,
patch,
}),
};
}
type ConvertedOptions = Record<string, unknown> | undefined;
interface ProfileOptionFields {
provider?: string;
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Profiles saved before options carry `featureValues`; when one comes from storage, `options`
* wins over a stale `featureValues` key.
*
* A write that carries `options`, empty included, is from a client that knows them: its
* `featureValues` fold over those. A write without `options` is from the unchanged app, whose
* form only knows the controls: its `featureValues` are a patch (`null` deletes) over the
* options stored for that profile, so options the form never shows, such as `sandbox_mode`,
* survive an edit. A profile moved to another provider starts from no options, because the old
* provider's keys mean nothing to the new one. A profile with no options is stored without the
* key.
*/
export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
profile: T;
stored: ProfileOptionFields | undefined;
source: "stored" | "client write";
/** Renames old option keys for the adapter the profile's provider runs on. */
convert: (provider: string, options: Record<string, unknown> | undefined) => ConvertedOptions;
}): Omit<T, "featureValues"> {
const { profile, stored, source, convert } = input;
const { featureValues: legacyValues, ...rest } = profile;
// A profile is stored in the current keys, so removal and merging below see one vocabulary.
const provider = rest.provider ?? "";
const featureValues = convert(provider, legacyValues);
const ownOptions = convert(provider, rest.options);
if (source === "stored") {
if (featureValues) return { ...rest, options: { ...featureValues, ...ownOptions } };
return ownOptions ? { ...rest, options: ownOptions } : rest;
}
const storedOptions =
stored?.provider === rest.provider ? convert(provider, stored?.options) : undefined;
const base = rest.options !== undefined ? ownOptions : storedOptions;
const options = applyProviderOptionsPatch({ options: base, patch: featureValues ?? {} });
return Object.keys(options).length > 0 ? { ...rest, options } : { ...rest, options: undefined };
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* The unchanged app reads a profile's options from `featureValues`; the config API answers with
* both fields.
*/
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(profile: T): T {
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
}
@@ -0,0 +1,60 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { ProviderConfigState } from "@getpaseo/plugin/server/provider";
import type { AgentOptionControl } from "./agent-sdk-types.js";
/** How a provider session's composer options look to the daemon, whichever shape it reports. */
export interface PluginOptionState {
controls: AgentOptionControl[];
/** The values the session runs with, for the keys it reports. */
options: Record<string, JsonValue>;
/** The provider reports `settings`, so it also reads `changes.settings`. */
legacySettings: boolean;
}
/**
* A provider that reports `controls` and `providerOptions` is read as is.
* COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A provider still
* reporting `settings` has each setting id read as an option key and its value as the option.
*/
export function readPluginOptionState(config: ProviderConfigState): PluginOptionState {
if (config.controls || config.providerOptions) {
return {
controls: (config.controls ?? []).map(toControl),
options: { ...config.providerOptions },
legacySettings: false,
};
}
const settings = config.settings ?? [];
return {
controls: settings.map(
(setting): AgentOptionControl =>
setting.type === "toggle"
? {
type: "toggle",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
),
options: Object.fromEntries(settings.map((setting) => [setting.id, setting.value])),
legacySettings: true,
};
}
function toControl(
control: NonNullable<ProviderConfigState["controls"]>[number],
): AgentOptionControl {
return control.type === "toggle"
? { ...control }
: { ...control, choices: control.choices.map((choice) => ({ ...choice })) };
}
@@ -58,6 +58,14 @@ interface ProviderHarnessOptions {
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
function controlKey(control: { key: string }): string {
return control.key;
}
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
return inputs.findLast((input) => input.type === "session.configure");
}
function createProviderHarness(options: ProviderHarnessOptions = {}) {
let listener: ((event: ProviderEvent) => void) | null = null;
let closeCount = 0;
@@ -497,6 +505,144 @@ test("combined plugin rewind replays the retained native branch", async () => {
});
});
test("a rewind reopens with the options as they stand, a reset included", async () => {
const options: Record<string, boolean> = {};
const report = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
controls: [{ type: "toggle", key: "concise", label: "Concise" }],
providerOptions: { ...options },
},
});
const harness = createRewindProviderHarness({
handleInput: async (input, emit) => {
if (input.type === "session.open" && input.history !== "replay") {
options.concise = true;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation"],
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
report(emit, input.sessionId);
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
// A plugin that represents the default by leaving the key out.
delete options.concise;
report(emit, input.sessionId);
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
providerOptions: { concise: true },
});
await session.run("A");
await session.applyOptions?.({ concise: null });
await session.revertConversation!({ messageId: "user-A" });
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
expect(reopened?.config.providerOptions).toEqual({});
await session.close();
await registry.shutdown();
});
// A plugin may report no options at all while it runs the ones it was given.
async function withUnreportedOptionSession(
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness({
handleInput: async (input, emit) => {
if (input.type === "session.open" && input.history !== "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation"],
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: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
providerOptions: { token_budget: 1000 },
});
try {
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
test("applying an option the plugin never reports leaves the stored option alone", async () => {
await withUnreportedOptionSession(async (session) => {
// The same value as a whole-profile patch carries it, then a new one.
expect(await session.applyOptions?.({ token_budget: 1000 })).toEqual({});
expect(await session.applyOptions?.({ token_budget: 2000 })).toEqual({});
});
});
test("a rewind reopens with the value the plugin accepted for an option it never reports", async () => {
await withUnreportedOptionSession(async (session, harness) => {
await session.run("A");
await session.applyOptions?.({ token_budget: 2000 });
await session.revertConversation!({ messageId: "user-A" });
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
expect(reopened?.config.providerOptions).toEqual({ token_budget: 2000 });
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
@@ -970,7 +1116,7 @@ describe("PluginAgentClientRegistry", () => {
}
});
test("stores only agent options while the plugin receives merged defaults", async () => {
test("stores the agent options and the settings the plugin opened with, not the merged config defaults", async () => {
const logger = createTestLogger();
const harness = createProviderHarness();
const plugins = new PluginAgentClientRegistry(logger);
@@ -992,13 +1138,323 @@ describe("PluginAgentClientRegistry", () => {
undefined,
{ workspaceId: undefined },
);
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own);
// `nested.base` comes from `config.json` and is not stored; `voice` is the plugin's own opening state.
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual({ ...own, voice: "direct" });
expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({
config: { providerOptions: { nested: { base: true, replace: "agent" } } },
});
await agent.session?.close();
});
// The same contract holds for a plugin that reports `controls` and `providerOptions` and for one
// still reporting `settings`.
describe.each(["controls", "settings"] as const)("options of a plugin reporting %s", (shape) => {
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
async function openStubbornVoiceSession(
given: Record<string, boolean | string>,
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
) {
const state: { voice: string; loud: boolean; tokenBudget?: number } = {
voice: "direct",
loud: false,
};
let openedSessionId = "";
const reported = () =>
shape === "controls"
? {
controls: [
{
type: "select" as const,
key: "voice",
label: "Voice",
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{
type: "toggle" as const,
key: "loud",
label: "Loud",
icon: "Volume2",
defaultValue: false,
},
],
providerOptions: {
voice: state.voice,
loud: state.loud,
...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }),
},
}
: {
settings: [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
],
};
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
...reported(),
},
});
const harness = createProviderHarness({
async handleInput(input, emit) {
if (input.type === "session.open") {
openedSessionId = input.sessionId;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: CAPABILITIES,
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
emitConfig(emit, input.sessionId);
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
const patch =
shape === "settings" ? input.changes.settings : input.changes.providerOptions;
const { loud, token_budget: tokenBudget } = patch ?? {};
// A plugin that takes `token_budget` live refuses the whole request on a bad value.
if (tokenBudget !== undefined && typeof tokenBudget !== "number") {
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "bad budget" },
});
return true;
}
if (typeof loud === "boolean") state.loud = loud;
if (typeof tokenBudget === "number") state.tokenBudget = tokenBudget;
// Going loud returns an unrequested token budget to its default, by leaving it out.
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
emitConfig(emit, input.sessionId);
emit(
refuseAfterChanging
? { type: "request.failed", requestId: input.requestId, error: { message: "no" } }
: { type: "request.completed", requestId: input.requestId },
);
return true;
}
return false;
},
});
const plugins = new PluginAgentClientRegistry(createTestLogger());
plugins.replace([harness.registration]);
const session = await plugins.clients()["plugin-direct"]!.createSession({
provider: "plugin-direct",
cwd: tmpdir(),
providerOptions: given,
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const changeOnItsOwn = (change: Partial<typeof state>) => {
Object.assign(state, change);
emitConfig(harness.emit, openedSessionId);
};
return { harness, session, events, changeOnItsOwn };
}
test("forwards a setting the plugin changes on its own", async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ loud: true });
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
]);
await session.close();
});
test.runIf(shape === "controls")(
"reports an option the plugin stops reporting as removed, alone or with another change",
async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ tokenBudget: 500 });
changeOnItsOwn({ tokenBudget: undefined });
changeOnItsOwn({ tokenBudget: 700 });
changeOnItsOwn({ tokenBudget: undefined, loud: true });
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: null } },
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 700 } },
{
type: "options_changed",
provider: "plugin-direct",
patch: { token_budget: null, loud: true },
},
]);
await session.close();
},
);
test.runIf(shape === "controls")(
"an apply returns the option the plugin reset along the way as removed",
async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ tokenBudget: 500 });
const ended = await session.applyOptions?.({ loud: true });
expect(ended).toEqual({ token_budget: null });
// The apply reports it; the plugin's later identical report is not a second change.
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
]);
await session.close();
},
);
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
await expect(session.applyOptions?.({ loud: true })).rejects.toThrow("no");
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
]);
await session.close();
});
test("a setting the plugin opened differently is the session's open patch, not an event", async () => {
const { session, events } = await openStubbornVoiceSession({ voice: "other", loud: false });
expect(session.openOptionsPatch).toEqual({ voice: "direct" });
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
test("options the plugin opened with that nobody set are in the open patch, defaults included", async () => {
const { session, events } = await openStubbornVoiceSession({});
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
test("applying options configures only the settings it is given", async () => {
const { harness, session } = await openStubbornVoiceSession({});
await session.applyOptions?.({ loud: true });
const configure = lastConfigure(harness.inputs);
// Only a plugin that reports `settings` is also sent them.
expect((configure as { changes: object }).changes).toEqual(
shape === "settings"
? { providerOptions: { loud: true }, settings: { loud: true } }
: { providerOptions: { loud: true } },
);
await session.close();
});
test("a removal reaches the plugin as null", async () => {
const { harness, session } = await openStubbornVoiceSession({});
const ended = await session.applyOptions?.({ loud: null });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { loud: null } },
});
// It went back to its default, and what it ended up with is reported like any provider's.
expect(ended).toEqual({ loud: false });
await session.close();
});
test("draws a control from what the plugin reports", async () => {
const { session } = await openStubbornVoiceSession({});
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
await session.close();
});
test.runIf(shape === "controls")(
"keeps a control's icon, default and choices as the plugin declared them",
async () => {
const { session } = await openStubbornVoiceSession({});
expect(session.controls?.[0]).toMatchObject({
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
});
await session.close();
},
);
test.runIf(shape === "controls")(
"sends a live option that has no control, and the plugin's answer decides",
async () => {
const { harness, session, events } = await openStubbornVoiceSession({});
await session.applyOptions?.({ token_budget: 2000 });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { token_budget: 2000 } },
});
// The plugin refuses the request as a whole, so the other key it was given is not applied.
await expect(session.applyOptions?.({ loud: true, token_budget: "lots" })).rejects.toThrow(
"bad budget",
);
expect(eventsOfType(events, "options_changed")).toEqual([]);
expect(await session.applyOptions?.({ token_budget: 3000 })).toEqual({});
await session.close();
},
);
test("sends a key the plugin does not report, for the plugin to accept or refuse", async () => {
const { harness, session } = await openStubbornVoiceSession({});
await session.applyOptions?.({ loud: true, launch_key: "changed" });
const configure = lastConfigure(harness.inputs) as {
changes: { providerOptions: object; settings?: object };
};
expect(configure.changes.providerOptions).toEqual({ loud: true, launch_key: "changed" });
// A plugin reporting `settings` is sent the same keys under that name.
expect(configure.changes.settings).toEqual(
shape === "settings" ? { loud: true, launch_key: "changed" } : undefined,
);
await session.close();
});
test("applying options returns the settings that ended up different, and emits nothing", async () => {
const { session, events } = await openStubbornVoiceSession({});
const ended = await session.applyOptions?.({ voice: "other", loud: true });
expect(ended).toEqual({ voice: "direct" });
expect(eventsOfType(events, "options_changed")).toEqual([]);
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" } },
@@ -1511,12 +1967,13 @@ describe("PluginAgentClientRegistry", () => {
});
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
expect(session.features).toEqual([
expect.objectContaining({
expect(session.controls).toEqual([
{
type: "select",
id: "voice",
options: [{ id: "direct", label: "Direct", value: "direct" }],
}),
key: "voice",
label: "Voice",
choices: [{ value: "direct", label: "Direct" }],
},
]);
expect(session.describePersistence()).toMatchObject({
provider: "plugin-direct",
@@ -1,8 +1,9 @@
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";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { z } from "zod";
import {
PROVIDER_CAPABILITIES,
@@ -31,10 +32,10 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentOptionControl,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
@@ -63,6 +64,7 @@ import {
createProviderEnv,
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import { readPluginOptionState } from "./plugin-provider-options.js";
import type { RegisteredProviderDefinition } from "./provider-registry.js";
import { runProviderTurn } from "./providers/provider-runner.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
@@ -604,7 +606,7 @@ class ProviderRuntimeSession {
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -1115,7 +1117,7 @@ class PluginAgentClient implements AgentClient {
this.provider,
bridge,
forget,
async (persistence, state) => {
async (persistence, state, options) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
@@ -1124,15 +1126,15 @@ class PluginAgentClient implements AgentClient {
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
settings: options,
providerOptions: options,
},
persistence,
history: "replay",
});
},
bind,
config.settings,
);
bind(bridge);
return session;
@@ -1201,6 +1203,14 @@ 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 options it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
private knownOptions: Record<string, JsonValue> = {};
// The options a reopened session starts with: what it launched with, then every value the
// plugin reported or was given, minus every key that was reset.
private readonly committedOptions: Record<string, JsonValue>;
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
private applyingOptions = false;
private currentTurnId: string | null = null;
private closed = false;
private replay: {
@@ -1219,10 +1229,16 @@ class PluginAgentSession implements AgentSession {
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
options: Readonly<Record<string, JsonValue>>,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
givenOptions: Readonly<Record<string, JsonValue>>,
) {
this.subagentIdsBySession.set(bridge.id, null);
// Every setting the session opened with that was not given, including ones nobody set.
this.committedOptions = { ...givenOptions };
this.openOptionsPatch = this.optionDifferences(givenOptions);
this.commitOptions();
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.
@@ -1246,15 +1262,8 @@ class PluginAgentSession implements AgentSession {
};
}
get features(): AgentFeature[] {
return this.bridge.config.settings.map((setting) =>
setting.type === "toggle"
? { ...setting }
: {
...setting,
options: setting.options.map((option) => ({ ...option, id: option.value })),
},
);
get controls(): AgentOptionControl[] {
return readPluginOptionState(this.bridge.config).controls;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -1411,11 +1420,39 @@ class PluginAgentSession implements AgentSession {
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
async setFeature(featureId: string, value: unknown): Promise<void> {
/** The agent manager sends one apply at a time per session. */
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
this.assertReplayComplete();
await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
});
const before = this.currentOptions();
const { legacySettings } = readPluginOptionState(this.bridge.config);
// The plugin applies every key it is given or rejects the whole request.
const settings: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
settings[id] = toJsonValue(value, `option '${id}'`);
}
if (Object.keys(settings).length === 0) return {};
this.applyingOptions = true;
try {
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin
// reporting `settings` is sent the same keys under that name.
await this.bridge.configure({
providerOptions: settings,
...(legacySettings ? { settings } : {}),
});
} catch (error) {
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
// call has ended.
this.applyingOptions = false;
for (const event of this.reportOptionChanges()) this.publish(event);
throw error;
} finally {
this.applyingOptions = false;
}
// A requested key is a request, not a report: only a key the plugin reported before can be
// one it has now dropped.
const ended = this.optionChanges({ ...before, ...settings }, before);
this.commitOptions(settings);
return ended;
}
async revertConversation(input: { messageId: string }): Promise<void> {
@@ -1597,10 +1634,66 @@ class PluginAgentSession implements AgentSession {
};
}
private currentOptions(): Record<string, JsonValue> {
return readPluginOptionState(this.bridge.config).options;
}
/** Options whose value differs from `known`: the plugin's own change or its opening state. */
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentOptions())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
}
return patch;
}
/**
* Settle the options a reopened session starts with: what is committed, then the request the
* plugin accepted, then what it reported back. A key it reported before and no longer reports
* was reset.
*/
private commitOptions(accepted: Readonly<Record<string, JsonValue>> = {}): void {
for (const [id, value] of Object.entries(accepted)) {
if (value === null) delete this.committedOptions[id];
else this.committedOptions[id] = value;
}
const current = this.currentOptions();
for (const id of Object.keys(this.knownOptions)) {
if (!(id in current)) delete this.committedOptions[id];
}
Object.assign(this.committedOptions, current);
this.knownOptions = current;
}
/**
* What changed against the options `known` to be set: values that differ, and `null` for a key
* in `reported` that the plugin no longer reports, which went back to its default.
*/
private optionChanges(
known: Readonly<Record<string, JsonValue>>,
reported: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const patch = this.optionDifferences(known);
const current = this.currentOptions();
for (const id of Object.keys(reported)) {
if (known[id] !== null && !(id in current)) patch[id] = null;
}
return patch;
}
private reportOptionChanges(): AgentStreamEvent[] {
if (this.applyingOptions) return [];
const patch = this.optionChanges(this.knownOptions, this.knownOptions);
if (Object.keys(patch).length === 0) return [];
this.commitOptions();
return [{ type: "options_changed", provider: this.provider, patch }];
}
private translateConfig(
event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] {
return [
...this.reportOptionChanges(),
{
type: "mode_changed",
provider: this.provider,
@@ -1751,7 +1844,7 @@ class PluginAgentSession implements AgentSession {
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
const bridge = await this.reopen(replay.persistence, replay.config, this.committedOptions);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
@@ -1853,7 +1946,8 @@ function mapSessionConfig(
model: config.model,
mode: config.modeId,
thinkingOption: config.thinkingOptionId,
settings: toJsonObject(config.featureValues ?? {}, "provider settings"),
// The plugin SDK reads composer values from `settings`; they are options here.
settings: toJsonObject(config.providerOptions ?? {}, "provider settings"),
providerOptions: config.providerOptions,
title: config.title ?? undefined,
persist,
@@ -1,4 +1,5 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { isDeepStrictEqual } from "node:util";
import type { z } from "zod";
export interface ProviderOptionIssue {
@@ -103,3 +104,26 @@ function isPlainObject(value: unknown): value is ProviderOptions {
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
}
/**
* Rejects a live patch that changes a key the running session cannot apply (one the provider
* reads only when it opens). A key whose value already matches the session's is not a change.
*/
export function assertChangesAreLive(input: {
provider: string;
patch: ProviderOptions;
current: ProviderOptions | undefined;
live: readonly string[];
}): void {
const issues = Object.entries(input.patch)
.filter(
([key, value]) =>
!input.live.includes(key) &&
!isDeepStrictEqual(input.current?.[key] ?? null, value ?? null),
)
.map(([key]) => ({
path: [key],
message: "cannot change on a running session; it applies when the agent is opened",
}));
if (issues.length > 0) throw new ProviderOptionsValidationError(input.provider, issues);
}
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
"listCommands",
"setModel",
"setThinkingOption",
"setFeature",
"applyOptions",
"revertConversation",
"revertFiles",
"revertBoth",
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
this.recordedCalls.push("setThinkingOption");
}
async setFeature() {
this.recordedCalls.push("setFeature");
async applyOptions() {
this.recordedCalls.push("applyOptions");
}
async revertConversation() {
@@ -193,7 +193,7 @@ describe("wrapSessionProvider", () => {
await wrapped.listCommands?.();
await wrapped.setModel?.("sonnet");
await wrapped.setThinkingOption?.("high");
await wrapped.setFeature?.("feature-1", true);
await wrapped.applyOptions?.({ "feature-1": true });
await wrapped.revertConversation?.({ messageId: "message-1" });
await wrapped.revertFiles?.({ messageId: "message-1" });
await wrapped.revertBoth?.({ messageId: "message-1" });
@@ -208,7 +208,7 @@ describe("wrapSessionProvider", () => {
"listCommands",
"setModel",
"setThinkingOption",
"setFeature",
"applyOptions",
"revertConversation",
"revertFiles",
"revertBoth",
@@ -4,7 +4,7 @@ import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type {
AgentClient,
AgentFeature,
AgentDraftControls,
AgentModelDefinition,
AgentMode,
AgentSessionConfig,
@@ -392,17 +392,20 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
return true;
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
mockState.cursorListFeaturesConfigs.push(config);
return [
{
type: "select",
id: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
},
];
return {
options: config.providerOptions ?? {},
controls: [
{
type: "select",
key: "fast",
label: "Fast",
defaultValue: "false",
choices: [{ value: "false", label: "Off" }],
},
],
};
}
},
}));
@@ -791,6 +794,33 @@ test("ACP provider configuration retains opaque options", () => {
});
});
test("a saved legacy Codex Fast preference wins over a configured service_tier default", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: { codex: { options: { service_tier: "default", plan_mode: false } } },
});
const client = registry.codex.createClient(logger);
// The manager hands the registry a pre-v0.10 record's `fast_mode` already converted.
expect(client.effectiveOptions?.({ service_tier: "fast" })).toEqual({
service_tier: "fast",
plan_mode: false,
});
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "default", plan_mode: false });
});
test("a configured legacy Codex Fast default supplies the service_tier default", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: { codex: { options: { fast_mode: true } } },
});
const client = registry.codex.createClient(logger);
// Removing an agent's own speed falls back to the configured Fast.
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "fast" });
expect(client.effectiveOptions?.({ service_tier: "default" })).toEqual({
service_tier: "default",
});
});
test("cursor provider extending acp uses CursorACPAgentClient", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
@@ -823,7 +853,7 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
expect(mockState.constructorArgs.genericAcp).toEqual([]);
});
test("wrapped cursor client lists ACP features through the inner provider", async () => {
test("wrapped cursor client lists ACP controls through the inner provider", async () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: {
cursor: {
@@ -837,19 +867,22 @@ test("wrapped cursor client lists ACP features through the inner provider", asyn
const client = registry.cursor.createClient(logger);
await expect(
client.listFeatures?.({
client.listControls?.({
provider: "cursor",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "select",
id: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
},
]);
).resolves.toEqual({
controls: [
{
type: "select",
key: "fast",
label: "Fast",
defaultValue: "false",
choices: [{ value: "false", label: "Off" }],
},
],
options: {},
});
expect(mockState.cursorListFeaturesConfigs).toEqual([
{
provider: "acp",
@@ -48,6 +48,7 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
import { convertLegacyOptions } from "./option-controls.js";
import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js";
import {
AGENT_PROVIDER_DEFINITIONS,
@@ -447,8 +448,11 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
get initialTimeline() {
return inner.initialTimeline;
},
get features() {
return inner.features;
get controls() {
return inner.controls;
},
get openOptionsPatch() {
return inner.openOptionsPatch;
},
usageSession: inner.usageSession?.bind(inner),
run: (prompt, options) => inner.run(prompt, options),
@@ -474,7 +478,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
listCommands: inner.listCommands?.bind(inner),
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
setFeature: inner.setFeature?.bind(inner),
applyOptions: inner.applyOptions?.bind(inner),
revertConversation: inner.revertConversation?.bind(inner),
revertFiles: inner.revertFiles?.bind(inner),
revertBoth: inner.revertBoth?.bind(inner),
@@ -488,14 +492,20 @@ function wrapClientProvider(
profileModels: ProviderProfileModel[],
additionalModels: ProviderProfileModel[],
profileModelsAreAdditive: boolean,
providerOptions: ProviderOptions | undefined,
configuredOptions: ProviderOptions | undefined,
): AgentClient {
const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner);
const listFeatures = inner.listFeatures?.bind(inner);
const listControls = inner.listControls?.bind(inner);
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
// Converted where it is used, so a malformed configured default fails the call that reads it
// rather than the registry that holds every provider.
const configuredDefaults = () =>
convertLegacyOptions({ provider: inner.provider, options: configuredOptions });
const withDefaults = (own: ProviderOptions | undefined) =>
mergeProviderOptions(configuredDefaults(), own);
return {
provider,
@@ -510,7 +520,7 @@ function wrapClientProvider(
{
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
providerOptions: withDefaults(config.providerOptions),
},
launchContext,
{ ...options, configuredModelIds },
@@ -527,14 +537,17 @@ function wrapClientProvider(
{
...overrides,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
providerOptions: withDefaults(overrides?.providerOptions),
},
launchContext,
{ ...options, configuredModelIds },
),
),
fetchCatalog: async (options, context) => {
const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context);
const catalog = await inner.fetchCatalog(
{ ...options, providerOptions: configuredDefaults() },
context,
);
return {
...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -549,7 +562,7 @@ function wrapClientProvider(
config: {
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
providerOptions: withDefaults(config.providerOptions),
},
env,
signal,
@@ -558,16 +571,18 @@ function wrapClientProvider(
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listFeatures: listFeatures
effectiveOptions: (own) => withDefaults(own) ?? {},
listControls: listControls
? async (config) =>
await listFeatures({
await listControls({
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
providerOptions: withDefaults(config.providerOptions),
})
: undefined,
listImportableSessions: listImportableSessions
? async (options) => await listImportableSessions({ ...options, providerOptions })
? async (options) =>
await listImportableSessions({ ...options, providerOptions: configuredDefaults() })
: undefined,
importSession: importSession
? async (input, context) => {
@@ -576,10 +591,7 @@ function wrapClientProvider(
config: {
...context.config,
provider: inner.provider,
providerOptions: mergeProviderOptions(
providerOptions,
context.config.providerOptions,
),
providerOptions: withDefaults(context.config.providerOptions),
},
storedConfig: {
...context.storedConfig,
@@ -1307,7 +1307,7 @@ describe("ProviderSnapshotManager public surface", () => {
resolverInputs.push(input);
return {
modeId: input.parent?.isUnattended ? "child-unattended" : undefined,
featureValues: undefined,
providerOptions: undefined,
};
},
}),
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project",
provider: "codex",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent,
unattended: false,
});
expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined });
expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined });
expect(resolverInputs).toEqual([
{
provider: "codex",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
modeId: "parent-unattended",
@@ -1386,7 +1386,7 @@ describe("ProviderSnapshotManager public surface", () => {
resolverInputs.push(input);
return {
modeId: input.unattended ? "worker" : undefined,
featureValues: undefined,
providerOptions: undefined,
};
},
}),
@@ -1397,17 +1397,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project",
provider: "codex",
requestedMode: undefined,
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
parent: null,
unattended: true,
});
expect(resolved).toEqual({ modeId: "worker", featureValues: undefined });
expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined });
expect(resolverInputs).toEqual([
{
provider: "codex",
requestedMode: undefined,
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
parent: null,
unattended: true,
availableModes: modes,
@@ -1456,7 +1456,7 @@ describe("ProviderSnapshotManager public surface", () => {
config: {
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
};
@@ -1464,12 +1464,28 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project",
provider: "opencode",
requestedMode: "base",
featureValues: undefined,
providerOptions: undefined,
parent,
unattended: false,
});
expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
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();
}
@@ -1,4 +1,4 @@
import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types";
import { normalizeAgentModelCatalog, type ProviderOptions } from "@getpaseo/protocol/agent-types";
import { createHash } from "node:crypto";
import { EventEmitter } from "node:events";
import { homedir } from "node:os";
@@ -160,14 +160,14 @@ export interface ResolveProviderCreateConfigOptions {
cwd?: string | null;
provider: AgentProvider;
requestedMode: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
parent: ManagedAgent | null;
unattended: boolean;
}
export interface ResolvedProviderCreateConfig {
modeId: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
}
interface ResolveDefaultModelOptions {
@@ -563,7 +563,7 @@ export class ProviderSnapshotManager {
return definition.resolveCreateConfig({
provider: input.provider,
requestedMode: input.requestedMode,
featureValues: input.featureValues,
providerOptions: input.providerOptions,
parent,
unattended: input.unattended || parent?.isUnattended === true,
availableModes: entry.modes ?? [],
@@ -744,8 +744,13 @@ export class ProviderSnapshotManager {
modeId: parent.currentModeId,
isUnattended: definition.isCreateConfigUnattended({
modeId: parent.currentModeId,
config: parent.config,
features: parent.features,
// 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 ?? [],
}),
};
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import {
AgentSideConnection,
@@ -220,7 +220,7 @@ function createSessionWithConfig(
provider?: string;
modeId?: string | null;
model?: string | null;
featureValues?: Record<string, unknown>;
providerOptions?: Record<string, unknown>;
} = {},
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
): ACPAgentSession {
@@ -230,7 +230,7 @@ function createSessionWithConfig(
cwd: "/tmp/paseo-acp-test",
modeId: config.modeId ?? undefined,
model: config.model ?? undefined,
featureValues: config.featureValues,
providerOptions: config.providerOptions,
},
{
provider: config.provider ?? "claude-acp",
@@ -322,19 +322,20 @@ function selectConfigOption(
function createCopilotSessionWithConfig(
modeId?: string | null,
featureValues?: Record<string, unknown>,
providerOptions?: Record<string, unknown>,
defaultCommand: string[] = ["copilot", "--acp"],
): ACPAgentSession {
return new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: modeId ?? undefined,
...(featureValues ? { featureValues } : {}),
...(providerOptions ? { providerOptions } : {}),
},
{
provider: "copilot",
logger: createTestLogger(),
defaultCommand: ["copilot", "--acp"],
defaultCommand,
defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions,
@@ -1538,7 +1539,7 @@ describe("ACPAgentSession Zed parity", () => {
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
const session = createSessionWithConfig({
provider: "cursor-acp",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const events: Array<{ type: string }> = [];
@@ -1570,7 +1571,7 @@ describe("ACPAgentSession Zed parity", () => {
test("does not auto-accept ACP chooser requests", async () => {
const session = createSessionWithConfig({
provider: "kimi-acp",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const events: AgentStreamEvent[] = [];
@@ -1623,11 +1624,8 @@ describe("ACPAgentSession Zed parity", () => {
internals.sessionId = "session-1";
internals.connection = {};
await session.setFeature("auto_accept", true);
await session.applyOptions({ auto_accept: true });
expect(session.features).toContainEqual(
expect.objectContaining({ type: "toggle", id: "auto_accept", value: true }),
);
await expect(
session.requestPermission({
sessionId: "session-1",
@@ -1645,7 +1643,7 @@ describe("ACPAgentSession Zed parity", () => {
});
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
const session = createSessionWithConfig({ featureValues: { auto_accept: true } });
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } });
const events: Array<{ type: string; request?: { id: string } }> = [];
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
@@ -1805,35 +1803,18 @@ describe("ACPAgentSession Zed parity", () => {
const internals = asInternals<ACPSessionInternals>(session);
internals.configOptions = [copilotAgentConfigOption("")];
expect(session.features).toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
value: false,
}),
expect(session.controls).toEqual([
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
{
type: "select",
id: "agent",
key: "agent",
label: "Agent",
description: "Use a Copilot custom agent profile",
tooltip: "Select Copilot agent",
icon: undefined,
value: "",
options: [
{
id: "",
label: "Default",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "Probe Agent",
label: "Probe Agent",
description: "Temporary probe agent",
isDefault: false,
metadata: undefined,
},
choices: [
{ value: "", label: "Default", description: undefined },
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
],
},
]);
@@ -1856,15 +1837,9 @@ describe("ACPAgentSession Zed parity", () => {
configId: "agent",
value: "Probe Agent",
});
expect(session.features).toEqual([
expect.objectContaining({
id: "auto_accept",
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
expect(session.controls).toEqual([
expect.objectContaining({ key: "auto_accept" }),
expect.objectContaining({ key: "agent" }),
]);
});
@@ -1878,26 +1853,175 @@ describe("ACPAgentSession Zed parity", () => {
connection: { setSessionConfigOption },
});
await session.setFeature("agent", "Probe Agent");
await session.applyOptions({ agent: "Probe Agent" });
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "agent",
value: "Probe Agent",
});
expect(session.features).toEqual([
expect.objectContaining({
id: "auto_accept",
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
expect(session.controls).toEqual([
expect.objectContaining({ key: "auto_accept" }),
expect.objectContaining({ key: "agent" }),
]);
});
});
describe("ACPAgentSession options against an ACP agent", () => {
const fakeAgentPath = fileURLToPath(new URL("./test-utils/fake-acp-agent.mjs", import.meta.url));
const sessions: ACPAgentSession[] = [];
afterEach(async () => {
await Promise.all(sessions.splice(0).map((session) => session.close()));
});
function thoughtOption(currentValue: string): SessionConfigOption {
return {
id: "thought",
name: "Thinking",
category: "thought_level",
type: "select",
currentValue,
options: [
{ value: "low", name: "Low" },
{ value: "high", name: "High" },
],
};
}
async function openSession(input: {
agent?: string;
scenario?: Record<string, unknown>;
providerOptions?: Record<string, unknown>;
}): Promise<{ session: ACPAgentSession; optionEvents: AgentStreamEvent[] }> {
const scenario = {
options: [copilotAgentConfigOption(input.agent ?? ""), thoughtOption("low")],
...input.scenario,
};
await mkdir("/tmp/paseo-acp-test", { recursive: true });
const session = createCopilotSessionWithConfig(null, input.providerOptions, [
process.execPath,
fakeAgentPath,
JSON.stringify(scenario),
]);
sessions.push(session);
const optionEvents: AgentStreamEvent[] = [];
session.subscribe((event) => {
if (event.type === "options_changed") optionEvents.push(event);
});
await session.initializeNewSession();
return { session, optionEvents };
}
test("a select the agent opened differently from the options is the open patch, not an event", async () => {
const { session, optionEvents } = await openSession({
providerOptions: { agent: "Probe Agent" },
scenario: { answers: { agent: "" } },
});
expect(session.openOptionsPatch).toEqual({ agent: "" });
expect(optionEvents).toEqual([]);
});
test("a select the agent opened with that nobody set is in the open patch", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
expect(session.openOptionsPatch).toEqual({ agent: "Probe Agent" });
expect(optionEvents).toEqual([]);
});
test("a notification that arrives before the open write is answered is not an event", async () => {
const { session, optionEvents } = await openSession({
providerOptions: { agent: "Probe Agent" },
scenario: { onWrite: "notify-first" },
});
expect(session.openOptionsPatch).toEqual({});
expect(optionEvents).toEqual([]);
});
test("applying a patch returns the selects that ended up different and emits nothing", async () => {
const { session, optionEvents } = await openSession({ scenario: { answers: { agent: "" } } });
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({ agent: "" });
expect(optionEvents).toEqual([]);
});
test("a notification that arrives before the apply write is answered is not an event", async () => {
const { session, optionEvents } = await openSession({ scenario: { onWrite: "notify-first" } });
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({});
expect(optionEvents).toEqual([]);
});
test("a rejected apply reports what the agent moved before refusing, since nothing is stored", async () => {
const { session, optionEvents } = await openSession({
scenario: { onWrite: "notify-then-reject" },
});
await expect(session.applyOptions({ agent: "Probe Agent" })).rejects.toThrow(/write refused/);
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]);
});
test("a removal writes the select's empty choice and returns it, since the options hold what the session runs", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
await expect(session.applyOptions({ agent: null })).resolves.toEqual({ agent: "" });
expect(optionEvents).toEqual([]);
});
test("a removal of a select already on its empty choice returns that choice too", async () => {
const { session } = await openSession({ agent: "" });
await expect(session.applyOptions({ agent: null })).resolves.toEqual({ agent: "" });
});
test("rejects a live auto_accept the creation schema would reject, and writes nothing", async () => {
const { session, optionEvents } = await openSession({
scenario: { onWrite: "notify-then-reject" },
});
await expect(
session.applyOptions({ auto_accept: "yes", agent: "Probe Agent" }),
).rejects.toThrow(/auto_accept/);
expect(optionEvents).toEqual([]);
});
test("forwards a select the agent changes on its own", async () => {
const { session, optionEvents } = await openSession({
scenario: { moves: { thought: { agent: "Probe Agent" } } },
});
await session.setThinkingOption("high");
await vi.waitFor(() =>
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]),
);
});
test("a select that appears after startup is reported with its native value", async () => {
const { session, optionEvents } = await openSession({
scenario: {
options: [thoughtOption("low")],
adds: { thought: [copilotAgentConfigOption("Probe Agent")] },
},
});
expect(session.openOptionsPatch).toEqual({});
await session.setThinkingOption("high");
await vi.waitFor(() =>
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]),
);
});
});
describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP(
@@ -2124,14 +2248,14 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({
provider: "generic-acp",
requestedMode: undefined,
featureValues: { provider_feature: "kept" },
providerOptions: { provider_feature: "kept" },
parent: null,
unattended: true,
availableModes: [],
}),
).toEqual({
modeId: undefined,
featureValues: { provider_feature: "kept", auto_accept: true },
providerOptions: { provider_feature: "kept", auto_accept: true },
});
});
@@ -2146,12 +2270,12 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({
provider: "generic-acp",
requestedMode: undefined,
featureValues: { auto_accept: false },
providerOptions: { auto_accept: false },
parent: null,
unattended: true,
availableModes: [],
}),
).toEqual({ modeId: undefined, featureValues: { auto_accept: false } });
).toEqual({ modeId: undefined, providerOptions: { auto_accept: false } });
});
test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
@@ -2165,7 +2289,7 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({
provider: "generic-acp",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
modeId: "bypassPermissions",
@@ -2174,7 +2298,7 @@ describe("ACPAgentClient config features", () => {
unattended: false,
availableModes: [{ id: "agent", label: "Agent" }],
}),
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
});
test("treats Auto Accept as an unattended ACP configuration", () => {
@@ -2190,7 +2314,7 @@ describe("ACPAgentClient config features", () => {
config: {
provider: "generic-acp",
cwd: "/tmp/acp-features",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
availableModes: [],
}),
@@ -2205,22 +2329,20 @@ describe("ACPAgentClient config features", () => {
});
await expect(
client.listFeatures({
client.listControls({
provider: "generic-acp",
cwd: "/tmp/acp-features",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
}),
).resolves.toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
]);
).resolves.toEqual({
controls: [
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
],
options: { auto_accept: true },
});
});
test("derives features from configured ACP select options", async () => {
test("derives controls from configured ACP select options", async () => {
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
@@ -2245,27 +2367,26 @@ describe("ACPAgentClient config features", () => {
configFeatureOptions: [COPILOT_AGENT_FEATURE_OPTION],
});
// The select nobody set reads as the agent opened it.
await expect(
client.listFeatures({
client.listControls({
provider: "copilot",
cwd: "/tmp/acp-features",
}),
).resolves.toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
value: false,
}),
expect.objectContaining({
type: "select",
id: "agent",
value: "Probe Agent",
options: [
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
],
}),
]);
).resolves.toEqual({
controls: [
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
expect.objectContaining({
type: "select",
key: "agent",
choices: [
expect.objectContaining({ value: "", label: "Default" }),
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
],
}),
],
options: { agent: "Probe Agent" },
});
});
});
@@ -4179,7 +4300,7 @@ describe("ACPAgentClient probe cleanup", () => {
terminateProcess: terminator.terminate,
});
await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" });
await client.listControls({ provider: "copilot", cwd: "/tmp/acp-features" });
expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" });
expect(terminator.terminated).toContain(child);
@@ -1,4 +1,7 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { ACPProviderOptionsSchema } from "./acp-options.js";
import { applyProviderOptionsPatch } from "../option-controls.js";
import { assertChangesAreLive } from "../provider-options.js";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
import { once } from "node:events";
@@ -67,7 +70,8 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateConfigUnattendedInput,
type AgentFeature,
type AgentDraftControls,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -862,10 +866,10 @@ export function deriveModelDefinitionsFromACP(
}));
}
export function deriveFeaturesFromACP(
export function deriveControlsFromACP(
configOptions: SessionConfigOption[] | null | undefined,
featureOptions: ACPConfigFeatureOption[],
): AgentFeature[] {
): AgentOptionControl[] {
return featureOptions.flatMap((featureOption) => {
const option = findSelectConfigFeatureOption(configOptions, featureOption);
if (!option) {
@@ -875,42 +879,55 @@ export function deriveFeaturesFromACP(
return [
{
type: "select",
id: featureOption.id,
key: featureOption.id,
label: featureOption.label,
description: featureOption.description,
tooltip: featureOption.tooltip,
icon: featureOption.icon,
value: option.currentValue ?? null,
options: deriveConfigFeatureSelectOptions(option, featureOption),
choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({
value: choice.id,
label: choice.label,
description: choice.description,
})),
},
];
});
}
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
/** The feature selects as the agent reports them, keyed by feature id. */
function featureValuesFromACP(
configOptions: SessionConfigOption[] | null | undefined,
featureOptions: ACPConfigFeatureOption[],
): Record<string, string> {
const values: Record<string, string> = {};
for (const featureOption of featureOptions) {
const current = findSelectConfigFeatureOption(configOptions, featureOption)?.currentValue;
if (current != null) values[featureOption.id] = current;
}
return values;
}
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
return {
type: "toggle",
id: ACP_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
value: isACPAutoAcceptEnabled(config),
};
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
}
const ACP_AUTO_ACCEPT_CONTROL: AgentOptionControl = {
type: "toggle",
key: ACP_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
};
function resolveACPCreateConfig(
input: ResolveAgentCreateConfigInput,
): ResolveAgentCreateConfigResult {
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
const featureValues =
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
: input.featureValues;
const providerOptions =
isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
: input.providerOptions;
if (
input.requestedMode === undefined &&
@@ -918,22 +935,16 @@ function resolveACPCreateConfig(
input.parent !== null &&
input.parent.provider !== input.provider
) {
return { modeId: undefined, featureValues };
return { modeId: undefined, providerOptions };
}
return resolveDefaultAgentCreateConfig({ ...input, featureValues });
return resolveDefaultAgentCreateConfig({ ...input, providerOptions });
}
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
return (
isDefaultAgentCreateConfigUnattended(input) ||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
input.features?.some(
(feature) =>
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
feature.type === "toggle" &&
feature.value === true,
) === true
input.config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true
);
}
@@ -1212,10 +1223,10 @@ export class ACPAgentClient implements AgentClient {
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
async listControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
const options = config.providerOptions ?? {};
if (this.configFeatureOptions.length === 0) {
return [autoAcceptFeature];
return { controls: [ACP_AUTO_ACCEPT_CONTROL], options };
}
this.assertProvider(config);
@@ -1230,10 +1241,18 @@ export class ACPAgentClient implements AgentClient {
);
probeSessionId = response.sessionId;
const transformed = this.transformSessionResponse(response);
return [
autoAcceptFeature,
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
];
return {
controls: [
ACP_AUTO_ACCEPT_CONTROL,
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
],
// The probe opened with the agent's own defaults, which a session would overwrite with
// the set values, so those stand over the probe's reading.
options: {
...featureValuesFromACP(transformed.configOptions, this.configFeatureOptions),
...options,
},
};
} finally {
await this.closeProbe(probe, probeSessionId);
}
@@ -1776,6 +1795,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private currentTitle: string | null = null;
private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = [];
/** How the opened session differs from the options it was given. */
openOptionsPatch: Record<string, unknown> = {};
/** Selects the provider moves while Paseo is opening or applying options belong to that result, not to events. */
private optionsTransactionDepth = 0;
private cachedCommands: AgentSlashCommand[] = [];
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
@@ -2100,10 +2123,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode;
}
get features(): AgentFeature[] {
get controls(): AgentOptionControl[] {
return [
buildACPAutoAcceptFeature(this.config),
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
ACP_AUTO_ACCEPT_CONTROL,
...deriveControlsFromACP(this.configOptions, this.configFeatureOptions),
];
}
@@ -2240,6 +2263,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({
sessionId,
@@ -2254,6 +2278,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId,
label: "mode",
});
this.reportFeatureChanges(before);
this.availableModes = this.modesFromConfig().modes;
this.pushEvent({
type: "mode_changed",
@@ -2366,6 +2391,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({
sessionId,
@@ -2380,6 +2406,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modelId,
label: "model",
});
this.reportFeatureChanges(before);
this.pushEvent({
type: "model_changed",
provider: this.provider,
@@ -2417,6 +2444,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
}
const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({
sessionId,
@@ -2431,6 +2459,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: thinkingOptionId,
label: "thought-level",
});
this.reportFeatureChanges(before);
this.pushEvent({
type: "thinking_option_changed",
provider: this.provider,
@@ -2438,14 +2467,91 @@ export class ACPAgentSession implements AgentSession, ACPClient {
});
}
async setFeature(featureId: string, value: unknown): Promise<void> {
/**
* Applies only the options the patch sets; returns the selects that ended up different.
* Provider notifications that arrive meanwhile are part of that result. When a write is
* rejected, what the provider moved is reported as an event, since nothing is stored.
* The agent manager sends one apply at a time per session.
*/
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
assertChangesAreLive({
provider: this.config.provider,
patch: options,
current: this.config.providerOptions,
live: [ACP_AUTO_ACCEPT_FEATURE_ID, ...this.configFeatureOptions.map(({ id }) => id)],
});
ACPProviderOptionsSchema.parse(
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
);
const before = this.featureValues();
const requested: Record<string, unknown> = {};
try {
await this.inOptionsTransaction(async () => {
for (const [id, value] of Object.entries(options)) {
const featureOption = this.configFeatureOptions.find((candidate) => candidate.id === id);
if (!featureOption) continue;
// A removal asks for the select's empty choice; a provider without one rejects it. The
// choice it lands on is reported against the `null` that was asked, so the options
// hold the value the session runs with.
requested[id] = value;
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (!option || (value ?? "") !== option.currentValue) {
await this.setConfigFeature(id, value);
}
}
});
} catch (error) {
this.reportFeatureChanges(before);
throw error;
}
// 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,
};
}
return this.featureDifferences({ ...before, ...requested });
}
private async inOptionsTransaction<T>(run: () => Promise<T>): Promise<T> {
this.optionsTransactionDepth += 1;
try {
return await run();
} finally {
this.optionsTransactionDepth -= 1;
}
}
/** The selects as the provider reports them now. */
private featureValues(): Record<string, string> {
return featureValuesFromACP(this.configOptions, this.configFeatureOptions);
}
/** The selects whose value is not what `expected` says, including ones it does not name. */
private featureDifferences(expected: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(
Object.entries(this.featureValues()).filter(([id, value]) => expected[id] !== value),
);
}
/** Reports the selects the provider moved on its own, compared with their values `before`. */
private reportFeatureChanges(before: Record<string, string>): void {
if (this.optionsTransactionDepth > 0) return;
const patch = this.featureDifferences(before);
if (Object.keys(patch).length > 0) {
this.pushEvent({ type: "options_changed", provider: this.provider, patch });
}
}
private async setConfigFeature(featureId: string, value: unknown): Promise<void> {
if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized");
}
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
this.config.featureValues = {
...this.config.featureValues,
this.config.providerOptions = {
...this.config.providerOptions,
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
};
return;
@@ -2477,14 +2583,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.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
}
private applyConfigOptionResponse({
@@ -3031,7 +3136,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
}
private async applyConfiguredOverrides(): Promise<void> {
private applyConfiguredOverrides(): Promise<void> {
return this.inOptionsTransaction(() => this.writeConfiguredOverrides());
}
private async writeConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) {
const selection = this.resolveModeSelection(configuredModeId);
@@ -3059,13 +3168,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
await this.setThinkingOption(this.config.thinkingOptionId);
}
const configuredFeatureValues = this.config.featureValues ?? {};
const configuredFeatureValues = this.config.providerOptions ?? {};
for (const featureOption of this.configFeatureOptions) {
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
continue;
}
try {
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
await this.setConfigFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
} catch (error) {
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
throw error;
@@ -3076,6 +3185,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
);
}
}
// The options are the state, so every select the session opened with is reported, including
// ones nobody set (a native default is stored like any other value).
this.openOptionsPatch = this.featureDifferences(configuredFeatureValues);
}
/**
@@ -3320,6 +3432,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
const before = this.featureValues();
this.configOptions = this.transformConfigOptions(update.configOptions);
const modeInfo = this.modesFromConfig();
const nextMode = modeInfo.currentModeId;
@@ -3330,6 +3443,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentMode = nextMode ?? this.currentMode;
this.currentModel = nextModel ?? this.currentModel;
this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId;
this.reportFeatureChanges(before);
const events: AgentStreamEvent[] = [];
if (nextMode !== null) {
@@ -2,6 +2,8 @@ import { z } from "zod";
export const ACPProviderOptionsSchema = z
.object({
// Composer control: approves permission prompts without asking.
auto_accept: z.boolean().optional(),
supportsMcpServers: z.boolean().optional(),
clientCapabilities: z
.object({
@@ -18,6 +18,9 @@ import {
resolveClaudeCodeVersion,
toClaudeSdkMcpConfig,
} from "./agent.js";
import { AgentManager } from "../../agent-manager.js";
import { ensureAgentLoaded } from "../../agent-loading.js";
import { AgentStorage } from "../../agent-storage.js";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
@@ -30,6 +33,8 @@ import type {
} from "../../agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { applyProviderOptionsPatch } from "../../option-controls.js";
import { mergeProviderOptions } from "../../provider-options.js";
interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
@@ -1143,60 +1148,91 @@ describe("ClaudeAgentSession features", () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
).resolves.toEqual({
controls: [expect.objectContaining({ key: "fast_mode", type: "toggle" })],
options: {},
});
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8[1m]",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
).resolves.toEqual({
controls: [expect.objectContaining({ key: "fast_mode", type: "toggle" })],
options: {},
});
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8-20260101",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
).resolves.toEqual({
controls: [expect.objectContaining({ key: "fast_mode", type: "toggle" })],
options: {},
});
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-5",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
).resolves.toEqual({
controls: [expect.objectContaining({ key: "fast_mode", type: "toggle" })],
options: {},
});
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "openrouter/anthropic/claude-opus-4-8",
}),
).resolves.toEqual([]);
).resolves.toEqual({ controls: [], options: {} });
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
}),
).resolves.toEqual([]);
).resolves.toEqual({ controls: [], options: {} });
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-4-6",
}),
).resolves.toEqual([]);
).resolves.toEqual({ controls: [], options: {} });
});
test("opens with Fast off on a model that has none, so unrelated options still apply", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
providerOptions: { fast_mode: true },
});
expect(session.openOptionsPatch).toEqual({ fast_mode: false });
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
await session.close();
});
test("passes initial fast mode through Claude flag settings", async () => {
@@ -1210,7 +1246,7 @@ describe("ClaudeAgentSession features", () => {
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
await expect(
@@ -1706,7 +1742,7 @@ describe("ClaudeAgentSession features", () => {
ensureQuery(): Promise<unknown>;
}
).ensureQuery();
await session.setFeature?.("fast_mode", true);
await session.applyOptions?.({ fast_mode: true });
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
@@ -1715,6 +1751,242 @@ describe("ClaudeAgentSession features", () => {
await session.close();
});
test("rejects a change to a launch option while the first query is starting", async () => {
const { queryFactory } = createQueryMock();
let releaseBinary!: () => void;
const binaryLookup = new Promise<void>((resolve) => {
releaseBinary = resolve;
});
let lookupStarted!: () => void;
const lookupEntered = new Promise<void>((resolve) => {
lookupStarted = resolve;
});
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => {
lookupStarted();
await binaryLookup;
return "/test/claude/bin";
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
providerOptions: { disallowedTools: ["Bash"] },
});
const turn = session.startTurn("hello");
await lookupEntered;
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
/disallowedTools.*cannot change on a running session/,
);
releaseBinary();
await turn;
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ disallowedTools: ["Bash"] });
await session.close();
});
test("a patch before the first prompt keeps the restrictions the session opened with", async () => {
const { queryFactory } = 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",
providerOptions: {
disallowedTools: ["Bash"],
sandbox: { enabled: true, failIfUnavailable: true },
},
});
await session.applyOptions?.({ fast_mode: true });
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
disallowedTools: ["Bash"],
sandbox: { enabled: true, failIfUnavailable: true },
});
await session.close();
});
test("an option Claude reads when it starts changes before the query exists and is rejected after", async () => {
const { queryFactory } = 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",
providerOptions: { disallowedTools: ["Bash"] },
});
await session.applyOptions?.({ disallowedTools: ["Bash", "Write"] });
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
disallowedTools: ["Bash", "Write"],
});
// The query now runs with these; replacing it would end its background work.
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
/disallowedTools.*cannot change on a running session/,
);
// The same value is not a change, and Fast still applies.
await expect(
session.applyOptions?.({ disallowedTools: ["Bash", "Write"], fast_mode: true }),
).resolves.toBeUndefined();
expect(queryFactory).toHaveBeenCalledTimes(1);
await session.close();
});
test("switching to a model without Fast turns it off, reports that, and later patches still apply", 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",
providerOptions: { fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setModel?.("claude-fable-5");
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
const reported = events.filter((event) => event.type === "options_changed");
expect(reported).toEqual([
{ type: "options_changed", provider: "claude", patch: { fast_mode: false } },
]);
// With a `config.json` default of `fast_mode: true`, the agent's effective options must
// still read Fast as off: a removed key would let the default fill `true` back in.
const stored = applyProviderOptionsPatch({
options: { fast_mode: true },
patch: reported[0].patch,
});
expect(mergeProviderOptions({ fast_mode: true }, stored)).toEqual({ fast_mode: false });
await expect(session.applyOptions?.({ fast_mode: null })).resolves.toBeUndefined();
await session.close();
});
test("removing fast_mode turns Fast off", 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 });
await session.applyOptions?.({ fast_mode: null });
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
await session.close();
});
test("retries a fast mode change the runtime rejected", 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();
queryMock.applyFlagSettings.mockClear();
queryMock.applyFlagSettings.mockRejectedValueOnce(new Error("runtime unavailable"));
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
"runtime unavailable",
);
await session.applyOptions?.({ fast_mode: true });
expect(queryMock.applyFlagSettings).toHaveBeenCalledTimes(2);
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
await session.close();
});
test("fast mode survives a daemon restart through the stored providerOptions", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-"));
const storagePath = path.join(root, "agents");
const makeClient = () => {
const mock = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory: mock.queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
return { ...mock, client };
};
const agentId = "00000000-0000-4000-8000-0000000000f1";
const before = makeClient();
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { claude: before.client },
registry: storage,
logger,
});
try {
await manager.createAgent(
{ provider: "claude", cwd: root, model: "claude-opus-4-8" },
agentId,
{ workspaceId: undefined },
);
await manager.setAgentOptions(agentId, { fast_mode: true });
await manager.closeAgent(agentId);
await manager.flush();
await storage.flush();
const after = makeClient();
const restartedStorage = new AgentStorage(storagePath, logger);
const restarted = new AgentManager({
clients: { claude: after.client },
registry: restartedStorage,
logger,
});
const agent = await ensureAgentLoaded(agentId, {
agentManager: restarted,
agentStorage: restartedStorage,
logger,
});
const session = (agent as unknown as { session: { ensureQuery(): Promise<unknown> } | null })
.session;
await session?.ensureQuery();
expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true });
expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
fastMode: true,
});
await restarted.closeAgent(agentId);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
@@ -1,6 +1,8 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import { validateProviderOptions } from "../../provider-options.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { applyProviderOptionsPatch } from "../../option-controls.js";
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs";
@@ -65,7 +67,7 @@ import { foldSubagentObservations, type SubagentObservation } from "./subagents/
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
@@ -104,7 +106,8 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentDraftControls,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -1758,12 +1761,11 @@ export class ClaudeAgentClient implements AgentClient {
});
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const claudeConfig = this.assertConfig(config);
return buildClaudeFeatures({
modelId: claudeConfig.model,
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
});
async listControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
return {
controls: buildClaudeControls(this.assertConfig(config).model),
options: config.providerOptions ?? {},
};
}
async listImportableSessions(
@@ -2388,6 +2390,8 @@ class ClaudeAgentSession implements AgentSession {
private compactionMarkerOpen = false;
private queryPumpPromise: Promise<void> | null = null;
private queryRestartNeeded = false;
/** True from the moment a query's launch options are copied until the query exists. */
private queryStarting = false;
private pendingInterruptAbort = false;
/**
* Whether Claude's main session is inside one of its own turns: set by system/init and by a
@@ -2411,6 +2415,7 @@ class ClaudeAgentSession implements AgentSession {
private pendingFreshSessionId: string | null = null;
private recentStderr = "";
private closed = false;
readonly openOptionsPatch: Record<string, unknown> = {};
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
this.config = config;
@@ -2454,17 +2459,21 @@ class ClaudeAgentSession implements AgentSession {
if (this.currentMode !== "plan") {
this.planResumeMode = this.currentMode;
}
// Fast cannot run on this model, so the session opens with it off. Off rather than removed
// keeps a `config.json` default of `fast_mode: true` from filling it back in.
if (config.providerOptions.fast_mode === true && !claudeModelSupportsFastMode(config.model)) {
this.config.providerOptions = { ...config.providerOptions, fast_mode: false };
this.openOptionsPatch = { fast_mode: false };
}
}
get id(): string | null {
return this.claudeSessionId;
}
get features(): AgentFeature[] {
return buildClaudeFeatures({
modelId: this.config.model,
fastModeEnabled: this.config.featureValues?.fast_mode === true,
});
get controls(): AgentOptionControl[] {
return buildClaudeControls(this.config.model);
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -2791,8 +2800,20 @@ class ClaudeAgentSession implements AgentSession {
await activeQuery.setModel(normalizedModelId ?? undefined);
this.config.model = normalizedModelId ?? undefined;
this.reconcileThinkingOptionForModel(normalizedModelId);
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
await this.applyFastModeFeature(false, activeQuery);
if (this.config.providerOptions.fast_mode) {
const supported = claudeModelSupportsFastMode(this.config.model);
await activeQuery.applyFlagSettings({ fastMode: supported });
if (!supported) {
// Fast cannot run on this model. Reporting it off rather than removed keeps a
// `config.json` default of `fast_mode: true` from filling it back in.
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: false };
this.pushEvent({
type: "options_changed",
provider: "claude",
patch: { fast_mode: false },
});
}
this.cachedRuntimeInfo = null;
}
this.contextUsage.setInitialContextWindowMaxTokens(
getClaudeContextWindowMaxTokens(this.config.model),
@@ -2845,31 +2866,36 @@ class ClaudeAgentSession implements AgentSession {
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId !== "fast_mode") {
throw new Error(`Unknown Claude feature: ${featureId}`);
async applyOptions(options: ProviderOptions): Promise<void> {
// Every option but Fast is read when the query is built, and replacing a started query would
// end its background work, so once it exists only Fast can change.
if (this.query || this.queryStarting) {
assertChangesAreLive({
provider: "claude",
patch: options,
current: this.config.providerOptions,
live: ["fast_mode"],
});
}
const enabled = Boolean(value);
if (enabled && !claudeModelSupportsFastMode(this.config.model)) {
const providerOptions =
validateProviderOptions(
"claude",
ClaudeProviderOptionsSchema,
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: 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"}'`,
);
}
await this.applyFastModeFeature(enabled);
}
private async applyFastModeFeature(enabled: boolean, query?: Query): Promise<void> {
this.config.featureValues = {
...this.config.featureValues,
fast_mode: enabled,
};
const activeQuery = query ?? this.query;
if (activeQuery) {
await activeQuery.applyFlagSettings({ fastMode: enabled });
const fastModeChanged =
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
if (fastModeChanged) {
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
this.cachedRuntimeInfo = null;
}
this.cachedRuntimeInfo = null;
// Held until the runtime accepts, so a rejected change is retried instead of skipped.
this.config.providerOptions = providerOptions;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -3565,9 +3591,17 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = await this.buildOptions(launchMode);
// Closure can finish while launch options are being prepared.
if (this.closed) throw new Error("Claude session is closed");
// buildOptions copies the launch options before it waits, so from here they are fixed.
this.queryStarting = true;
let options: ClaudeOptions;
try {
options = await this.buildOptions(launchMode);
// Closure can finish while launch options are being prepared.
if (this.closed) throw new Error("Claude session is closed");
} catch (error) {
this.queryStarting = false;
throw error;
}
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.runtimeSafety.reset();
@@ -3586,6 +3620,7 @@ class ClaudeAgentSession implements AgentSession {
},
},
);
this.queryStarting = false;
const fastMode = this.resolveFastModeSetting();
if (fastMode !== null) {
await this.query.applyFlagSettings({ fastMode });
@@ -3705,10 +3740,9 @@ class ClaudeAgentSession implements AgentSession {
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
const providerOptions = applyClaudeToolPolicy(
this.config.providerOptions,
this.config.toolPolicy,
);
// `fast_mode` is a composer control the provider applies through flag settings.
const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
@@ -3827,7 +3861,7 @@ class ClaudeAgentSession implements AgentSession {
if (!claudeModelSupportsFastMode(this.config.model)) {
return null;
}
return this.config.featureValues?.fast_mode === true;
return this.config.providerOptions.fast_mode === true;
}
private normalizeMcpServers(
@@ -1,9 +1,9 @@
import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js";
import type { AgentOptionControl, AgentOptionToggleControl } from "../../agent-sdk-types.js";
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
type: "toggle",
id: "fast_mode",
key: "fast_mode",
label: "Fast",
description: "Lower latency Opus responses at higher token cost",
tooltip: "Toggle fast mode",
@@ -14,18 +14,6 @@ export function claudeModelSupportsFastMode(modelId: string | null | undefined):
return claudeManifestModelSupportsFastMode(modelId);
}
export function buildClaudeFeatures(input: {
modelId: string | null | undefined;
fastModeEnabled: boolean;
}): AgentFeature[] {
if (!claudeModelSupportsFastMode(input.modelId)) {
return [];
}
return [
{
...CLAUDE_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
},
];
export function buildClaudeControls(modelId: string | null | undefined): AgentOptionControl[] {
return claudeModelSupportsFastMode(modelId) ? [{ ...CLAUDE_FAST_MODE_CONTROL }] : [];
}
@@ -45,6 +45,8 @@ const SandboxFilesystemSchema = z
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
export const ClaudeProviderOptionsSchema = z
.object({
/** Composer control, not a Claude SDK option: the provider reads it and keeps it out of the SDK options. */
fast_mode: z.boolean().optional(),
allowedTools: z.array(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
additionalDirectories: z.array(z.string()).optional(),
@@ -1,7 +1,20 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pino from "pino";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { AgentManager } from "../agent-manager.js";
import { toAgentPayload } from "../agent-projections.js";
import { AgentStorage } from "../agent-storage.js";
import { featuresFromControls } from "@getpaseo/protocol/option-controls";
import { convertLegacyOptions } from "../option-controls.js";
import type {
AgentClient,
AgentSession,
AgentSessionConfig,
AgentStreamEvent,
} from "../agent-sdk-types.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import {
createFakeCodexAppServer,
@@ -37,23 +50,32 @@ const TEST_SPEED_MODELS = [
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
}));
function speedFeature(value: string) {
function speedControl() {
return {
type: "select",
id: "service_tier",
key: "service_tier",
label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
value,
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
};
}
const PLAN_CONTROL = {
type: "toggle",
key: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
};
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
{
name: "Code",
@@ -132,7 +154,7 @@ async function createConnectedSession(
return harness;
}
describe("Codex app-server provider features", () => {
describe("Codex app-server provider controls", () => {
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
const { session, appServer } = await createConnectedSession(
{ model: "gpt-6.1-sol" },
@@ -149,19 +171,19 @@ describe("Codex app-server provider features", () => {
},
);
try {
expect(session.features).toContainEqual(
expect(session.controls).toContainEqual(
expect.objectContaining({
id: "service_tier",
key: "service_tier",
type: "select",
value: "default",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
{ id: "ultrafast", label: "Ultrafast" },
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
{ value: "ultrafast", label: "Ultrafast" },
],
}),
);
await session.setFeature?.("service_tier", "ultrafast");
await session.applyOptions?.({ service_tier: "ultrafast" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "ultrafast",
@@ -186,16 +208,16 @@ describe("Codex app-server provider features", () => {
},
);
try {
expect(session.features).toContainEqual(
expect(session.controls).toContainEqual(
expect.objectContaining({
id: "service_tier",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "future-tier", label: "Future speed" },
key: "service_tier",
choices: [
{ value: "default", label: "Normal" },
{ value: "future-tier", label: "Future speed" },
],
}),
);
await session.setFeature?.("service_tier", "future-tier");
await session.applyOptions?.({ service_tier: "future-tier" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "future-codex-model",
@@ -210,7 +232,7 @@ describe("Codex app-server provider features", () => {
"restores %s and Normal explicitly overrides an existing thread tier",
async (tier) => {
const { session, appServer } = await createConnectedSession(
{ model: "future-model", featureValues: { service_tier: tier } },
{ model: "future-model", providerOptions: { service_tier: tier } },
{
models: [
{
@@ -224,11 +246,8 @@ describe("Codex app-server provider features", () => {
},
);
try {
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: tier }),
);
await session.getRuntimeInfo();
await session.setFeature?.("service_tier", "default");
await session.applyOptions?.({ service_tier: "default" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
@@ -239,17 +258,40 @@ describe("Codex app-server provider features", () => {
},
);
test("rejects unadvertised tiers and non-string values", async () => {
const { session } = await createConnectedSession();
test.each(["priority", "ultrafast"])("restores %s from providerOptions", async (tier) => {
const { session, appServer } = await createConnectedSession(
{ model: "future-model", providerOptions: { service_tier: tier } },
{
models: [
{
id: "future-model",
serviceTiers: [
{ id: "priority", name: "Fast", description: "Fast processing" },
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
],
},
],
},
);
try {
for (const value of ["ultrafast", "bogus", true, null]) {
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: tier });
} finally {
await session.close();
}
});
test("rejects unadvertised tiers and non-string values", async () => {
const { session, appServer } = await createConnectedSession();
try {
for (const value of ["ultrafast", "bogus"]) {
await expect(session.applyOptions?.({ service_tier: value })).rejects.toThrow(
"is not available",
);
}
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "default" }),
);
await expect(session.applyOptions?.({ service_tier: true })).rejects.toThrow();
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
} finally {
await session.close();
}
@@ -257,7 +299,7 @@ describe("Codex app-server provider features", () => {
test("switching to a model without the selected tier resets speed", async () => {
const { session, appServer } = await createConnectedSession(
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
{ model: "ultra-model", providerOptions: { service_tier: "ultrafast" } },
{
models: [
{
@@ -270,8 +312,14 @@ describe("Codex app-server provider features", () => {
);
try {
await session.setModel("gpt-6.1-sol");
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "default" }),
expect(session.controls).toContainEqual(
expect.objectContaining({
key: "service_tier",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
}),
);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
@@ -280,10 +328,47 @@ describe("Codex app-server provider features", () => {
}
});
test("maps legacy Fast commands to the catalog's request id", async () => {
test("a Fast chosen as `fast` is kept as the catalog's id, so a model with a different Fast id resets it", async () => {
const { session, appServer } = await createConnectedSession(
{ model: "model-a" },
{
models: [
{
id: "model-a",
isDefault: true,
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster" }],
},
{
id: "model-b",
serviceTiers: [{ id: "rapid", name: "Fast", description: "Faster" }],
},
],
},
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
try {
await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({
service_tier: "priority",
});
await session.setModel("model-b");
// The option the session reports and the tier the turn runs are the same speed.
expect(events.filter((event) => event.type === "options_changed")).toEqual([
expect.objectContaining({ patch: { service_tier: "default" } }),
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
} finally {
await session.close();
}
});
test("`fast` maps to the catalog's request id", async () => {
const { session, appServer } = await createConnectedSession();
try {
await session.setFeature?.("fast_mode", true);
await session.applyOptions?.({ service_tier: "fast" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
@@ -307,13 +392,13 @@ describe("Codex app-server provider features", () => {
])("exposes and sends Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({ model });
try {
expect(session.features).toContainEqual(
expect(session.controls).toContainEqual(
expect.objectContaining({
id: "service_tier",
value: "default",
key: "service_tier",
defaultValue: "default",
}),
);
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model,
@@ -338,12 +423,13 @@ describe("Codex app-server provider features", () => {
])("does not expose or restore Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({
model,
featureValues: { fast_mode: true },
providerOptions: { service_tier: "fast" },
});
const fresh = await createConnectedSession({ model });
try {
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
`Codex fast mode is not available for model '${model}'`,
expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]);
await expect(fresh.session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow(
`Codex speed 'fast' is not available for model '${model}'`,
);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -351,21 +437,16 @@ describe("Codex app-server provider features", () => {
});
} finally {
await session.close();
await fresh.session.close();
}
});
test("restores Fast on Astra and preserves it when switching supported models", async () => {
const { session, appServer } = await createConnectedSession({
model: "gpt-6-astra",
featureValues: { fast_mode: true },
providerOptions: { service_tier: "fast" },
});
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "service_tier",
value: "priority",
}),
);
await session.setModel("gpt-5.6-sol");
await session.setModel("gpt-6-astra");
await session.startTurn("hello");
@@ -378,72 +459,33 @@ describe("Codex app-server provider features", () => {
}
});
test("features returns speed selector and plan toggle when supported", async () => {
const { session } = await createConnectedSession();
test("controls return the speed selector and plan toggle when supported", async () => {
const { session, appServer } = await createConnectedSession();
expect(session.features).toEqual([
speedFeature("default"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
await session.setFeature?.("service_tier", "priority");
await session.setFeature?.("plan_mode", true);
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
await session.startTurn("hello");
expect(session.features).toEqual([
speedFeature("priority"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
collaborationMode: expect.objectContaining({ mode: "plan" }),
});
});
test("features returns only plan toggle when model does not support fast mode", async () => {
test("controls return only the plan toggle when model does not support fast mode", async () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([PLAN_CONTROL]);
});
test("constructor ignores restored fast mode when model does not support it", async () => {
const { session, appServer } = await createConnectedSession({
model: "gpt-3.5-turbo",
featureValues: { fast_mode: true },
providerOptions: { service_tier: "fast" },
});
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([PLAN_CONTROL]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -454,7 +496,7 @@ describe("Codex app-server provider features", () => {
test("selecting Fast sends the catalog service tier", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -462,12 +504,12 @@ describe("Codex app-server provider features", () => {
});
});
test("legacy Fast off explicitly selects Normal", async () => {
test("selecting Normal after Fast explicitly selects Normal", async () => {
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true },
providerOptions: { service_tier: "fast" },
});
await session.setFeature?.("fast_mode", false);
await session.applyOptions?.({ service_tier: "default" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -475,53 +517,39 @@ describe("Codex app-server provider features", () => {
});
});
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => {
test('applyOptions({ service_tier: "fast" }) rejects models that do not support fast mode', async () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
await expect(session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow(
"Codex speed 'fast' is not available for model 'gpt-3.5-turbo'",
);
});
test("setFeature invalidates runtime info", async () => {
test("applyOptions invalidates runtime info", async () => {
const { session } = await createConnectedSession();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
extra: { collaborationMode: "Plan" },
});
await session.setFeature?.("plan_mode", true);
await session.applyOptions?.({ plan_mode: true });
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
extra: { collaborationMode: "Plan" },
});
});
test("setFeature throws for unknown feature ids", async () => {
const { session } = createSessionHarness();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature",
);
});
test("constructor restores feature flags from config.featureValues", async () => {
test("constructor restores composer options from config.providerOptions", async () => {
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true, plan_mode: true },
providerOptions: { service_tier: "fast", plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
expect(session.features).toEqual([
speedFeature("priority"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
// A saved `fast` opens as the model's Fast tier, which the open patch says.
expect(session.openOptionsPatch).toEqual({ service_tier: "priority" });
expect(events.filter((event) => event.type === "options_changed")).toEqual([]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -532,10 +560,36 @@ describe("Codex app-server provider features", () => {
});
});
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" },
});
await session.applyOptions?.({ plan_mode: true });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
sandboxPolicy: expect.objectContaining({ type: "readOnly" }),
});
});
test("rejects a live change to the approval policy or sandbox, which the thread fixed when it started", async () => {
const { session } = await createConnectedSession({
providerOptions: { sandbox_mode: "workspace-write" },
});
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).rejects.toThrow(
/sandbox_mode.*cannot change on a running session/,
);
// The same value is not a change.
await expect(session.applyOptions?.({ sandbox_mode: "workspace-write" })).resolves.toEqual({});
});
test("startTurn includes serviceTier when fast mode is enabled", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -543,11 +597,351 @@ describe("Codex app-server provider features", () => {
});
});
test("opening on a model without the saved tier runs Normal and the open patch says so", async () => {
const models = [
{ id: "plain", isDefault: true, defaultReasoningEffort: "medium", serviceTiers: [] },
];
const { session, appServer } = await createConnectedSession(
{ model: "plain", providerOptions: { service_tier: "priority" } },
{ models },
);
expect(session.openOptionsPatch).toEqual({ service_tier: "default" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
});
test("a saved service_tier: fast runs as the model's Fast tier and the open patch names that tier", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { service_tier: "fast" },
});
expect(session.openOptionsPatch).toEqual({ service_tier: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
});
test("a live service_tier: fast is returned as the model's Fast tier", async () => {
const { session, appServer } = await createConnectedSession();
await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({
service_tier: "priority",
});
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
});
test("switching to a model without the speed tier says Normal and keeps it so on the way back", async () => {
const models = [
...TEST_SPEED_MODELS,
{ id: "plain", isDefault: false, defaultReasoningEffort: "medium", serviceTiers: [] },
];
const { session, appServer } = await createConnectedSession(
{ model: "gpt-5.4", providerOptions: { service_tier: "priority" } },
{ models },
);
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setModel?.("plain");
await session.setModel?.("gpt-5.4");
expect(events.filter((event) => event.type === "options_changed")).toEqual([
{ type: "options_changed", provider: "codex", patch: { service_tier: "default" } },
]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
});
describe("legacy Fast (fast_mode) saved or requested before service_tier", () => {
function createLegacyFastHarness(
rawDefaults: Record<string, unknown> = {},
providerId = CODEX_PROVIDER,
) {
// The provider registry converts configured defaults before it merges them.
const configDefaults = convertLegacyOptions({
provider: CODEX_PROVIDER,
options: rawDefaults,
});
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
"model/list": () => ({ data: TEST_SPEED_MODELS }),
});
const logger = createTestLogger();
const openSession = async (
config: AgentSessionConfig,
resumeSessionId: string | null,
): Promise<AgentSession> => {
const session = new CodexAppServerAgentSession(
// The provider registry hands sessions the `config.json` defaults merged into the agent's own options.
{
...config,
provider: CODEX_PROVIDER,
providerOptions: { ...configDefaults, ...config.providerOptions },
},
resumeSessionId ? { sessionId: resumeSessionId } : null,
logger,
async () => appServer.child,
);
await session.connect();
return session;
};
const client: AgentClient = {
provider: providerId,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
effectiveOptions: (own) => ({ ...configDefaults, ...own }),
createSession: (config) => openSession(config, null),
resumeSession: (handle, config) =>
openSession(
{ ...config, provider: CODEX_PROVIDER } as AgentSessionConfig,
handle.sessionId,
),
};
const workdir = mkdtempSync(join(tmpdir(), "codex-legacy-fast-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { [providerId]: client },
// A custom provider that extends Codex runs on the Codex adapter.
providerDefinitions:
providerId === CODEX_PROVIDER
? {}
: { [providerId]: { enabled: true, derivedFromProviderId: CODEX_PROVIDER } },
registry: storage,
logger,
});
const turnStarts = () =>
appServer
.requests()
.filter((message) => message.method === "turn/start")
.map((message) => message.params);
async function create(providerOptions?: Record<string, unknown>) {
const agent = await manager.createAgent(
{
provider: providerId,
cwd: workdir,
model: "gpt-5.4",
...(providerOptions ? { providerOptions } : {}),
},
undefined,
{ workspaceId: undefined },
);
return agent.id;
}
async function resume(providerOptions: Record<string, unknown>) {
const agent = await manager.resumeAgentFromPersistence({
provider: providerId,
sessionId: "thread-1",
metadata: { cwd: workdir, model: "gpt-5.4", providerOptions },
});
return agent.id;
}
function shownSpeed(agentId: string) {
const payload = toAgentPayload(manager.getAgent(agentId)!);
const control = payload.controls?.find((candidate) => candidate.key === "service_tier");
const choice =
control?.type === "select"
? control.choices.find((candidate) => candidate.value === payload.options?.service_tier)
: undefined;
return {
options: payload.options,
own: manager.getAgent(agentId)?.config.providerOptions,
// What a client reading `options` + `controls` selects, and what the legacy `features` show.
selected:
payload.options?.service_tier === undefined && control?.type === "select"
? control.defaultValue
: choice?.value,
feature: payload.features?.find((feature) => feature.id === "service_tier")?.value,
};
}
async function runTurn(agentId: string, expectedTier: string) {
const before = turnStarts().length;
await manager.getAgent(agentId)!.session!.startTurn("hello");
await vi.waitFor(() => expect(turnStarts()).toHaveLength(before + 1));
const turn = turnStarts()[before]!;
expect(turn).toMatchObject({ serviceTier: expectedTier });
appServer.completeTurn({ threadId: String(turn.threadId) });
await vi.waitFor(() => expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"));
}
return { manager, create, resume, shownSpeed, runTurn };
}
const FAST = { selected: "priority", feature: "priority" };
test("a saved fast_mode 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)).toMatchObject({
options: { service_tier: "priority" },
own: { service_tier: "priority" },
...FAST,
});
await runTurn(agentId, "priority");
});
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)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("from config.json defaults runs Fast and is shown as Fast", async () => {
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
const agentId = await create();
expect(shownSpeed(agentId)).toMatchObject(FAST);
await runTurn(agentId, "priority");
});
test("a saved fast_mode beats a configured service_tier default", async () => {
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({
service_tier: "default",
});
const agentId = await resume({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("a provider extending Codex opens a saved fast_mode as Fast", async () => {
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
const agentId = await resume({ fast_mode: true });
await manager.flush();
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("a provider extending Codex creates with a requested fast_mode and applies a later one", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
const agentId = await create({ fast_mode: false });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
await manager.setAgentOptions(agentId, { fast_mode: true });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
});
test("an older client's fast_mode 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({ own: { service_tier: "priority" }, ...FAST });
await runTurn(agentId, "priority");
await manager.setAgentOptions(agentId, { service_tier: "default" });
expect(shownSpeed(agentId)).toMatchObject({
own: { service_tier: "default" },
selected: "default",
feature: "default",
});
await runTurn(agentId, "default");
await manager.setAgentOptions(agentId, { service_tier: "priority" });
expect(shownSpeed(agentId)).toMatchObject({ selected: "priority" });
await runTurn(agentId, "priority");
});
test("a request carrying both speed keys follows service_tier", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ service_tier: "priority" });
await manager.setAgentOptions(agentId, { service_tier: "default", fast_mode: true });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
await runTurn(agentId, "default");
});
test.each([
[CODEX_PROVIDER, "a requested legacy fast_mode", {}, { fast_mode: true }],
["my-codex", "a requested legacy fast_mode", {}, { fast_mode: true }],
[CODEX_PROVIDER, "a configured legacy fast_mode", { fast_mode: true }, undefined],
[CODEX_PROVIDER, "a configured Fast alias", { service_tier: "fast" }, undefined],
])(
"draft speed for %s with %s is the one the first turn runs with",
async (providerId, _name, configured, requested) => {
const drafting = createLegacyFastHarness(configured, providerId);
const draft = await drafting.manager.listDraftControls({
provider: providerId,
cwd: tmpdir(),
model: "gpt-5.4",
...(requested ? { providerOptions: requested } : {}),
});
const features = featuresFromControls({ controls: draft.controls, options: draft.options });
expect(draft.options).toEqual({ service_tier: "priority" });
expect(draft.controls).toContainEqual(expect.objectContaining({ key: "service_tier" }));
expect(features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "priority" }),
);
// A draft session ends with its app-server; creating runs on a fresh one.
const creating = createLegacyFastHarness(configured, providerId);
const agentId = await creating.create(requested);
await creating.runTurn(agentId, "priority");
},
);
test.each([
["a string", "false"],
["a number", 1],
])("a legacy fast_mode that is %s is rejected, not read as Fast", async (_name, value) => {
const { manager, create } = createLegacyFastHarness();
const agentId = await create({});
await expect(manager.setAgentOptions(agentId, { fast_mode: value })).rejects.toThrow(
/fast_mode must be true, false or null/,
);
});
test("an older client turning fast_mode off selects Normal", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ service_tier: "priority" });
await manager.setAgentOptions(agentId, { fast_mode: false });
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
await runTurn(agentId, "default");
});
test("removing the speed returns the agent to Normal", async () => {
const { create, manager, shownSpeed } = createLegacyFastHarness();
const agentId = await create({ service_tier: "priority" });
expect(shownSpeed(agentId)).toMatchObject(FAST);
await manager.setAgentOptions(agentId, { service_tier: null });
expect(shownSpeed(agentId)).toMatchObject({ own: undefined, selected: "default" });
});
});
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
const capture = createCapturedLogger();
const prompt = "secret prompt text should not be logged";
const { session } = await createConnectedSession(
{ featureValues: { fast_mode: true } },
{ providerOptions: { service_tier: "fast" } },
{ logger: capture.logger },
);
@@ -570,20 +964,10 @@ describe("Codex app-server provider features", () => {
test("setModel clears fast mode when switching to an unsupported model", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([PLAN_CONTROL]);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -594,7 +978,7 @@ describe("Codex app-server provider features", () => {
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("plan_mode", true);
await session.applyOptions?.({ plan_mode: true });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -413,7 +413,7 @@ describe("Codex app-server provider (local e2e)", () => {
);
try {
await session.setFeature?.("plan_mode", true);
await session.applyOptions!({ plan_mode: true });
const events: AgentStreamEvent[] = [];
session.subscribe((event) => {
@@ -38,7 +38,7 @@ async function withPendingPlan(
cwd: workdir,
modeId: "auto",
model: "gpt-5.4",
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -526,7 +526,7 @@ test("idle provider exit preserves a synthetic plan approval across reconnect",
cwd: workdir,
modeId: "auto",
model: "gpt-5.4",
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
},
undefined,
{ workspaceId: undefined },
@@ -6449,7 +6449,7 @@ describe("Codex app-server provider", () => {
test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, service_tier: "fast" },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -6519,7 +6519,7 @@ describe("Codex app-server provider", () => {
test("does not complete Codex plan timeline cards while plan approval is pending", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, service_tier: "fast" },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -6565,7 +6565,7 @@ describe("Codex app-server provider", () => {
test("replaces a pending synthetic plan approval when a later plan completes", async () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
@@ -6598,7 +6598,7 @@ describe("Codex app-server provider", () => {
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -6640,7 +6640,7 @@ describe("Codex app-server provider", () => {
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
@@ -6694,7 +6694,7 @@ describe("Codex app-server provider", () => {
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
@@ -6744,7 +6744,7 @@ describe("Codex app-server provider", () => {
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
await asInternals(session).handleNotification("turn/started", {
@@ -7368,7 +7368,7 @@ describe("Codex app-server provider", () => {
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, service_tier: "fast" },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -7399,9 +7399,10 @@ describe("Codex app-server provider", () => {
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({
plan_mode: false,
fast_mode: true,
expect(events).toContainEqual({
type: "options_changed",
provider: "codex",
patch: { plan_mode: false },
});
// The session returns the follow-up prompt instead of calling startTurn directly.
// The caller (session/agent-manager) is responsible for sending it through streamAgent.
@@ -7422,7 +7423,7 @@ describe("Codex app-server provider", () => {
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: false },
providerOptions: { plan_mode: true, service_tier: "default" },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -7451,11 +7452,12 @@ describe("Codex app-server provider", () => {
selectedActionId: "implement",
});
expect(asInternals(session).serviceTier).toBeNull();
expect(asInternals(session).serviceTier).toBe("default");
expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({
plan_mode: false,
fast_mode: false,
expect(events).toContainEqual({
type: "options_changed",
provider: "codex",
patch: { plan_mode: false },
});
expect(result?.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."),
@@ -7464,7 +7466,7 @@ describe("Codex app-server provider", () => {
test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, service_tier: "fast" },
});
asInternals(session).collaborationModes = [
{
@@ -1,12 +1,14 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { applyProviderOptionsPatch } from "../option-controls.js";
import { assertChangesAreLive, validateProviderOptions } from "../provider-options.js";
import { CodexBackgroundInventory } from "./codex/background-inventory.js";
import { validateProviderOptions } from "../provider-options.js";
import {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentResumeSessionOptions,
type AgentMode,
@@ -78,7 +80,7 @@ import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import {
buildCodexFeatures,
buildCodexControls,
CodexServiceTierSchema,
readCodexServiceTier,
type CodexServiceTier,
@@ -3504,7 +3506,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly codexHome: string;
private currentMode: string;
private hasWorkflowModeOverride: boolean;
private readonly providerOptions: CodexProviderOptions;
private providerOptions: CodexProviderOptions;
private resolvedWorkspaceWrite: NonNullable<
CodexProviderOptions["sandbox_workspace_write"]
> | null = null;
@@ -3529,12 +3531,15 @@ 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. */
openOptionsPatch: Record<string, unknown> = {};
private readonly turnNamespace = randomUUID();
private nextTurnOrdinal = 0;
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: string | null = null;
/** The tier the options ask for; `serviceTier` is what the current model can honor. */
private requestedServiceTier: string | null = null;
private speedModels: CodexModel[] = [];
private planModeEnabled = false;
// History read by rewind or an archived resume, held until streamHistory consumes it.
@@ -3655,10 +3660,8 @@ export class CodexAppServerAgentSession implements AgentSession {
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
this.serviceTier = readCodexServiceTier(this.config.featureValues);
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
this.requestedServiceTier = readCodexServiceTier(this.providerOptions);
this.planModeEnabled = this.providerOptions.plan_mode === true;
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
@@ -3673,11 +3676,9 @@ export class CodexAppServerAgentSession implements AgentSession {
return this.currentThreadId;
}
get features(): AgentFeature[] {
return buildCodexFeatures({
get controls(): AgentOptionControl[] {
return buildCodexControls({
serviceTiers: this.currentServiceTiers(),
serviceTier: this.serviceTier ?? "default",
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
}
@@ -3742,7 +3743,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
this.reconcileServiceTier();
this.normalizeSpeed();
await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes();
await this.loadSkills();
@@ -3957,24 +3958,34 @@ export class CodexAppServerAgentSession implements AgentSession {
return selectedModel?.serviceTiers ?? [];
}
private reconcileServiceTier(): void {
const tiers = this.currentServiceTiers();
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
}
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
this.serviceTier = null;
}
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
/**
* A saved `service_tier: "fast"` runs as the model's Fast tier, and a tier the opening model
* lacks runs as Normal; the session says so in `openOptionsPatch`, so the options and the
* control agree (as when the model is switched later).
*/
private normalizeSpeed(): void {
const requested = this.requestedServiceTier;
const resolved = this.resolveServiceTier(requested);
if (requested === resolved) return;
const runs = resolved ?? "default";
this.providerOptions = { ...this.providerOptions, service_tier: runs };
this.requestedServiceTier = runs;
this.openOptionsPatch = { service_tier: runs };
}
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
this.planModeEnabled = value;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
private get serviceTier(): string | null {
return this.resolveServiceTier(this.requestedServiceTier);
}
private resolveServiceTier(requested: string | null): string | null {
// Until the model list arrives there is nothing to check the request against.
if (this.speedModels.length === 0 || requested === "default") return requested;
const tiers = this.currentServiceTiers();
// `fast` names whichever tier the model calls Fast.
if (requested === "fast" && !tiers.some((tier) => tier.id === "fast")) {
return tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
}
return tiers.some((tier) => tier.id === requested) ? requested : null;
}
private rememberPlanResult(item: ToolCallTimelineItem): void {
@@ -4039,7 +4050,13 @@ export class CodexAppServerAgentSession implements AgentSession {
const planText =
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
this.applyFeatureValue("plan_mode", false);
this.setPlanMode(false);
// The user left plan mode; their options follow so controls and reopened sessions agree.
this.emitEvent({
type: "options_changed",
provider: CODEX_PROVIDER,
patch: { plan_mode: false },
});
return buildCodexPlanImplementationPrompt(planText);
}
@@ -4749,9 +4766,27 @@ export class CodexAppServerAgentSession implements AgentSession {
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
this.reconcileServiceTier();
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
this.dropUnavailableServiceTier();
}
/**
* A speed the new model does not offer turns into Normal and is reported, so choosing a model
* that does offer it again does not bring the paid tier back. It is reported as Normal rather
* than removed so a configured default cannot fill the old speed back in.
*/
private dropUnavailableServiceTier(): void {
const requested = this.requestedServiceTier;
if (requested === null || this.speedModels.length === 0) return;
if (this.isServiceTierAvailable(requested)) return;
this.requestedServiceTier = "default";
this.providerOptions = { ...this.providerOptions, service_tier: "default" };
this.emitEvent({
type: "options_changed",
provider: CODEX_PROVIDER,
patch: { service_tier: "default" },
});
}
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
@@ -4763,35 +4798,53 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
if (featureId === "fast_mode") {
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
if (value && !fast)
/** Applies only the options the patch sets; the rest of the running options stay as they are. */
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
// The approval policy and sandbox are sent when the thread starts, not with each turn.
assertChangesAreLive({
provider: "codex",
patch: options,
current: this.providerOptions,
live: ["service_tier", "plan_mode"],
});
const valid =
validateProviderOptions(
"codex",
CodexProviderOptionsSchema,
applyProviderOptionsPatch({ options: this.providerOptions, patch: options }),
) ?? {};
const requested = "service_tier" in options ? readCodexServiceTier(options) : null;
let accepted = this.requestedServiceTier;
if ("service_tier" in options) {
if (requested !== accepted && !this.isServiceTierAvailable(requested)) {
throw new Error(
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
);
return this.setFeature("service_tier", value ? fast!.id : "default");
}
if (featureId === "service_tier") {
if (
typeof value !== "string" ||
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value))
) {
throw new Error(
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`,
`Codex speed '${String(requested)}' is not available for model '${this.config.model ?? "default"}'`,
);
}
this.serviceTier = value;
this.reconcileServiceTier();
this.cachedRuntimeInfo = null;
return;
// The session keeps the catalog's id for the tier, so a later model switch sees what runs.
accepted = requested && (this.resolveServiceTier(requested) ?? requested);
}
if (featureId === "plan_mode") {
this.applyFeatureValue("plan_mode", Boolean(value));
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
this.providerOptions = { ...valid, ...(accepted ? { service_tier: accepted } : {}) };
this.requestedServiceTier = accepted || null;
if ("plan_mode" in options) this.setPlanMode(options.plan_mode === true);
this.cachedRuntimeInfo = null;
// The catalog may name the tier differently from the request (`fast` is the Fast tier's id).
const tier = this.serviceTier;
return tier !== null && requested !== null && tier !== requested ? { service_tier: tier } : {};
}
private isServiceTierAvailable(tier: string | null): boolean {
if (tier === null || tier === "default") return true;
const tiers = this.currentServiceTiers();
const namesFast = tier === "fast" && tiers.some((entry) => entry.name.toLowerCase() === "fast");
return namesFast || tiers.some((entry) => entry.id === tier);
}
private setPlanMode(enabled: boolean): void {
if (this.planModeEnabled === enabled) return;
this.planModeEnabled = enabled;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -5595,7 +5648,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async applyDefaultModelAndThinking(): Promise<string> {
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model;
this.reconcileServiceTier();
this.config.thinkingOptionId = thinkingOptionId;
this.refreshResolvedCollaborationMode();
return model;
@@ -5663,7 +5715,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private buildCodexInnerConfig(): Record<string, unknown> | null {
const innerConfig: Record<string, unknown> = {};
Object.assign(innerConfig, this.providerOptions);
const { service_tier: _tier, plan_mode: _plan, ...codexConfig } = this.providerOptions;
Object.assign(innerConfig, codexConfig);
if (this.deps.customCodexConfig) {
Object.assign(innerConfig, this.deps.customCodexConfig);
}
@@ -1,4 +1,4 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
import type { AgentOptionControl, AgentOptionToggleControl } from "../agent-sdk-types.js";
import { z } from "zod";
@@ -9,51 +9,47 @@ export const CodexServiceTierSchema = z.object({
});
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] {
function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[] {
if (tiers.length === 0) return [];
return [
{
type: "select",
id: "service_tier",
key: "service_tier",
label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
value,
options: [
{ id: "default", label: "Normal", isDefault: true },
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
...tiers.map((tier) => ({ value: tier.id, label: tier.name })),
],
},
];
}
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = {
type: "toggle",
id: "plan_mode",
key: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
};
export function buildCodexFeatures(input: {
export function buildCodexControls(input: {
serviceTiers: CodexServiceTier[];
serviceTier: string;
planModeEnabled: boolean;
planModeAvailable?: boolean;
}): AgentFeature[] {
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
}): AgentOptionControl[] {
const controls = buildCodexSpeedControl(input.serviceTiers);
if (input.planModeAvailable !== false) {
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled });
controls.push({ ...CODEX_PLAN_MODE_CONTROL });
}
return features;
return controls;
}
export function readCodexServiceTier(values: Record<string, unknown> | undefined): string | null {
const tier = values?.service_tier;
if (typeof tier === "string") return tier;
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
return values?.fast_mode ? "fast" : null;
export function readCodexServiceTier(options: Record<string, unknown> | undefined): string | null {
const tier = options?.service_tier;
return typeof tier === "string" ? tier : null;
}
@@ -40,7 +40,7 @@ describe("Codex app-server provider (real) plan mode", () => {
});
try {
await session.setFeature?.("plan_mode", true);
await session.applyOptions!({ plan_mode: true });
const result = await session.run(
"You are in plan mode. Produce a markdown plan with a short heading and exactly 3 bullets for implementing a login screen. Do not ask questions.",
@@ -37,6 +37,9 @@ const NetworkPolicySchema = z
// Codex config reference, maintained against Codex CLI 0.143+.
export const CodexProviderOptionsSchema = z
.object({
// Composer controls, not Codex config: the provider reads them and keeps them out of `config`.
service_tier: z.string().optional(),
plan_mode: z.boolean().optional(),
approval_policy: ApprovalPolicySchema.optional(),
sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(),
sandbox_workspace_write: z
@@ -155,7 +155,7 @@ describe("CursorACPAgentClient model discovery", () => {
});
});
test("exposes Cursor fast mode through provider features", async () => {
test("exposes Cursor fast mode through provider controls", async () => {
const client = new TestCursorACPAgentClient({
sessionId: "session-1",
models: null,
@@ -163,46 +163,35 @@ describe("CursorACPAgentClient model discovery", () => {
});
await expect(
client.listFeatures({
client.listControls({
provider: "acp",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
value: false,
},
{
type: "select",
id: CURSOR_FAST_FEATURE_OPTION.id,
label: "Fast",
description: "Cursor fast mode",
tooltip: "Select Cursor fast mode",
icon: "zap",
value: "false",
options: [
{
id: "false",
label: "Off",
isDefault: true,
description: undefined,
metadata: undefined,
},
{
id: "true",
label: "Fast",
isDefault: false,
description: undefined,
metadata: undefined,
},
],
},
]);
).resolves.toEqual({
controls: [
{
type: "toggle",
key: "auto_accept",
label: "Auto Accept",
description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
},
{
type: "select",
key: CURSOR_FAST_FEATURE_OPTION.id,
label: "Fast",
description: "Cursor fast mode",
tooltip: "Select Cursor fast mode",
icon: "zap",
choices: [
{ value: "false", label: "Off" },
{ value: "true", label: "Fast" },
],
},
],
options: { fast: "false" },
});
});
});
@@ -228,7 +217,9 @@ describe("CursorACPAgentClient session start", () => {
session: {
currentModelId: string;
configOptions: SessionConfigOption[];
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>;
setSessionConfigOption?: (request: {
value: string;
}) => Promise<{ configOptions: SessionConfigOption[] }>;
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
logger = createTestLogger(),
): ACPAgentSession {
@@ -277,7 +268,7 @@ describe("CursorACPAgentClient session start", () => {
// still stored after the draft moved to a model that has no fast variant.
const warn = captureWarnings();
const session = createCursorSession(
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
warn.logger,
);
@@ -295,7 +286,7 @@ describe("CursorACPAgentClient session start", () => {
// still listed after the model moves to one that has no fast variant.
const warn = captureWarnings();
const session = createCursorSession(
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
@@ -319,7 +310,7 @@ describe("CursorACPAgentClient session start", () => {
test("still fails session start when a stored feature write fails for another reason", async () => {
const session = createCursorSession(
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
@@ -334,16 +325,71 @@ describe("CursorACPAgentClient session start", () => {
const session = createCursorSession({ model: "kimi-k3" });
await session.initializeNewSession();
await expect(session.setFeature(CURSOR_FAST_FEATURE_OPTION.id, "true")).rejects.toThrow(
await expect(session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" })).rejects.toThrow(
"acp does not expose ACP feature 'fast'",
);
});
test("removing auto_accept returns the session to asking for permission", async () => {
const session = createCursorSession({ model: "composer-2.5" });
await session.initializeNewSession();
const request = {
sessionId: "session-1",
toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" },
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
} as const;
await session.applyOptions({ auto_accept: true });
await expect(session.requestPermission({ ...request })).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-always" },
});
await expect(session.applyOptions({ auto_accept: null })).resolves.toEqual({});
void session.requestPermission({ ...request });
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
});
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("applies only the selects the patch sets", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
}));
const session = createCursorSession(
{ model: "composer-2.5" },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
setSessionConfigOption,
},
);
await session.initializeNewSession();
await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" });
await session.applyOptions({});
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 () => {
// No model switch, so the session's options came straight from session/new and the
// provider disagreeing about a feature it just advertised is a real failure.
const session = createCursorSession(
{ model: "composer-2.5", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
@@ -68,7 +68,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
featureValues: { mockPromptRejections: 1 },
providerOptions: { mockPromptRejections: 1 },
});
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
@@ -95,7 +95,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
featureValues: {
providerOptions: {
mockStreamingAssistantResponse: response,
mockStreamingAssistantIntervalMs: 20,
},
@@ -467,7 +467,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock",
cwd: process.cwd(),
model: "e2e-fast-stream",
featureValues: { mockAssistantResponses: ["First response", "![Second](second.png)"] },
providerOptions: { mockAssistantResponses: ["First response", "![Second](second.png)"] },
});
await session.run("Emit a synthetic turn failure.");
expect((await session.run("First prompt")).finalText).toBe("First response");
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentOptionControl,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
@@ -787,7 +787,7 @@ function getConfiguredAssistantResponses(value: unknown): string[] {
export class MockLoadTestAgentSession implements AgentSession {
readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID;
readonly capabilities = CAPABILITIES;
readonly features: AgentFeature[] = [];
readonly controls: AgentOptionControl[] = [];
readonly id: string;
private readonly usageSessionKey = randomUUID();
@@ -812,18 +812,18 @@ export class MockLoadTestAgentSession implements AgentSession {
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
this.assistantResponse =
typeof options.config.featureValues?.mockAssistantResponse === "string"
? options.config.featureValues.mockAssistantResponse
typeof options.config.providerOptions?.mockAssistantResponse === "string"
? options.config.providerOptions.mockAssistantResponse
: null;
this.assistantResponses = getConfiguredAssistantResponses(
options.config.featureValues?.mockAssistantResponses,
options.config.providerOptions?.mockAssistantResponses,
);
this.streamingAssistantResponse =
typeof options.config.featureValues?.mockStreamingAssistantResponse === "string"
? options.config.featureValues.mockStreamingAssistantResponse
typeof options.config.providerOptions?.mockStreamingAssistantResponse === "string"
? options.config.providerOptions.mockStreamingAssistantResponse
: null;
const requestedStreamingInterval =
options.config.featureValues?.mockStreamingAssistantIntervalMs;
options.config.providerOptions?.mockStreamingAssistantIntervalMs;
this.streamingAssistantIntervalMs =
typeof requestedStreamingInterval === "number" &&
Number.isFinite(requestedStreamingInterval) &&
@@ -831,10 +831,10 @@ export class MockLoadTestAgentSession implements AgentSession {
? Math.min(requestedStreamingInterval, 1_000)
: MOCK_LOAD_TEST_INTERVAL_MS;
this.rewindError =
typeof options.config.featureValues?.mockRewindError === "string"
? options.config.featureValues.mockRewindError
typeof options.config.providerOptions?.mockRewindError === "string"
? options.config.providerOptions.mockRewindError
: null;
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
const requestedPromptRejections = options.config.providerOptions?.mockPromptRejections;
this.remainingPromptRejections =
typeof requestedPromptRejections === "number" &&
Number.isSafeInteger(requestedPromptRejections) &&
@@ -842,7 +842,7 @@ export class MockLoadTestAgentSession implements AgentSession {
? requestedPromptRejections
: 0;
this.remainingSteerFailures = getPositiveFeatureInteger(
options.config.featureValues?.mockSteerAmbiguousFailures,
options.config.providerOptions?.mockSteerAmbiguousFailures,
);
}
@@ -613,9 +613,9 @@ describe("OMP agent client and session", () => {
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
"openrouter/google/gemini-3.8-flash",
);
expect(omp.requireSession().features).toEqual([
expect(omp.requireSession().controls).toEqual([
expect.objectContaining({
value: true,
key: "fast_mode",
description: expect.stringMatching(/does not apply/i),
}),
]);
@@ -626,18 +626,90 @@ describe("OMP agent client and session", () => {
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: false };
await session.setFeature?.("fast_mode", true);
await session.applyOptions?.({ fast_mode: true });
expect(omp.runtime().setFastModeRequests).toEqual([true]);
expect(session.features).toEqual([
expect(session.controls).toEqual([
expect.objectContaining({
id: "fast_mode",
value: true,
key: "fast_mode",
description: expect.stringMatching(/does not apply to this model/i),
tooltip: expect.stringMatching(/does not apply to this model/i),
}),
]);
});
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();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.applyOptions?.({ fast_mode: true });
expect(omp.runtime().setFastModeRequests).toEqual([true]);
// A patch without the key leaves the runtime alone.
await session.applyOptions?.({});
expect(omp.runtime().setFastModeRequests).toEqual([true]);
});
test("rejects a live option the creation schema would reject, and sends nothing", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).rejects.toThrow(/omp/i);
await expect(session.applyOptions?.({ fast_mode: "yes" })).rejects.toThrow(/omp/i);
expect(omp.runtime().setFastModeRequests).toEqual([]);
});
test("a removal turns Fast off, and a Fast the runtime keeps is returned", 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 });
omp.runtime().fastModeResult = { enabled: false, active: false };
await expect(session.applyOptions?.({ fast_mode: null })).resolves.toEqual({});
expect(omp.runtime().setFastModeRequests).toEqual([true, false]);
omp.runtime().fastModeResult = { enabled: false, active: false };
await expect(session.applyOptions?.({ fast_mode: true })).resolves.toEqual({
fast_mode: false,
});
expect(omp.eventTypes()).not.toContain("options_changed");
});
test("a session opened without the Fast it was given says so in its open patch", async () => {
const omp = new OmpHarness();
// The fake runtime answers the open-time Fast request with Fast off.
await omp.start({ providerOptions: { fast_mode: true } });
expect(omp.requireSession().openOptionsPatch).toEqual({ fast_mode: false });
expect(omp.eventTypes()).not.toContain("options_changed");
});
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 () => {
const omp = new OmpHarness();
await omp.start();
@@ -649,11 +721,11 @@ describe("OMP agent client and session", () => {
} = omp.runtime().state;
omp.runtime().state = olderState;
await session.getRuntimeInfo();
expect(session.features).toEqual([]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
expect(session.controls).toEqual([]);
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(/unavailable/i);
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
await session.getRuntimeInfo();
expect(session.features).toHaveLength(1);
expect(session.controls).toHaveLength(1);
});
test("switching model refreshes whether Fast applies", async () => {
@@ -661,8 +733,7 @@ describe("OMP agent client and session", () => {
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.setFeature?.("fast_mode", true);
expect(session.features).toEqual([expect.objectContaining({ value: true })]);
await session.applyOptions?.({ fast_mode: true });
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
omp.runtime().queueStateReports([
@@ -674,24 +745,79 @@ describe("OMP agent client and session", () => {
},
]);
await session.setModel?.("openrouter/other/model");
expect(session.features).toEqual([
expect(session.controls).toEqual([
expect.objectContaining({
value: true,
key: "fast_mode",
description: expect.stringMatching(/does not apply/i),
}),
]);
});
test("a replacement runtime starts with the Fast the session has now, not the one it opened with", async () => {
const omp = new OmpHarness();
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: false, active: false };
await session.applyOptions?.({ fast_mode: false });
await omp.setMode("ask");
// The approval-mode restart starts a second runtime; it must not turn Fast back on.
expect(omp.runtimeLaunches()).toHaveLength(2);
expect(omp.runtime().setFastModeRequests).toEqual([]);
});
test("a replacement runtime starts with the Fast the session opened with, not the one it was asked for", async () => {
const omp = new OmpHarness();
// The fake runtime answers the open-time Fast request with Fast off.
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
expect(omp.requireSession().openOptionsPatch).toEqual({ fast_mode: false });
omp.runtime().fastModeResult = { enabled: true, active: true };
await omp.setMode("ask");
expect(omp.runtimeLaunches()).toHaveLength(2);
expect(omp.runtime().setFastModeRequests).toEqual([]);
});
test("a replacement runtime that refuses the Fast the session had publishes what it accepted", async () => {
const omp = new OmpHarness();
omp.acceptFastOnNextRuntime();
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
expect(omp.requireSession().openOptionsPatch).toEqual({});
// The approval-mode restart starts a second runtime, which refuses Fast.
await omp.setMode("ask");
expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]);
});
test("a Fast change the runtime reports when a turn completes is published", async () => {
const omp = new OmpHarness();
omp.acceptFastOnNextRuntime();
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
omp.runtime().state = { ...omp.runtime().state, fastModeEnabled: false, fastModeActive: false };
const { completion } = await omp.startPromptUntilProviderIdle("hello", "done", {
isStreaming: false,
isCompacting: false,
});
await completion;
await omp.requireSession().getRuntimeInfo();
expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]);
});
test("restores Fast from the initial OMP state on create and resume", async () => {
const created = new OmpHarness();
await created.start({ featureValues: { fast_mode: true } });
await created.start({ providerOptions: { fast_mode: true } });
expect(created.runtime().setFastModeRequests).toEqual([true]);
expect(created.runtime().getStateRequestCount).toBe(1);
const resumed = new OmpHarness();
await resumed.resume(
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
{ featureValues: { fast_mode: true } },
{ providerOptions: { fast_mode: true } },
);
expect(resumed.runtime().setFastModeRequests).toEqual([true]);
expect(resumed.runtime().getStateRequestCount).toBe(1);
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
@@ -9,7 +10,8 @@ import stripAnsi from "strip-ansi";
import {
type AgentCapabilityFlags,
type AgentClient,
type AgentFeature,
type AgentDraftControls,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -66,9 +68,12 @@ import {
resolveOmpLaunchMode,
resolveOmpProviderOptions,
OMP_MODES,
OmpProviderOptionsSchema,
type OmpRuntimeOptions,
} from "./provider-config.js";
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
import { applyProviderOptionsPatch } from "../../option-controls.js";
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
import { getUserMessageText } from "./message-history.js";
@@ -750,6 +755,10 @@ export class OmpAgentSession implements AgentSession {
this.restartRuntime = options.restartRuntime;
this.config = options.config;
this.state = options.initialState;
this.openOptionsPatch = this.fastModeDifference(options.config.providerOptions?.fast_mode);
// A replacement runtime starts from the Fast this one opened with, not the one it was asked for.
if (typeof this.openOptionsPatch.fast_mode === "boolean")
this.rememberFastMode(this.openOptionsPatch.fast_mode);
this.currentModeId = options.currentModeId ?? null;
this.logger = options.logger;
this.live = options.live ?? true;
@@ -804,15 +813,14 @@ export class OmpAgentSession implements AgentSession {
return this.state.sessionId;
}
get features(): AgentFeature[] {
get controls(): AgentOptionControl[] {
if (!hasOmpFastMode(this.state)) return [];
return [
{
type: "toggle",
id: "fast_mode",
key: "fast_mode",
label: "Fast",
icon: "zap",
value: this.state.fastModeEnabled === true,
...(this.state.fastModeEnabled && !this.state.fastModeActive
? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
: {}),
@@ -820,19 +828,50 @@ export class OmpAgentSession implements AgentSession {
];
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`);
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
assertChangesAreLive({
provider: "omp",
patch: options,
current: this.config.providerOptions,
live: ["fast_mode"],
});
const next = validateProviderOptions(
"omp",
OmpProviderOptionsSchema,
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
);
if (options.fast_mode === undefined) return {};
const requested = next?.fast_mode ?? false;
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
const result = await this.runtimeSession.setFastMode(value);
this.state = {
...this.state,
fastModeEnabled: result.enabled,
fastModeActive: result.active,
};
this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled };
if (requested !== this.state.fastModeEnabled) {
const result = await this.runtimeSession.setFastMode(requested);
this.state = {
...this.state,
fastModeEnabled: result.enabled,
fastModeActive: result.active,
};
}
if (typeof this.state.fastModeEnabled === "boolean") {
this.rememberFastMode(this.state.fastModeEnabled);
}
return this.fastModeDifference(requested);
}
/** A replacement runtime starts from the Fast the session has now, not the one it opened with. */
private rememberFastMode(enabled: boolean): void {
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: enabled };
}
/** 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. */
readonly openOptionsPatch: Record<string, unknown>;
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
return runProviderTurn({
prompt,
@@ -1072,7 +1111,7 @@ export class OmpAgentSession implements AgentSession {
this.pendingPromptResults.clear();
this.runtimeSession = next;
this.hostTools = restarted.hostTools;
this.state = state;
this.installState(state);
this.currentModeId = modeId;
this.config.modeId = modeId;
this.runtimeDead = false;
@@ -2211,7 +2250,7 @@ export class OmpAgentSession implements AgentSession {
}
try {
const state = await this.runtimeSession.getState();
this.state = state;
this.installState(state);
if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) {
// An interrupt settled this turn while the state check was in flight.
return;
@@ -2228,7 +2267,22 @@ export class OmpAgentSession implements AgentSession {
}
private async refreshState(): Promise<void> {
this.state = await this.runtimeSession.getState();
this.installState(await this.runtimeSession.getState());
}
// The runtime owns Fast: whatever it reports is what the session now runs with, so every state
// read from the runtime is installed here and a difference is published once.
private installState(state: OmpSessionState): void {
const before = this.state.fastModeEnabled;
this.state = state;
if (this.state.fastModeEnabled !== before && typeof this.state.fastModeEnabled === "boolean") {
this.rememberFastMode(this.state.fastModeEnabled);
this.emit({
type: "options_changed",
provider: this.provider,
patch: { fast_mode: this.state.fastModeEnabled },
});
}
}
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
@@ -2296,7 +2350,7 @@ export class OmpAgentClient implements AgentClient {
config: AgentSessionConfig,
state: OmpSessionState,
): Promise<OmpSessionState> {
const value = config.featureValues?.fast_mode;
const value = config.providerOptions?.fast_mode;
if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
return state;
const result = await runtimeSession.setFastMode(value);
@@ -2531,8 +2585,8 @@ export class OmpAgentClient implements AgentClient {
}
}
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
return [];
async listControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
return { controls: [], options: config.providerOptions ?? {} };
}
async listImportableSessions(
@@ -15,6 +15,8 @@ export { OMP_MODES };
export const OmpProviderOptionsSchema = z
.object({
// Composer control, not a runtime option: the session reads it and asks OMP to match.
fast_mode: z.boolean().optional(),
sessionDir: z.string().min(1).optional(),
rpcTimeoutMs: z.number().int().positive().optional(),
smolModel: z.string().min(1).optional(),
@@ -467,6 +467,16 @@ export class OmpHarness {
return this.events.map((event) => event.type);
}
acceptFastOnNextRuntime(): void {
this.omp.queueSessionSetup((runtime) => {
runtime.fastModeResult = { enabled: true, active: true };
});
}
optionsChanges(): Record<string, unknown>[] {
return this.events.flatMap((event) => (event.type === "options_changed" ? [event.patch] : []));
}
async history(): Promise<AgentTimelineItem[]> {
const items: AgentTimelineItem[] = [];
for await (const event of this.requireSession().streamHistory()) {
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import { createTestLogger } from "../../../test-utils/test-logger.js";
import type { AgentStreamEvent } from "../agent-sdk-types.js";
@@ -108,33 +108,25 @@ describe("OpenCode auto_accept feature", () => {
expect(modes).toEqual([]);
});
test("lists auto accept as a provider feature", async () => {
test("lists auto accept as a provider control", async () => {
const { runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const enabledFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
});
const legacyFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
});
expect(enabledFeatures).toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
await expect(
client.listControls({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { auto_accept: true },
}),
]);
expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
).resolves.toEqual({
controls: [
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
],
options: { auto_accept: true },
});
});
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
@@ -151,7 +143,7 @@ describe("OpenCode auto_accept feature", () => {
});
expect(await session.getCurrentMode()).toBe("build");
expect(session.features).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
expect(session.controls).toEqual([expect.objectContaining({ key: "auto_accept" })]);
await session.run("Implement the change");
@@ -163,6 +155,48 @@ describe("OpenCode auto_accept feature", () => {
await session.close();
});
test("reports the Auto Accept that legacy full-access turned on, when it opens or resumes", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient();
runtime.enqueueClient(openCodeClient);
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const created = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
});
expect(created.openOptionsPatch).toEqual({ auto_accept: true });
await created.close();
const resumed = await client.resumeSession(
{ provider: "opencode", sessionId: "session-1" },
{ cwd: "/tmp/project", modeId: "full-access" },
);
expect(resumed.openOptionsPatch).toEqual({ auto_accept: true });
await resumed.close();
});
test("reports nothing when Auto Accept was already on", async () => {
const { runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
providerOptions: { auto_accept: true },
});
expect(session.openOptionsPatch).toEqual({});
await session.close();
});
test("resolves legacy full-access for provider-driven child creation", () => {
const client = new OpenCodeAgentClient(createTestLogger());
@@ -170,7 +204,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: "full-access",
featureValues: undefined,
providerOptions: undefined,
parent: null,
unattended: false,
availableModes: [
@@ -178,7 +212,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "plan", label: "Plan" },
],
}),
).toEqual({ modeId: "build", featureValues: { auto_accept: true } });
).toEqual({ modeId: "build", providerOptions: { auto_accept: true } });
});
test("inherits unattended callers as auto accept with OpenCode's default agent", () => {
@@ -191,7 +225,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
modeId: "bypassPermissions",
@@ -203,7 +237,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "plan", label: "Plan" },
],
}),
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
});
test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => {
@@ -213,7 +247,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: null,
unattended: true,
availableModes: [
@@ -221,7 +255,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "plan", label: "Plan" },
],
}),
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
});
test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => {
@@ -231,7 +265,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "opencode",
modeId: "paseo-custom",
@@ -244,7 +278,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "paseo-custom", label: "Paseo Custom" },
],
}),
).toEqual({ modeId: "paseo-custom", featureValues: { auto_accept: true } });
).toEqual({ modeId: "paseo-custom", providerOptions: { auto_accept: true } });
});
test("inherits auto accept from an OpenCode parent when the child chooses a mode", () => {
@@ -254,7 +288,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: "base",
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "opencode",
modeId: "orchestrator",
@@ -267,7 +301,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "orchestrator", label: "Orchestrator" },
],
}),
).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
});
test("auto-approves tool permissions when auto accept is enabled", async () => {
@@ -283,7 +317,7 @@ describe("OpenCode auto_accept feature", () => {
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
session.subscribe((event) => receivedEvents.push(event));
@@ -301,6 +335,72 @@ describe("OpenCode auto_accept feature", () => {
await session.close();
});
test("rejects a live auto_accept the creation schema would reject, and keeps approving", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { auto_accept: true },
});
await expect(session.applyOptions?.({ auto_accept: "yes" })).rejects.toThrow(/auto_accept/);
await session.run("Run verification");
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
await session.close();
});
test("rejects a live permission change the running session cannot apply", async () => {
const { runtime } = mockOpenCodeClient({ events: [idleEvent()] });
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { permission: "allow" },
});
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
/permission.*cannot change on a running session/,
);
await expect(
session.applyOptions?.({ permission: "allow", auto_accept: true }),
).resolves.toBeUndefined();
await session.close();
});
test("stops auto-approving once auto_accept is removed", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { auto_accept: true },
});
await session.applyOptions?.({ auto_accept: null });
await session.startTurn("Run verification");
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
expect(openCodeClient.calls.permissionReply).toHaveLength(0);
await session.close();
});
test("keeps questions separate from auto accept tool approval", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()],
@@ -314,7 +414,7 @@ describe("OpenCode auto_accept feature", () => {
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
session.subscribe((event) => receivedEvents.push(event));
@@ -5300,7 +5300,7 @@ describe("OpenCode provider subagent contract", () => {
const parent = await client.createSession({
provider: "opencode",
cwd: "/workspace/repo",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
parent.subscribe(() => undefined);
@@ -1,4 +1,6 @@
import { validateProviderOptions } from "../provider-options.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { applyProviderOptionsPatch } from "../option-controls.js";
import { assertChangesAreLive, validateProviderOptions } from "../provider-options.js";
import {
createOpencodeClient,
type AssistantMessage as OpenCodeAssistantMessage,
@@ -25,7 +27,8 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentDraftControls,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
@@ -232,15 +235,25 @@ const DEFAULT_MODES: AgentMode[] = [
];
function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
return config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
}
function withOpenCodeAutoAcceptFeature(
featureValues: Record<string, unknown> | undefined,
/** What opening changed in the options the caller gave: legacy `full-access` turns Auto Accept on. */
function openCodeOpenOptionsPatch(input: {
given: AgentSessionConfig;
opened: OpenCodeAgentConfig;
}): Record<string, unknown> {
return isOpenCodeAutoAcceptEnabled(input.opened) && !isOpenCodeAutoAcceptEnabled(input.given)
? { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true }
: {};
}
function withOpenCodeAutoAccept(
providerOptions: ProviderOptions | undefined,
enabled: boolean,
): Record<string, unknown> {
): ProviderOptions {
return {
...featureValues,
...providerOptions,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
}
@@ -259,25 +272,25 @@ function resolveOpenCodeCreateConfig(
const requestedMode = legacyFullAccess
? OPENCODE_BUILD_MODE_ID
: (input.requestedMode ?? inheritedOpenCodeMode);
const featureValues =
const providerOptions =
legacyFullAccess ||
(isUnattendedCreate && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
? withOpenCodeAutoAcceptFeature(input.featureValues, true)
: input.featureValues;
(isUnattendedCreate && input.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
? withOpenCodeAutoAccept(input.providerOptions, true)
: input.providerOptions;
if (inheritsUnattended && requestedMode === undefined) {
// Unattendedness for OpenCode is carried by auto_accept (set above), not
// by any particular agent. Leave the mode unset so OpenCode uses its own
// default agent — `build` may not exist in the user's OpenCode config.
return { modeId: undefined, featureValues };
return { modeId: undefined, providerOptions };
}
const resolved = resolveDefaultAgentCreateConfig({
...input,
requestedMode,
featureValues,
providerOptions,
});
return { ...resolved, featureValues };
return { ...resolved, providerOptions };
}
function isOpenCodeCreateConfigUnattended(
@@ -285,26 +298,18 @@ function isOpenCodeCreateConfigUnattended(
): boolean {
return (
isDefaultAgentCreateConfigUnattended(input) ||
input.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true ||
input.features?.some(
(feature) =>
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
(feature.value === true || feature.value === "true"),
) === true
input.config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true
);
}
function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
return {
type: "toggle",
id: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves OpenCode tool permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
value: isOpenCodeAutoAcceptEnabled(config),
};
}
const OPENCODE_AUTO_ACCEPT_CONTROL: AgentOptionControl = {
type: "toggle",
key: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves OpenCode tool permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
};
function buildOpenCodePermissionActions(): AgentPermissionAction[] {
return [
@@ -686,10 +691,7 @@ function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConf
return {
...normalized,
modeId: OPENCODE_BUILD_MODE_ID,
featureValues: {
...normalized.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
},
providerOptions: withOpenCodeAutoAccept(normalized.providerOptions, true),
};
}
@@ -1507,6 +1509,7 @@ export class OpenCodeAgentClient implements AgentClient {
false,
unbindBridge,
connectServer,
openCodeOpenOptionsPatch({ given: config, opened: openCodeConfig }),
);
} catch (error) {
await connection.release();
@@ -1562,6 +1565,7 @@ export class OpenCodeAgentClient implements AgentClient {
registeredAcquisition !== null,
unbindBridge,
connectServer,
openCodeOpenOptionsPatch({ given: config, opened: openCodeConfig }),
);
} catch (error) {
await connection.release();
@@ -1683,8 +1687,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
async listControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
this.assertConfig(config);
return { controls: [OPENCODE_AUTO_ACCEPT_CONTROL], options: config.providerOptions ?? {} };
}
async listImportableSessions(
@@ -3375,6 +3380,7 @@ interface OpenCodeServerConnection {
class OpenCodeAgentSession implements AgentSession {
readonly provider = "opencode" as const;
readonly capabilities = OPENCODE_CAPABILITIES;
readonly openOptionsPatch: Record<string, unknown>;
private readonly config: OpenCodeAgentConfig;
private server: OpenCodeServerConnection;
@@ -3463,8 +3469,10 @@ class OpenCodeAgentSession implements AgentSession {
private readonly externallyDriven = false,
releaseBridge?: () => void,
connectServer?: () => Promise<OpenCodeServerConnection>,
openOptionsPatch: Record<string, unknown> = {},
) {
this.config = config;
this.openOptionsPatch = openOptionsPatch;
this.server = {
client,
events,
@@ -3570,8 +3578,8 @@ class OpenCodeAgentSession implements AgentSession {
return this.turnState.status === "running" ? this.turnState.turnId : null;
}
get features(): AgentFeature[] {
return [buildOpenCodeAutoAcceptFeature(this.config)];
get controls(): AgentOptionControl[] {
return [OPENCODE_AUTO_ACCEPT_CONTROL];
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -4961,7 +4969,15 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModeId = normalizeOpenCodeModeId(modeId);
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
this.currentMode = OPENCODE_BUILD_MODE_ID;
await this.setFeature(OPENCODE_AUTO_ACCEPT_FEATURE_ID, true);
this.setAutoAccept(true);
this.notifySubscribers(
{
type: "options_changed",
provider: "opencode",
patch: { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true },
},
null,
);
return;
}
@@ -4969,17 +4985,26 @@ class OpenCodeAgentSession implements AgentSession {
this.config.modeId = normalizedModeId ?? undefined;
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) {
throw new Error(`Unsupported OpenCode feature '${featureId}'`);
async applyOptions(options: ProviderOptions): Promise<void> {
assertChangesAreLive({
provider: "opencode",
patch: options,
current: this.config.providerOptions,
live: [OPENCODE_AUTO_ACCEPT_FEATURE_ID],
});
const next = validateProviderOptions(
"opencode",
OpenCodeProviderOptionsSchema,
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
);
if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) {
this.setAutoAccept(next?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true);
}
}
const enabled = value === true;
private setAutoAccept(enabled: boolean): void {
this.autoAcceptEnabled = enabled;
this.config.featureValues = {
...this.config.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
this.config.providerOptions = withOpenCodeAutoAccept(this.config.providerOptions, enabled);
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -10,6 +10,8 @@ const PermissionRuleSchema = z.union([
// OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46.
export const OpenCodeProviderOptionsSchema = z
.object({
// Composer control: approves permission prompts without asking.
auto_accept: z.boolean().optional(),
permission: z
.union([
PermissionActionSchema,
@@ -100,13 +100,13 @@ test.each([
});
try {
await writeFile(script, initialSource);
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto Accept",
);
expect(
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label,
).toBe("Auto Accept");
await writeFile(script, 'console.log("opencode v2.0.10")');
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto-accept",
);
expect(
(await client.listControls({ provider: "opencode", cwd: root })).controls[0]?.label,
).toBe("Auto-accept");
} finally {
await client.shutdown();
await rm(root, { recursive: true, force: true });
@@ -202,7 +202,7 @@ test.each([
const legacy = new OpenCodeAgentClient(logger);
const config = { provider: "opencode", cwd: root };
try {
expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config));
expect(await client.listControls(config)).toEqual(await legacy.listControls(config));
} finally {
await client.shutdown();
await legacy.shutdown();
@@ -221,8 +221,8 @@ test("selects v2 without probing the binary when an external server is configure
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
});
try {
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
const { controls } = await client.listControls({ provider: "opencode", cwd: "/tmp/project" });
expect(controls.map((control) => control.label)).toEqual(["Auto-accept"]);
} finally {
await client.shutdown();
}
@@ -160,8 +160,8 @@ export class OpenCodeRuntimeClient implements AgentClient {
async listCommands(config: AgentSessionConfig) {
return (await this.client()).listCommands(config);
}
async listFeatures(config: AgentSessionConfig) {
return (await this.client()).listFeatures(config);
async listControls(config: AgentSessionConfig) {
return (await this.client()).listControls(config);
}
async listImportableSessions(options?: ListImportableSessionsOptions) {
return (await this.client()).listImportableSessions(options);
@@ -37,6 +37,78 @@ describe("OpenCode v2 session lifecycle", () => {
expect(refreshed).toEqual(["/tmp/project"]);
await client.shutdown();
});
test("rejects a live auto_accept the creation schema would reject", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
await expect(session.applyOptions?.({ auto_accept: "yes" })).rejects.toThrow(/auto_accept/);
await expect(session.applyOptions?.({ auto_accept: true })).resolves.toBeUndefined();
await session.close();
});
test("a rejected auto_accept leaves the running session asking for permission", async () => {
const harness = new V2Harness();
const permissions = [
{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] },
];
harness.api.permission.list = async () => permissions;
const replies: string[] = [];
harness.api.permission.reply = async (input) => {
replies.push(input.requestID);
throw new Error("upstream refused");
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
await expect(session.applyOptions?.({ auto_accept: true })).rejects.toThrow(
"upstream refused",
);
const next = {
id: "next",
sessionID: "session",
action: "shell",
resources: ["ls"],
};
permissions.push(next);
harness.push({ id: "asked", created: 2, type: "permission.asked", data: next });
const pendingIds = () => session.getPendingPermissions().map(({ id }) => id);
await expect.poll(pendingIds).toContain("next");
expect(replies).not.toContain("next");
} finally {
await session.close();
}
});
test("rejects a live permission change the running session cannot apply", async () => {
const harness = new V2Harness();
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { permission: "allow" },
});
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
/permission.*cannot change on a running session/,
);
await expect(
session.applyOptions?.({ permission: "allow", auto_accept: true }),
).resolves.toBeUndefined();
await session.close();
});
test("completes from execution events without a long-lived HTTP wait", async () => {
const harness = new V2Harness();
harness.prompt = async (input) => {
@@ -1,7 +1,7 @@
import { OpenCodeV2Session } from "./session.js";
import { V2_CAPABILITIES } from "./capabilities.js";
import {
features,
controls,
permissionRules,
applyResumeOverrides,
requiresDedicatedV2Server,
@@ -17,7 +17,7 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentDraftControls,
AgentLaunchContext,
AgentPersistenceHandle,
AgentSession,
@@ -222,8 +222,8 @@ export class OpenCodeV2AgentClient implements AgentClient {
throw error;
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
return features(config);
async listControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
return { controls, options: config.providerOptions ?? {} };
}
async listCommands(config: AgentSessionConfig) {
const connection = await this.runtime.acquire();
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
import { controls, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
describe("OpenCode v2 permission rules", () => {
test("maps authored native permissions and exact injected MCP grants", () => {
@@ -56,17 +56,8 @@ describe("OpenCode v2 helper isolation", () => {
});
});
describe("OpenCode v2 features", () => {
test("reports the auto-accept toggle from the session value", () => {
expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([
{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: false },
]);
expect(
features({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
}),
).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]);
describe("OpenCode v2 controls", () => {
test("describes the auto-accept toggle over the auto_accept option", () => {
expect(controls).toEqual([{ type: "toggle", key: "auto_accept", label: "Auto-accept" }]);
});
});
@@ -2,8 +2,8 @@ import type { SessionInfo, PermissionRuleset } from "@opencode/client";
import type { V2Api } from "./api.js";
import type {
AgentFeature,
AgentLaunchContext,
AgentOptionControl,
AgentSessionConfig,
} from "../../../agent-sdk-types.js";
@@ -11,16 +11,9 @@ import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../
import { modelRef } from "./mapping.js";
export function features(config: AgentSessionConfig): AgentFeature[] {
return [
{
type: "toggle",
id: "auto_accept",
label: "Auto-accept",
value: config.featureValues?.["auto_accept"] === true,
},
];
}
export const controls: AgentOptionControl[] = [
{ type: "toggle", key: "auto_accept", label: "Auto-accept" },
];
export function permissionRules(config: AgentSessionConfig): PermissionRuleset {
const rules =
buildOpenCodePermissionRules(
@@ -96,7 +96,7 @@ export class SessionPermissions {
};
this.pending.set(request.id, pending);
this.permissionOwners.set(request.id, sessionID);
if (!this.config.toolPolicy && this.config.featureValues?.["auto_accept"] === true)
if (!this.config.toolPolicy && this.config.providerOptions?.["auto_accept"] === true)
await this.respondToPermission(request.id, { behavior: "allow" });
else this.emit({ type: "permission_requested", provider: "opencode", request: pending });
}
@@ -1,3 +1,7 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { applyProviderOptionsPatch } from "../../../option-controls.js";
import { assertChangesAreLive, validateProviderOptions } from "../../../provider-options.js";
import { OpenCodeProviderOptionsSchema } from "../options.js";
import { OpenCodeBackgroundInventory } from "./background-inventory.js";
import { SessionChildren } from "./children.js";
import { SessionTurns } from "./turns.js";
@@ -32,7 +36,7 @@ import { type V2Connection } from "./runtime.js";
import { modelRef, modesFromV2 } from "./mapping.js";
import { V2_CAPABILITIES } from "./capabilities.js";
import { features } from "./configuration.js";
import { controls } from "./configuration.js";
import { commands } from "./commands.js";
import { messages } from "./history.js";
import { SessionPermissions } from "./permissions.js";
@@ -134,8 +138,8 @@ export class OpenCodeV2Session implements AgentSession {
get id() {
return this.info.id;
}
get features() {
return features(this.config);
get controls() {
return controls;
}
private get client() {
return this.connection.client;
@@ -382,14 +386,33 @@ export class OpenCodeV2Session implements AgentSession {
this.info = await this.client.session.get({ sessionID: this.id });
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
}
async setFeature(id: string, value: unknown) {
if (id !== "auto_accept" || typeof value !== "boolean")
throw new Error("Unknown OpenCode feature");
this.config.featureValues = { ...this.config.featureValues, [id]: value };
if (value && !this.config.toolPolicy)
for (const request of this.permissions.list())
if (request.kind !== "question")
await this.respondToPermission(request.id, { behavior: "allow" });
/** The agent manager sends one apply at a time, so a failed one restores exactly what it changed. */
async applyOptions(options: ProviderOptions): Promise<void> {
assertChangesAreLive({
provider: "opencode",
patch: options,
current: this.config.providerOptions,
live: ["auto_accept"],
});
const next = validateProviderOptions(
"opencode",
OpenCodeProviderOptionsSchema,
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
);
if (!("auto_accept" in options)) return;
const value = next?.auto_accept === true;
const previous = this.config.providerOptions;
this.config.providerOptions = { ...previous, auto_accept: value };
try {
if (value && !this.config.toolPolicy)
for (const request of this.permissions.list())
if (request.kind !== "question")
await this.respondToPermission(request.id, { behavior: "allow" });
} catch (error) {
// A rejected apply stores nothing, so the running session keeps asking as it did.
this.config.providerOptions = previous;
throw error;
}
}
async listCommands() {
await this.reconnectIfExited();
@@ -3078,13 +3078,13 @@ describe("PiRpcAgentClient", () => {
},
);
test("lists no draft features without starting a Pi session", async () => {
test("lists no draft controls without starting a Pi session", async () => {
const pi = new FakePi();
const client = createClient(pi);
await expect(
client.listFeatures(createConfig({ model: "openrouter/test/model" })),
).resolves.toEqual([]);
client.listControls(createConfig({ model: "openrouter/test/model" })),
).resolves.toEqual({ controls: [], options: {} });
expect(pi.recordedLaunches).toHaveLength(0);
});
@@ -10,7 +10,7 @@ import { z } from "zod";
import {
type AgentCapabilityFlags,
type AgentClient,
type AgentFeature,
type AgentDraftControls,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -2833,8 +2833,8 @@ export class PiRpcAgentClient implements AgentClient {
}
}
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
return [];
async listControls(config: AgentSessionConfig): Promise<AgentDraftControls> {
return { controls: [], options: config.providerOptions ?? {} };
}
async listImportableSessions(
@@ -0,0 +1,60 @@
// A stdio ACP agent for tests. argv[2] is a JSON scenario:
// options: the session's config options
// answers: { [configId]: value } the agent ends up with instead of the requested value
// onWrite: "notify-first" sends config_option_update before answering the write;
// "notify-then-reject" does the same and then fails the write
// moves: { [configId]: { [id]: value } } writes that also move another select, announced
// by a config_option_update after the write is answered
// adds: { [configId]: option[] } writes that make more selects appear, announced the same way
import { Readable, Writable } from "node:stream";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
const scenario = JSON.parse(process.argv[2] ?? "{}");
let options = scenario.options ?? [];
function set(id, value) {
options = options.map((option) =>
option.id === id ? { ...option, currentValue: value } : option,
);
}
const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
export const connection = new AgentSideConnection(
(agentConnection) => ({
async initialize() {
return { protocolVersion: 1, agentCapabilities: {} };
},
async newSession() {
return { sessionId: "session-1", modes: null, models: null, configOptions: options };
},
async setSessionConfigOption({ sessionId, configId, value }) {
set(configId, scenario.answers?.[configId] ?? value);
const announce = () =>
agentConnection.sessionUpdate({
sessionId,
update: { sessionUpdate: "config_option_update", configOptions: options },
});
if (scenario.onWrite === "notify-first" || scenario.onWrite === "notify-then-reject") {
await announce();
if (scenario.onWrite === "notify-then-reject") throw new Error("write refused");
return { configOptions: options };
}
const moves = scenario.moves?.[configId];
const adds = scenario.adds?.[configId];
if (moves || adds) {
for (const [id, moved] of Object.entries(moves ?? {})) set(id, moved);
options = [...options, ...(adds ?? [])];
setTimeout(() => void announce(), 20);
}
return { configOptions: options };
},
async authenticate() {
return {};
},
async prompt() {
return { stopReason: "end_turn" };
},
async cancel() {},
}),
stream,
);
@@ -18,6 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-
import type { AgentManager } from "../agent-manager.js";
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js";
import { featuresFromControls } from "@getpaseo/protocol/option-controls";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -951,9 +952,6 @@ function createToolCatalog(
...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions
? { providerOptions: callerAgent.config.providerOptions }
: {}),
...(callerAgent.config.featureValues
? { featureValues: callerAgent.config.featureValues }
: {}),
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
};
@@ -1657,7 +1655,7 @@ function createToolCatalog(
cwd: resolvedArgs.cwd,
workspaceId: resolvedArgs.workspaceId,
thinking: parsedArgs.settings?.thinkingOptionId,
features: parsedArgs.settings?.features,
providerOptions: parsedArgs.settings?.features,
labels: {
...parsedArgs.labels,
...(callerAgentId && !resolvedArgs.detached && options.serverId
@@ -2137,6 +2135,7 @@ function createToolCatalog(
const structuredSnapshot = buildStoredAgentPayload(
record,
new Set(providerSnapshotManager.listRegisteredProviderIds()),
(provider, own) => agentManager.effectiveOptionsFor(provider, own),
);
return {
content: [],
@@ -2192,6 +2191,7 @@ function createToolCatalog(
buildStoredAgentPayload(
record,
new Set(providerSnapshotManager.listRegisteredProviderIds()),
(provider, own) => agentManager.effectiveOptionsFor(provider, own),
),
isProviderVisible: () => true,
isStoredProviderAvailable: (record) =>
@@ -2314,9 +2314,7 @@ function createToolCatalog(
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
}
if (settings?.features) {
for (const [featureId, value] of Object.entries(settings.features)) {
await agentManager.setAgentFeature(agentId, featureId, value);
}
await agentManager.setAgentOptions(agentId, settings.features);
}
await updateAgentCommand({ agentManager }, { agentId, name, labels });
@@ -3094,8 +3092,8 @@ function createToolCatalog(
"List agent profiles: named provider/model/mode bundles a human configured for specific " +
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " +
"list if none are configured.",
"`options` into create_agent as `settings.features` (there is no `profile` parameter). " +
"Returns an empty list if none are configured.",
inputSchema: {},
outputSchema: {
profiles: z.array(AgentProfileSchema),
@@ -3148,13 +3146,13 @@ function createToolCatalog(
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
}
const selectedModel = settings?.model ?? resolvedProviderModel.model;
const features = await agentManager.listDraftFeatures({
const { controls, options: effectiveOptions } = await agentManager.listDraftControls({
provider: providerId,
cwd: resolvedCwd,
...(settings?.modeId ? { modeId: settings.modeId } : {}),
...(selectedModel ? { model: selectedModel } : {}),
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
...(settings?.features ? { featureValues: settings.features } : {}),
...(settings?.features ? { providerOptions: settings.features } : {}),
});
return {
content: [],
@@ -3166,7 +3164,7 @@ function createToolCatalog(
status: summary.status,
modes: summary.modes,
selectedModel: selectedModel ?? null,
features,
features: featuresFromControls({ controls, options: effectiveOptions }),
}),
};
},
@@ -122,6 +122,7 @@ const INBOUND_PERMISSION = {
list_available_providers_request: "daemon.read",
list_commands_request: "daemon.read",
list_provider_features_request: "daemon.read",
"provider.controls.list.request": "daemon.read",
list_provider_models_request: "daemon.read",
list_provider_modes_request: "daemon.read",
list_terminals_request: "workspace.read",
@@ -220,6 +221,8 @@ const INBOUND_PERMISSION = {
"workspace.label.delete.request": "workspace.manage",
"workspace.label.list.request": "workspace.read",
"workspace.label.update.request": "workspace.manage",
"agent.metadata.update.request": "workspace.write",
"workspace.metadata.update.request": "workspace.manage",
"workspace.pin.set.request": "workspace.manage",
"workspace.base_branch.set.request": "workspace.write",
"workspace.recovery.inspect.request": ["workspace.read", "hub.execute"],
@@ -368,6 +371,7 @@ const OUTBOUND_PERMISSION = {
list_available_providers_response: "daemon.read",
list_commands_response: "daemon.read",
list_provider_features_response: "daemon.read",
"provider.controls.list.response": "daemon.read",
list_provider_models_response: "daemon.read",
list_provider_modes_response: "daemon.read",
list_terminals_response: "workspace.read",
@@ -468,6 +472,8 @@ const OUTBOUND_PERMISSION = {
"workspace.label.list.response": "workspace.read",
"workspace.label.update": "workspace.read",
"workspace.label.update.response": "workspace.manage",
"agent.metadata.update.response": "workspace.write",
"workspace.metadata.update.response": "workspace.manage",
"workspace.pin.set.response": "workspace.manage",
"workspace.base_branch.set.response": "workspace.write",
"workspace.recovery.inspect.response": ["workspace.read", "hub.execute"],
@@ -1109,6 +1109,7 @@ test("disabled recovery closes stale active records without resuming sessions",
lastStatus: status as StoredAgentRecord["lastStatus"],
title: `Keep ${status}`,
labels: { "paseo.parent-agent-id": "parent", custom: "keep" },
metadata: {},
persistence: { provider: "codex", sessionId: `session-${index}` },
config: { modeId: "full-access" },
requiresAttention: true,
@@ -181,6 +181,56 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
}
});
test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => {
const clients = createTestAgentClients();
const codex = clients.codex as AgentClient;
const applied: unknown[] = [];
// Records what reaches the provider session; behavior is the fake client's.
const recordingCodex: AgentClient = Object.create(codex, {
createSession: {
value: async (...args: Parameters<AgentClient["createSession"]>) => {
const session = await codex.createSession(...args);
const apply = session.applyOptions?.bind(session);
session.applyOptions = async (options) => {
applied.push(options);
await apply?.(options);
};
return session;
},
},
});
const daemon = await createTestPaseoDaemon({
agentClients: { ...clients, codex: recordingCodex },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Options agent",
modeId: "full-access",
model: "gpt-5.4-mini",
});
expect(agent.options?.sandbox_mode).toBeUndefined();
await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } });
expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]);
const next = await client.fetchAgent(agent.id);
expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" });
expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode");
} finally {
await client.close();
await daemon.close();
}
});
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
import {
DaemonConfigStore,
applyMutableProviderConfigToOverrides,
toWireDaemonConfig,
} from "./daemon-config-store.js";
import { loadConfig } from "./config.js";
import { loadPersistedConfig } from "./persisted-config.js";
import { readSpeechSettings } from "./speech/speech-settings.js";
@@ -196,13 +200,326 @@ describe("DaemonConfigStore", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
options: { webSearch: true },
notes: "Use for components, layout and design tokens.",
},
]);
expect(store.get().agentProfiles).toHaveLength(1);
});
test("a profile written with featureValues is stored as options, and the config API answers with both", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
// The unchanged app sends back the `options` it read plus the `featureValues` it edited.
await store.patch({
agentProfiles: [
{
id: "p",
name: "P",
provider: "claude",
options: { fast_mode: false },
featureValues: { fast_mode: true },
},
],
});
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
{
id: "p",
name: "P",
provider: "claude",
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
]);
});
function storeWithProfile(): { store: DaemonConfigStore; paseoHome: string } {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: false },
},
],
});
return { store, paseoHome };
}
test("a profile written with empty options, as the form sends it once the last one is removed, keeps none", async () => {
const { store, paseoHome } = storeWithProfile();
await store.patch({
agentProfiles: [{ id: "p", name: "P", provider: "codex", options: {} }],
});
expect(store.get().agentProfiles).toEqual([{ id: "p", name: "P", provider: "codex" }]);
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{ id: "p", name: "P", provider: "codex" },
]);
});
describe("a profile edited by the unchanged app", () => {
test("keeps the options its form never shows when it changes a control", async () => {
const { store } = storeWithProfile();
await store.patch({
agentProfiles: [
{ id: "p", name: "Renamed", provider: "codex", featureValues: { plan_mode: true } },
],
});
expect(store.get().agentProfiles).toEqual([
{
id: "p",
name: "Renamed",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: true },
},
]);
});
test("keeps every option when it sends no featureValues at all", async () => {
const { store } = storeWithProfile();
await store.patch({ agentProfiles: [{ id: "p", name: "Renamed", provider: "codex" }] });
expect(store.get().agentProfiles).toEqual([
{
id: "p",
name: "Renamed",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: false },
},
]);
});
test("starts from no options when it moves the profile to another provider", async () => {
const { store } = storeWithProfile();
await store.patch({
agentProfiles: [
{ id: "p", name: "P", provider: "claude", featureValues: { fast_mode: true } },
],
});
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
});
test("drops the old provider's options when it moves the profile with no featureValues", async () => {
const { store } = storeWithProfile();
await store.patch({ agentProfiles: [{ id: "p", name: "P", provider: "claude" }] });
expect(store.get().agentProfiles).toEqual([{ id: "p", name: "P", provider: "claude" }]);
});
test("folds featureValues over the options a write carries, not over the stored ones", async () => {
const { store } = storeWithProfile();
await store.patch({
agentProfiles: [
{
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "workspace-write" },
featureValues: { plan_mode: true },
},
],
});
expect(store.get().agentProfiles).toEqual([
{
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "workspace-write", plan_mode: true },
},
]);
});
test("converts a Codex profile's legacy fast_mode to service_tier", async () => {
const { store } = storeWithProfile();
await store.patch({
agentProfiles: [
{ id: "p", name: "P", provider: "codex", featureValues: { fast_mode: true } },
],
});
expect(store.get().agentProfiles).toEqual([
{
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" },
},
]);
});
test("removes an option whose featureValues entry is null", async () => {
const { store } = storeWithProfile();
await store.patch({
agentProfiles: [
{ id: "p", name: "P", provider: "codex", featureValues: { plan_mode: null } },
],
});
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "codex", options: { sandbox_mode: "read-only" } },
]);
});
});
test("a profile stored with featureValues loads as options", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{
id: "legacy",
name: "Legacy",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
});
expect(store.get().agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
});
describe("a profile holding Codex's legacy fast_mode", () => {
const legacyProfiles = ["codex", "my-codex"].map((provider) => ({
id: provider,
name: provider,
provider,
options: { fast_mode: true },
}));
function storeWithLegacyProfiles(agentProfiles: unknown[] = legacyProfiles): DaemonConfigStore {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
return new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: { "my-codex": { extends: "codex" } },
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles,
} as MutableDaemonConfig);
}
test("reads the same for codex and a provider that extends it", () => {
const store = storeWithLegacyProfiles();
expect(store.get().agentProfiles).toEqual([
{ id: "codex", name: "codex", provider: "codex", options: { service_tier: "fast" } },
{
id: "my-codex",
name: "my-codex",
provider: "my-codex",
options: { service_tier: "fast" },
},
]);
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
expect.objectContaining({ featureValues: { service_tier: "fast" } }),
expect.objectContaining({ featureValues: { service_tier: "fast" } }),
]);
});
test.each(["codex", "my-codex"])("resetting %s's speed removes what was shown", async (id) => {
const store = storeWithLegacyProfiles();
await store.patch({
agentProfiles: [{ id, name: id, provider: id, featureValues: { service_tier: null } }],
});
expect(store.get().agentProfiles).toEqual([{ id, name: id, provider: id }]);
});
test("a provider edit in the same write does not hide which adapter a profile runs on", async () => {
const store = storeWithLegacyProfiles();
await store.patch({
providers: { "my-codex": { label: "Renamed" } },
agentProfiles: [
{ id: "p", name: "P", provider: "my-codex", featureValues: { fast_mode: false } },
],
});
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "my-codex", options: { service_tier: "default" } },
]);
});
test("a malformed value rejects the write and leaves config reads working", async () => {
const store = storeWithLegacyProfiles();
await expect(
store.patch({
agentProfiles: [
{ id: "p", name: "P", provider: "my-codex", options: { fast_mode: "false" } },
],
}),
).rejects.toThrow(/fast_mode must be true, false or null/);
expect(toWireDaemonConfig(store.get()).agentProfiles).toHaveLength(2);
});
test("a malformed value in a stored profile does not stop the config from loading", () => {
const store = storeWithLegacyProfiles([
{ id: "p", name: "P", provider: "codex", options: { fast_mode: "false" } },
]);
expect(store.get().agentProfiles?.[0]?.options).toEqual({ fast_mode: "false" });
});
});
test("patch replaces the whole agent profile list rather than merging entries", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -1274,6 +1591,35 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().agentProfiles).toBeUndefined();
});
test("reload folds a profile file written with featureValues into options, and saving it persists options", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
daemon: {
...persisted.daemon,
agentProfiles: [
{
id: "legacy",
name: "Legacy",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
},
});
await store.reload();
expect(store.get().agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
// The app reads the profile and writes the list back on its next edit.
await store.patch({ agentProfiles: store.get().agentProfiles });
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
});
test("reports a launch-controlled edit without changing live state", async () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
@@ -5,6 +5,13 @@ import {
type PersistedConfig,
} from "./persisted-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import {
convertLegacyOptions,
foldProfileFeatureValues,
resolveAdapter,
withLegacyProfileFeatureValues,
} from "./agent/option-controls.js";
import type { AgentProfile } from "@getpaseo/protocol/agent-profile";
import {
MutableDaemonConfigSchema,
MutableDaemonConfigPatchSchema,
@@ -168,6 +175,54 @@ function omitProvidersFromOverrides(
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
}
/**
* Profiles are held and persisted with `options` only, in the current keys: a profile is old
* data entering, so it is converted for the adapter its provider runs on, which `config.json`'s
* `providers` section names through `extends`.
*/
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(input: {
config: T;
providers: Record<string, ProviderOverride> | undefined;
source: "stored" | "client write";
held?: AgentProfile[];
}): T {
const { config, providers, source, held = [] } = input;
if (!config.agentProfiles) return config;
const convert = (provider: string, options: Record<string, unknown> | undefined) =>
convertLegacyOptions({
provider: resolveAdapter({ provider, extendsOf: (id) => providers?.[id]?.extends }),
options,
});
const identity = (_provider: string, options: Record<string, unknown> | undefined) => options;
const foldWith = (profile: AgentProfile, convertWith: typeof convert) =>
foldProfileFeatureValues({
profile,
stored: held.find((candidate) => candidate.id === profile.id),
source,
convert: convertWith,
});
return {
...config,
agentProfiles: config.agentProfiles.map((profile) => {
// A malformed value rejects a client write before anything is committed.
if (source === "client write") return foldWith(profile, convert);
try {
return foldWith(profile, convert);
} catch {
// A hand-edited `config.json` value that cannot convert must not stop the daemon from
// starting; it keeps its keys and is rejected when an agent is created from it.
return foldWith(profile, identity);
}
}),
};
}
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
if (!config.agentProfiles) return config;
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path
.split(".")
@@ -380,7 +435,7 @@ export class DaemonConfigStore {
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
this.lastKnownPersisted = this.startupPersisted;
this.current = MutableDaemonConfigSchema.parse({
...initial,
...foldConfigProfiles({ config: initial, providers: initial.providers, source: "stored" }),
relay: initial.relay ?? { enabled: true },
...this.readSpeech(this.startupPersisted),
});
@@ -410,6 +465,18 @@ export class DaemonConfigStore {
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
}
private foldPatchProfiles<
T extends { agentProfiles?: AgentProfile[]; providers?: Record<string, ProviderOverride> },
>(submittedPatch: T): T {
return foldConfigProfiles({
config: submittedPatch,
// The provider graph this request will commit, so `extends` survives a partial edit.
providers: deepMerge(this.current.providers ?? {}, submittedPatch.providers ?? {}),
source: "client write",
held: this.current.agentProfiles,
});
}
private async applySupportedPatch(
parsedPatch: SupportedMutableConfigPatch,
): Promise<MutableDaemonConfig> {
@@ -418,7 +485,8 @@ export class DaemonConfigStore {
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
);
}
const { removeProviders = [], ...patch } = parsedPatch;
const { removeProviders = [], ...submittedPatch } = parsedPatch;
const patch = this.foldPatchProfiles(submittedPatch);
const { features: speechPatch, ...configPatch } = patch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);
@@ -437,7 +505,16 @@ export class DaemonConfigStore {
const configChanged = !isEqualValue(this.current, next);
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
// Profiles held folded in memory may still be on disk with `featureValues`; saving them writes `options`.
const profilesStaleOnDisk =
patch.agentProfiles !== undefined &&
!isEqualValue(patch.agentProfiles, this.lastKnownPersisted.daemon?.agentProfiles);
if (
!configChanged &&
removedProviders.length === 0 &&
speechPatch === undefined &&
!profilesStaleOnDisk
) {
return this.current;
}
@@ -495,7 +572,11 @@ export class DaemonConfigStore {
// Plugin source changes require the plugin lifecycle operation or a daemon
// restart. The global switch is independently reloadable.
const desired = MutableDaemonConfigSchema.parse({
...resolved.mutable,
...foldConfigProfiles({
config: resolved.mutable,
providers: resolved.mutable.providers,
source: "stored",
}),
plugins: this.current.plugins,
...this.readSpeech(persisted),
});
@@ -2,7 +2,6 @@ import { describe, test, expect, beforeEach, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "fs";
import { tmpdir } from "os";
import path from "path";
import { execFileSync } from "node:child_process";
import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js";
import type { AgentSnapshotPayload, SessionOutboundMessage } from "../messages.js";
@@ -39,13 +38,13 @@ function waitForAgentUpdate(
});
}
// The daemon only switches to a model the provider lists.
function pickModelSwitchPair(provider: string, models: Array<{ id: string }>): [string, string] {
const ids = Array.from(new Set(models.map((m) => m.id))).filter(Boolean);
const first = ids[0];
if (!first) {
throw new Error(`No models returned for provider ${provider}`);
const [first, second] = Array.from(new Set(models.map((m) => m.id))).filter(Boolean);
if (!first || !second) {
throw new Error(`Provider ${provider} lists fewer than two models`);
}
return [first, ids[1] ?? `${first}-switch-target`];
return [first, second];
}
function pickThinkingSwitchOption(
@@ -55,13 +54,10 @@ function pickThinkingSwitchOption(
defaultThinkingOptionId?: string;
}>,
): { model: (typeof models)[number]; thinkingOptionId: string } {
// The daemon only switches to a thinking option the model lists.
const modelWithOptions = models.find((m) => (m.thinkingOptions?.length ?? 0) > 0);
if (!modelWithOptions) {
const first = models[0];
if (!first) {
throw new Error(`No ${provider} models returned`);
}
return { model: first, thinkingOptionId: "test-thinking-option" };
throw new Error(`No ${provider} model lists thinking options`);
}
const defaultThinkingId = modelWithOptions.defaultThinkingOptionId ?? "default";
@@ -74,18 +70,6 @@ function pickThinkingSwitchOption(
return { model: modelWithOptions, thinkingOptionId };
}
function isBinaryInstalled(binary: string): boolean {
try {
const out = execFileSync("which", [binary], { encoding: "utf8" }).trim();
return out.length > 0;
} catch {
return false;
}
}
const hasCodex = isBinaryInstalled("codex");
const hasOpenCode = isBinaryInstalled("opencode");
let ctx: DaemonTestContext;
let messages: SessionOutboundMessage[] = [];
let unsubscribe: (() => void) | null = null;
@@ -105,154 +89,137 @@ afterEach(async () => {
}, 60000);
describe.each(["claude", "codex", "opencode"] as const)("live model switching (%s)", (provider) => {
const shouldRun =
provider === "claude" ||
(provider === "codex" && hasCodex) ||
(provider === "opencode" && hasOpenCode);
test.runIf(shouldRun)(
"updates agent model without restarting",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels(provider);
if (!modelList.models || modelList.models.length === 0) {
throw new Error(`No models returned for provider ${provider}`);
}
const [modelA, modelB] = pickModelSwitchPair(provider, modelList.models);
const agent = await ctx.client.createAgent({
provider,
cwd,
title: `Model Switch (${provider})`,
model: modelA,
});
const startIndex = messages.length;
await ctx.client.setAgentModel(agent.id, modelB);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.model === modelB,
{ timeoutMs: 20000 },
);
expect(updated.model).toBe(modelB);
} finally {
rmSync(cwd, { recursive: true, force: true });
test("updates agent model without restarting", async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels(provider);
if (!modelList.models || modelList.models.length === 0) {
throw new Error(`No models returned for provider ${provider}`);
}
},
180000,
);
const [modelA, modelB] = pickModelSwitchPair(provider, modelList.models);
const agent = await ctx.client.createAgent({
provider,
cwd,
title: `Model Switch (${provider})`,
model: modelA,
});
const startIndex = messages.length;
await ctx.client.setAgentModel(agent.id, modelB);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.model === modelB,
{ timeoutMs: 20000 },
);
expect(updated.model).toBe(modelB);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 180000);
});
test("live thinking switching works for Claude (off -> on)", async () => {
test("live thinking switching works for Claude (default -> non-default)", async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("claude");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Claude models returned");
}
const modelId = modelList.models[0].id;
const { model, thinkingOptionId } = pickThinkingSwitchOption("Claude", modelList.models);
const agent = await ctx.client.createAgent({
provider: "claude",
cwd,
title: "Claude Thinking Switch",
model: modelId,
model: model.id,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, "on");
await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === "on",
(a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId,
{ timeoutMs: 20000 },
);
expect(updated.thinkingOptionId).toBe("on");
expect(updated.thinkingOptionId).toBe(thinkingOptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 120000);
test.runIf(hasCodex)(
"live thinking switching works for Codex (default -> non-default)",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("codex");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Codex models returned");
}
const { model: modelWithOptions, thinkingOptionId } = pickThinkingSwitchOption(
"Codex",
modelList.models,
);
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Codex Thinking Switch",
model: modelWithOptions.id,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId);
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId,
{ timeoutMs: 20000 },
);
expect(updated.thinkingOptionId).toBe(thinkingOptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
test("live thinking switching works for Codex (default -> non-default)", async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("codex");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No Codex models returned");
}
},
120000,
);
test.runIf(hasOpenCode)(
"live thinking switching works for OpenCode",
async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("opencode");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No OpenCode models returned");
}
const { model: modelWithOptions, thinkingOptionId } = pickThinkingSwitchOption(
"Codex",
modelList.models,
);
const { model: modelWithThinkingOptions, thinkingOptionId } = pickThinkingSwitchOption(
"OpenCode",
modelList.models,
);
const agent = await ctx.client.createAgent({
provider: "codex",
cwd,
title: "Codex Thinking Switch",
model: modelWithOptions.id,
});
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode Preferences Switch",
model: modelWithThinkingOptions.id,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId);
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId);
const updatedThinking = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId,
{ timeoutMs: 20000 },
);
expect(updatedThinking.thinkingOptionId).toBe(thinkingOptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
const updated = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId,
{ timeoutMs: 20000 },
);
expect(updated.thinkingOptionId).toBe(thinkingOptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 120000);
test("live thinking switching works for OpenCode", async () => {
const cwd = tmpCwd();
try {
const modelList = await ctx.client.listProviderModels("opencode");
if (!modelList.models || modelList.models.length === 0) {
throw new Error("No OpenCode models returned");
}
},
180000,
);
const { model: modelWithThinkingOptions, thinkingOptionId } = pickThinkingSwitchOption(
"OpenCode",
modelList.models,
);
const agent = await ctx.client.createAgent({
provider: "opencode",
cwd,
title: "OpenCode Preferences Switch",
model: modelWithThinkingOptions.id,
});
const startIndex = messages.length;
await ctx.client.setAgentThinkingOption(agent.id, thinkingOptionId);
const updatedThinking = await waitForAgentUpdate(
messages,
startIndex,
(a) => a.id === agent.id && a.thinkingOptionId === thinkingOptionId,
{ timeoutMs: 20000 },
);
expect(updatedThinking.thinkingOptionId).toBe(thinkingOptionId);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 180000);
@@ -86,7 +86,10 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
cwd: response.payload.agent.cwd,
});
expect(JSON.stringify(response.payload.agent)).not.toContain(bearer);
expect(JSON.stringify(response.payload.agent)).not.toContain("private-build");
// Provider options are not secret and appear in the snapshot's `options`.
expect(response.payload.agent.options).toMatchObject({
sandbox_workspace_write: { writable_roots: ["/var/cache/private-build"] },
});
expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution");
const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update");
@@ -104,7 +107,6 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
cwd: update.payload.agent.cwd,
});
expect(JSON.stringify(update.payload.agent)).not.toContain(bearer);
expect(JSON.stringify(update.payload.agent)).not.toContain("private-build");
expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution");
});
@@ -23,7 +23,6 @@ export interface HubExecutionAgentCreateInput {
model?: string;
modeId?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
env?: Record<string, string>;
@@ -193,7 +192,6 @@ export class DaemonExecutions implements HubExecutionAgents {
cwd: input.cwd,
mode: input.modeId,
thinking: input.thinkingOptionId,
features: input.featureValues,
env: input.env,
...(input.mcpServers || input.providerOptions || input.toolPolicy
? {
@@ -290,7 +288,11 @@ export class DaemonExecutions implements HubExecutionAgents {
agent: live
? serializeAgentSnapshot(live)
: {
...buildStoredAgentPayload(record, this.agentManager.getRegisteredProviderIds()),
...buildStoredAgentPayload(
record,
this.agentManager.getRegisteredProviderIds(),
(provider, own) => this.agentManager.effectiveOptionsFor(provider, own),
),
status: "closed",
},
};
@@ -13,6 +13,7 @@ import {
} from "../agent/provider-options.js";
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
import { foldFeatureValues } from "../agent/option-controls.js";
interface HubExecutionControllerOptions {
agents: HubExecutionAgents;
@@ -167,8 +168,8 @@ export class HubExecutionController {
model: message.model,
modeId: message.modeId,
thinkingOptionId: message.thinkingOptionId,
featureValues: message.featureValues,
providerOptions: message.providerOptions,
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
providerOptions: foldFeatureValues(message).providerOptions,
toolPolicy: message.toolPolicy,
env: message.env,
mcpServers: message.mcpServers,
@@ -440,7 +440,7 @@ type AcceptedCreate = Omit<
const providerCatalog = {
async resolveCreateConfig(input) {
return { modeId: input.requestedMode, featureValues: input.featureValues };
return { modeId: input.requestedMode, providerOptions: input.providerOptions };
},
} satisfies CreateAgentCommandDependencies["providerSnapshotManager"];
@@ -985,6 +985,45 @@ test("config changes require their own event demand and cannot escape to an idle
}
});
test("a config change push carries profiles with the legacy featureValues the unchanged app reads", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
let observer: SubscriptionPeer | undefined;
try {
await admin.connect();
await admin.patchDaemonConfig({
agentProfiles: [
{ id: "p", name: "P", provider: "codex", options: {}, featureValues: { plan_mode: true } },
],
});
observer = await SubscriptionPeer.connect(daemon.port, "config-push-session");
await observer.request({
type: "session.events.set_subscription.request",
requestId: "config-feed",
events: ["status.daemon_config_changed"],
});
await admin.patchDaemonConfig({ autoArchiveAfterMerge: true });
const peer = observer;
await expect
.poll(() =>
peer.frames.flatMap((frame) =>
frame.type === "session" &&
frame.message.type === "status" &&
frame.message.payload.status === "daemon_config_changed"
? [frame.message.payload.config?.agentProfiles?.[0]?.featureValues]
: [],
),
)
.toEqual([{ plan_mode: true }]);
} finally {
observer?.close();
await admin.close();
await daemon.close();
}
});
test("status-shaped operation replies use actual source request provenance", async () => {
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
const peers: SubscriptionPeer[] = [];
@@ -675,6 +675,40 @@ test("renames the branch even when the app supplies a random placeholder slug",
expect(branchAfter).toBe("renamed-from-prompt");
});
test("keeps a branch the caller named instead of renaming it from the first agent context", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
const created = await createPaseoWorktree(
{
cwd: repoDir,
worktreeSlug: "dazzling-yak",
branchName: "pas-101-slow-sidebar",
firstAgentContext: { prompt: "Investigate the failing login flow" },
paseoHome: path.join(tempDir, ".paseo"),
},
createDeps(),
);
expect(created.worktree.branchName).toBe("pas-101-slow-sidebar");
await expect(
attemptFirstAgentBranchAutoName({
cwd: created.worktree.worktreePath,
firstAgentContext: { prompt: "Investigate the failing login flow" },
generateBranchNameFromContext: async () => "must-not-rename",
}),
).resolves.toEqual({ attempted: false, renamed: false, branchName: null });
expect(
execFileSync("git", ["branch", "--show-current"], {
cwd: created.worktree.worktreePath,
stdio: "pipe",
})
.toString()
.trim(),
).toBe("pas-101-slow-sidebar");
});
test("renames the branch from a github_pr attachment when no prompt is supplied", async () => {
const { repoDir, tempDir } = createGitRepo();
cleanupPaths.push(tempDir);
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginLifecycle } from "./plugins/lifecycle/index.js";
import { stat } from "node:fs/promises";
import { resolve } from "node:path";
@@ -34,6 +35,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
workspaceId?: string;
projectId?: string;
title?: string;
metadata?: Record<string, JsonValue>;
background?: boolean;
callerWorkspaceId?: string;
}
@@ -88,7 +90,7 @@ async function createPaseoWorktreeWithPriority(
},
});
}
await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
await maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree, input });
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
targetWorktreePath: createdWorktree.worktree.worktreePath,
@@ -113,6 +115,7 @@ async function createPaseoWorktreeWithPriority(
branch: createdWorktree.worktree.branchName || null,
baseBranch: createdWorktree.worktree.comparisonBaseRef,
title: input.title?.trim() || resolveFirstAgentPromptTitle(input.firstAgentContext),
metadata: input.metadata,
expectsInitialAgent: Boolean(input.firstAgentContext),
background: input.background,
callerWorkspaceId: input.callerWorkspaceId,
@@ -277,11 +280,16 @@ async function findAvailableBranchName(options: {
async function maybeMarkFirstAgentBranchAutoNameEligible(options: {
createdWorktree: Awaited<ReturnType<typeof createWorktreeCore>>;
input: Pick<CreatePaseoWorktreeInput, "branchName">;
}): Promise<void> {
const { createdWorktree } = options;
const { createdWorktree, input } = options;
if (!createdWorktree.created || createdWorktree.intent.kind !== "branch-off") {
return;
}
// A branch the caller named is their choice; only a placeholder slug is replaced.
if (input.branchName !== undefined) {
return;
}
await writePaseoWorktreeFirstAgentBranchAutoNameMetadata(createdWorktree.worktree.worktreePath, {
placeholderBranchName: createdWorktree.worktree.branchName,
@@ -73,7 +73,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,
providerOptions: record.config?.providerOptions ?? undefined,
toolPolicy: record.config?.toolPolicy ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
@@ -95,7 +94,6 @@ export function buildSessionConfig(
modeId: overrides.modeId,
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
featureValues: overrides.featureValues,
providerOptions: overrides.providerOptions,
toolPolicy: overrides.toolPolicy,
systemPrompt: overrides.systemPrompt,
@@ -139,6 +137,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
updatedAt: Date;
lastUserMessageAt: Date | null;
labels?: Record<string, string>;
metadata?: StoredAgentRecord["metadata"];
workspaceId?: string;
owner?: StoredAgentRecord["owner"];
background?: boolean;
@@ -148,6 +147,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
updatedAt: new Date(resolveStoredAgentUpdatedAt(record)),
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
labels: record.labels,
metadata: record.metadata,
workspaceId: record.workspaceId,
owner: record.owner,
background: record.background,
@@ -8,6 +8,7 @@ import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
import { resolveDaemonVersion } from "../daemon-version.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { createPaseoApi } from "@getpaseo/client";
test("a plugin transforms workspace creation once across receipt replays and observes its committed lifecycle", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-lifecycle-"));
@@ -240,6 +241,193 @@ export default function contribute(server) {
}
}, 60_000);
test("metadata set at creation reaches the hooks, a before hook can add to it, and snapshots carry it", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-hooks-"));
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
try {
await writeFile(
path.join(directory, "paseo-plugin.json"),
JSON.stringify({
id: "metadata-hooks",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
await writeFile(
path.join(directory, "index.server.ts"),
`
export default function contribute(server) {
const log = (hook, metadata) => console.log(JSON.stringify({ hook, metadata }));
server.addHook("workspace.create.before", async ({ request }) => {
log("workspace.create.before", request.metadata);
return request;
});
server.addHook("workspace.create.after", async ({ workspace }) => {
log("workspace.create.after", workspace.metadata);
});
server.addHook("agent.create.before", async ({ request }) => {
log("agent.create.before", request.metadata);
return { ...request, metadata: { ...request.metadata, "hook.added": true } };
});
server.addHook("agent.open.before", async ({ request }) => {
log("agent.open.before", request.metadata);
return request;
});
server.addHook("agent.create.after", async ({ agent }) => {
log("agent.create.after", agent.metadata);
});
return () => {};
}
`,
);
await client.connect();
await client.fetchAgents({ subscribe: {} });
await client.patchDaemonConfig({ pluginsEnabled: true });
await client.installDirectoryPlugin(directory);
const workspace = await client.createWorkspace({
source: { kind: "directory", path: directory },
metadata: { "linear.issue": { id: "ENG-123" } },
});
expect(workspace.workspace?.metadata).toEqual({ "linear.issue": { id: "ENG-123" } });
const agent = await client.createAgent({
provider: "claude",
cwd: directory,
workspaceId: workspace.workspace!.id,
metadata: { "linear.issue": { id: "ENG-123" } },
});
expect(agent.metadata).toEqual({ "linear.issue": { id: "ENG-123" }, "hook.added": true });
await expect
.poll(async () => {
const logs = await client.getPluginLogs("metadata-hooks");
return logs
.filter((entry) => entry.message.startsWith('{"hook":'))
.map((entry) => JSON.parse(entry.message));
})
.toMatchObject([
{ hook: "workspace.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
{ hook: "workspace.create.after", metadata: { "linear.issue": { id: "ENG-123" } } },
{ hook: "agent.create.before", metadata: { "linear.issue": { id: "ENG-123" } } },
{
hook: "agent.open.before",
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
},
{
hook: "agent.create.after",
metadata: { "linear.issue": { id: "ENG-123" }, "hook.added": true },
},
]);
} finally {
await client.close();
await daemon.close();
await rm(directory, { recursive: true, force: true });
}
}, 60_000);
test("metadata set at creation and through setMetadata survives a daemon restart", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-metadata-restart-"));
const directory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-cwd-"));
const pluginDirectory = await mkdtemp(path.join(tmpdir(), "paseo-metadata-plugin-"));
try {
await writeFile(
path.join(pluginDirectory, "paseo-plugin.json"),
JSON.stringify({
id: "metadata-restart",
requirements: { paseo: `>=${resolveDaemonVersion(import.meta.url)}` },
}),
);
// Returns an explicit object without `metadata`, as a plugin written before metadata existed.
await writeFile(
path.join(pluginDirectory, "index.server.ts"),
`
export default function contribute(server) {
server.addHook("agent.open.before", async ({ request }) => {
console.log(JSON.stringify({ hook: "agent.open.before", metadata: request.metadata }));
const { metadata, ...old } = request;
return { ...old, env: { ...old.env, HOOK_OPEN: "opened" } };
});
return () => {};
}
`,
);
const first = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
const firstClient = new DaemonClient({ url: `ws://127.0.0.1:${first.port}/ws` });
const firstApi = createPaseoApi(firstClient);
let agentId: string;
let workspaceId: string;
try {
await firstClient.connect();
await firstClient.fetchAgents({ subscribe: {} });
await firstClient.patchDaemonConfig({ pluginsEnabled: true });
await firstClient.installDirectoryPlugin(pluginDirectory);
const workspace = await firstClient.createWorkspace({
source: { kind: "directory", path: directory },
metadata: { "a.workspace": 1, "b.workspace": "to remove" },
});
workspaceId = workspace.workspace!.id;
const agent = await firstClient.createAgent({
provider: "claude",
cwd: directory,
workspaceId,
metadata: { "a.agent": { nested: [1, 2] }, "b.agent": "to remove" },
});
agentId = agent.id;
const agentHandle = firstApi.agents.ref(agentId);
await agentHandle.setMetadata({ "b.agent": null, "c.agent": true });
await firstApi.workspaces.ref(workspaceId).setMetadata({
"b.workspace": null,
"c.workspace": [],
});
expect((await firstClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
"a.agent": { nested: [1, 2] },
"c.agent": true,
});
} finally {
await firstApi.dispose();
await firstClient.close();
await first.close();
}
const second = await createTestPaseoDaemon({ paseoHomeRoot: root, cleanup: false });
const secondClient = new DaemonClient({ url: `ws://127.0.0.1:${second.port}/ws` });
try {
await secondClient.connect();
const agent = await secondClient.fetchAgent({ agentId });
expect(agent?.agent.metadata).toEqual({ "a.agent": { nested: [1, 2] }, "c.agent": true });
const workspaces = await secondClient.fetchWorkspaces({});
expect(workspaces.entries.find((entry) => entry.id === workspaceId)?.metadata).toEqual({
"a.workspace": 1,
"c.workspace": [],
});
await secondClient.fetchAgents({ subscribe: {} });
await secondClient.patchDaemonConfig({ pluginsEnabled: true });
await secondClient.installDirectoryPlugin(pluginDirectory);
await secondClient.refreshAgent(agentId);
await expect
.poll(async () => {
const logs = await secondClient.getPluginLogs("metadata-restart");
return logs
.filter((entry) => entry.message.startsWith('{"hook":'))
.map((entry) => JSON.parse(entry.message));
})
.toContainEqual({
hook: "agent.open.before",
metadata: { "a.agent": { nested: [1, 2] }, "c.agent": true },
});
expect((await secondClient.fetchAgent({ agentId }))?.agent.metadata).toEqual({
"a.agent": { nested: [1, 2] },
"c.agent": true,
});
} finally {
await secondClient.close();
await second.close();
}
} finally {
await rm(root, { recursive: true, force: true });
await rm(directory, { recursive: true, force: true });
await rm(pluginDirectory, { recursive: true, force: true });
}
}, 60_000);
test("agent creation hooks change the provider and environment before the session opens", async () => {
const directory = await mkdtemp(path.join(tmpdir(), "paseo-agent-hooks-"));
const pluginDirectory = await createProviderPlugin(directory, {
@@ -92,6 +92,7 @@ test("session-open hooks reject changes to session identity instead of silently
env: {},
title: null,
labels: {},
metadata: {},
tools: [],
mcpServers: {},
},
@@ -100,6 +101,105 @@ test("session-open hooks reject changes to session identity instead of silently
).rejects.toThrow("agent.open.before hooks can only change env");
});
test("a create hook that omits metadata passes on what an earlier hook added", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.create.before", async ({ request }) => ({
...request,
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
}));
hooks.addHook("agent.create.before", async ({ request }) => ({
config: request.config,
env: request.env,
}));
const seen: unknown[] = [];
hooks.addHook("agent.create.before", async ({ request }) => {
seen.push(request.metadata);
});
const result = await hooks.invoke(
"operation",
"agent.create.before",
{ config: { provider: "codex", cwd: "/project" }, env: {}, metadata: { original: true } },
paseo,
);
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
});
test("a workspace create hook that omits metadata passes on what an earlier hook added", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("workspace.create.before", async ({ request }) => ({
...request,
metadata: { ...request.metadata, "new.plugin": { ticket: 42 } },
}));
hooks.addHook("workspace.create.before", async ({ request }) => ({ source: request.source }));
const seen: unknown[] = [];
hooks.addHook("workspace.create.before", async ({ request }) => {
seen.push(request.metadata);
});
const result = await hooks.invoke(
"operation",
"workspace.create.before",
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
paseo,
);
expect(result).toMatchObject({ metadata: { original: true, "new.plugin": { ticket: 42 } } });
expect(seen).toEqual([{ original: true, "new.plugin": { ticket: 42 } }]);
const replacing = new PluginHookHandlers(() => {});
replacing.addHook("workspace.create.before", async ({ request }) => ({
...request,
metadata: {},
}));
await expect(
replacing.invoke(
"operation",
"workspace.create.before",
{ source: { kind: "directory", path: "/project" }, metadata: { original: true } },
paseo,
),
).resolves.toMatchObject({ metadata: {} });
});
test("a session-open hook that returns its own object without metadata leaves metadata unchanged", async () => {
const hooks = new PluginHookHandlers(() => {});
hooks.addHook("agent.open.before", async ({ request }) => ({
agentId: request.agentId,
workspaceId: request.workspaceId,
provider: request.provider,
cwd: request.cwd,
reason: request.reason,
purpose: request.purpose,
title: request.title,
labels: request.labels,
tools: request.tools,
env: { HOOK: "1" },
mcpServers: request.mcpServers,
}));
const result = await hooks.invoke(
"operation",
"agent.open.before",
{
agentId: "agent",
workspaceId: null,
provider: "codex",
cwd: "/project",
title: null,
labels: {},
metadata: { "linear.issue": { id: "ENG-1" } },
tools: [],
mcpServers: {},
env: {},
reason: "resume",
purpose: "interactive",
},
paseo,
);
expect(result).toMatchObject({
env: { HOOK: "1" },
metadata: { "linear.issue": { id: "ENG-1" } },
});
});
test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
"session-open targeting metadata is read-only: %j",
async (patch) => {
@@ -116,6 +216,7 @@ test.each([{ title: "Changed" }, { labels: { changed: "true" } }])(
cwd: "/project",
title: "Original",
labels: {},
metadata: {},
tools: [],
mcpServers: {},
env: {},
@@ -13,6 +13,7 @@ import {
import type { AgentToolDefinition } from "@getpaseo/plugin/server";
import type { AgentStreamEvent } from "../../agent/agent-sdk-types.js";
import { z } from "zod";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { CreateAgentRequestMessageSchema } from "@getpaseo/protocol/messages";
import type {
PluginHookAgent,
@@ -91,6 +92,7 @@ const beforeSchemas = {
"agent.create.before": CreateAgentRequestMessageSchema.pick({
config: true,
env: true,
metadata: true,
}).strict(),
"agent.open.before": z
.object({
@@ -102,6 +104,8 @@ const beforeSchemas = {
purpose: z.enum(["interactive", "history"]),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()),
// Optional so a plugin that returns its own object keeps working; omitted means unchanged.
metadata: z.record(z.string(), z.custom<JsonValue>()).optional(),
tools: z.array(agentToolMetadataSchema),
env: z.record(z.string(), z.string()),
mcpServers: CreateAgentRequestMessageSchema.shape.config.shape.mcpServers.unwrap(),
@@ -152,6 +156,7 @@ export function describeHookWorkspace(workspace: PersistedWorkspaceRecord): Plug
cwd: workspace.cwd,
name: workspace.title,
archivedAt: workspace.archivedAt,
metadata: workspace.metadata,
};
}
@@ -162,6 +167,7 @@ export function describeHookAgent(agent: {
cwd: string;
title?: string | null;
labels: Record<string, string>;
metadata: Record<string, JsonValue>;
}): PluginHookAgent {
return {
id: agent.id,
@@ -170,6 +176,7 @@ export function describeHookAgent(agent: {
provider: agent.provider,
cwd: agent.cwd,
title: agent.title ?? null,
metadata: agent.metadata,
};
}
@@ -229,10 +236,12 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
previous.reason !== next.reason ||
previous.purpose !== next.purpose ||
previous.title !== next.title ||
!isDeepStrictEqual(previous.labels, next.labels)
!isDeepStrictEqual(previous.labels, next.labels) ||
(next.metadata !== undefined && !isDeepStrictEqual(previous.metadata, next.metadata))
) {
throw new Error("agent.open.before hooks can only change env, tools, and mcpServers");
}
return { ...result, metadata: previous.metadata };
}
if (name === "agent.create.before") {
const previous = beforeSchemas["agent.create.before"].parse(input);
@@ -240,6 +249,14 @@ export async function validateBeforeResult<Name extends keyof PluginWireBeforeRe
if (previous.config.cwd !== next.config.cwd) {
throw new Error("agent.create.before hooks cannot change the workspace directory");
}
// Omitted means unchanged: the next hook sees what this one was handed.
return { ...result, metadata: next.metadata ?? previous.metadata };
}
if (name === "workspace.create.before") {
const previous = beforeSchemas["workspace.create.before"].parse(input);
const next = beforeSchemas["workspace.create.before"].parse(result);
// Omitted means unchanged; an explicit {} still replaces.
return { ...result, metadata: next.metadata ?? previous.metadata };
}
if (name === "worktree.create.before") {
const previous = beforeSchemas["worktree.create.before"].parse(input);
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginHookAgent } from "@getpaseo/plugin/server";
import { UsageScopeSchema, type UsageScope } from "@getpaseo/plugin/server/usage";
import type {
@@ -168,6 +169,7 @@ export const PluginProcessRequestSchema: z.ZodType<PluginProcessRequest> = z.dis
provider: z.string(),
cwd: z.string(),
title: z.string().nullable(),
metadata: z.record(z.string(), z.custom<JsonValue>()),
})
.strict(),
name: z.string(),
@@ -0,0 +1,15 @@
/** Top-level keys replace; a null value removes the key. */
export function applyRecordPatch<Value>(
record: Record<string, Value>,
patch: Record<string, Value | null>,
): Record<string, Value> {
const next = { ...record };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete next[key];
} else {
next[key] = value;
}
}
return next;
}
@@ -88,7 +88,7 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
async resolveCreateConfig(input) {
return {
modeId: input.unattended ? input.requestedMode : "interactive",
featureValues: input.featureValues,
providerOptions: input.providerOptions,
};
},
};
@@ -1911,7 +1911,7 @@ describe("ScheduleService", () => {
});
return {
modeId: input.unattended ? "bypassPermissions" : "interactive",
featureValues: input.featureValues,
providerOptions: input.providerOptions,
};
},
},
@@ -1980,9 +1980,9 @@ describe("ScheduleService", () => {
});
return {
modeId: input.unattended ? "build" : "interactive",
featureValues: input.unattended
? { ...input.featureValues, auto_accept: true }
: input.featureValues,
providerOptions: input.unattended
? { ...input.providerOptions, auto_accept: true }
: input.providerOptions,
};
},
},
@@ -2010,7 +2010,7 @@ describe("ScheduleService", () => {
expect(inspected.runs[0]?.error).toBeNull();
expect(createdConfigs[0]).toMatchObject({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
});
@@ -2052,7 +2052,7 @@ describe("ScheduleService", () => {
});
return {
modeId: input.requestedMode,
featureValues: { ...input.featureValues, resolved: true },
providerOptions: { ...input.providerOptions, resolved: true },
};
},
},
@@ -2099,11 +2099,13 @@ describe("ScheduleService", () => {
model: "test-model",
modeId: "stored-mode",
thinkingOptionId: "think-hard",
// Stored schedules written before options carry `featureValues`; launch folds them.
providerOptions: {
allowedTools: ["Read"],
sandbox: { enabled: true, network: { allowLocalBinding: true } },
auto_accept: true,
resolved: true,
},
featureValues: { auto_accept: true, resolved: true },
systemPrompt: "Stay concise.",
mcpServers: {
docs: {
@@ -15,6 +15,7 @@ import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import { foldFeatureValues } from "../agent/option-controls.js";
import type {
CreateScheduleInput,
ScheduleExecutionResult,
@@ -235,7 +236,7 @@ type ScheduleAgentManager = Pick<
> &
Pick<
AgentManager,
| "createAgent"
| "createAgentFromRecord"
| "getRegisteredProviderIds"
| "hydrateTimelineFromProvider"
| "resumeAgentFromPersistence"
@@ -998,7 +999,6 @@ export class ScheduleService {
},
mode: config.modeId,
thinking: config.thinkingOptionId,
features: config.featureValues,
unattended: true,
promptFailure: "return-error",
notifyOnFinish: false,
@@ -1098,8 +1098,8 @@ function buildScheduleAgentConfig(
model: config.model,
thinkingOptionId: config.thinkingOptionId,
title: config.title,
providerOptions: config.providerOptions,
featureValues: config.featureValues,
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
providerOptions: foldFeatureValues(config).providerOptions,
systemPrompt: config.systemPrompt,
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
};
+2 -2
View File
@@ -5877,7 +5877,7 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }),
agentManager: liveAgentManager({ setAgentOptions: vi.fn().mockResolvedValue(undefined) }),
});
await session.handleMessage({
@@ -5901,7 +5901,7 @@ describe("agent config setters", () => {
const session = createSessionForTest({
messages,
agentManager: liveAgentManager({
setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")),
setAgentOptions: vi.fn().mockRejectedValue(new Error("feature boom")),
}),
});
+104 -7
View File
@@ -93,11 +93,12 @@ import {
createWorkspaceScriptsService,
type WorkspaceScriptsService,
} from "./session/workspace-scripts/workspace-scripts-service.js";
import type { DaemonConfigStore } from "./daemon-config-store.js";
import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import { applyRecordPatch } from "./record-patch.js";
import type { WorkspaceGitRuntimeSnapshot, WorkspaceGitService } from "./workspace-git-service.js";
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
import {
@@ -1090,7 +1091,7 @@ export class Session {
supportsProviderSnapshotReferences: () =>
this.supports(CLIENT_CAPS.providerSnapshotReferences),
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
listDraftControls: (config) => this.agentManager.listDraftControls(config),
},
providerSnapshotManager,
logger: this.sessionLogger,
@@ -1120,8 +1121,7 @@ export class Session {
setMode: async (agentId, modeId) =>
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
setFeature: (agentId, featureId, value) =>
agentManager.setAgentFeature(agentId, featureId, value),
setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
setThinking: (agentId, thinkingOptionId) =>
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
},
@@ -2247,7 +2247,9 @@ export class Session {
record: StoredAgentRecord,
registeredProviderIds = new Set(this.providerSnapshotManager.listRegisteredProviderIds()),
): AgentSnapshotPayload {
return buildStoredAgentPayload(record, registeredProviderIds);
return buildStoredAgentPayload(record, registeredProviderIds, (provider, own) =>
this.agentManager.effectiveOptionsFor(provider, own),
);
}
private isProviderVisibleToClient(provider: string): boolean {
@@ -2589,10 +2591,22 @@ export class Session {
this.dispatchWorkspaceStateMessage(msg) ??
this.dispatchWorkspaceLabelMessage(msg) ??
this.dispatchWorkspaceSetupMessage(msg) ??
this.dispatchMetadataMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg)
);
}
private dispatchMetadataMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "agent.metadata.update.request":
return this.handleAgentMetadataUpdateRequest(msg);
case "workspace.metadata.update.request":
return this.handleWorkspaceMetadataUpdateRequest(msg);
default:
return undefined;
}
}
private dispatchOrchestrationSkillsMessage(
msg: SessionInboundMessage,
): Promise<void> | undefined {
@@ -3137,7 +3151,10 @@ export class Session {
case "get_daemon_config_request":
this.emit({
type: "get_daemon_config_response",
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
payload: {
requestId: msg.requestId,
config: toWireDaemonConfig(this.daemonConfigStore.get()),
},
});
return undefined;
case "daemon.get_status.request":
@@ -3161,7 +3178,7 @@ export class Session {
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config,
config: toWireDaemonConfig(config),
},
});
});
@@ -3378,6 +3395,8 @@ export class Session {
return this.providerCatalogSession.handleListProviderModesRequest(msg);
case "list_provider_features_request":
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
case "provider.controls.list.request":
return this.providerCatalogSession.handleProviderControlsListRequest(msg);
case "list_available_providers_request":
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
case "get_providers_snapshot_request":
@@ -4153,6 +4172,79 @@ export class Session {
}
}
private async handleAgentMetadataUpdateRequest(
request: Extract<SessionInboundMessage, { type: "agent.metadata.update.request" }>,
): Promise<void> {
const { agentId, patch, requestId } = request;
this.sessionLogger.info({ agentId, requestId }, "session: agent.metadata.update.request");
try {
const metadata = await this.agentManager.setMetadata(agentId, patch);
this.emit({
type: "agent.metadata.update.response",
payload: { requestId, agentId, accepted: true, metadata, error: null },
});
} catch (error) {
this.sessionLogger.error(
{ err: error, agentId, requestId },
"session: agent.metadata.update.request error",
);
this.emit({
type: "agent.metadata.update.response",
payload: {
requestId,
agentId,
accepted: false,
metadata: null,
error: getErrorMessageOr(error, "Failed to update agent metadata"),
},
});
}
}
private async handleWorkspaceMetadataUpdateRequest(
request: Extract<SessionInboundMessage, { type: "workspace.metadata.update.request" }>,
): Promise<void> {
const { workspaceId, patch, requestId } = request;
this.sessionLogger.info(
{ workspaceId, requestId },
"session: workspace.metadata.update.request",
);
const reject = (error: string) =>
this.emit({
type: "workspace.metadata.update.response",
payload: { requestId, workspaceId, accepted: false, metadata: null, error },
});
try {
const updatedAt = new Date().toISOString();
const updated = await this.workspaceRegistry.update(workspaceId, (existing) => ({
...existing,
metadata: applyRecordPatch(existing.metadata, patch),
updatedAt,
}));
if (!updated) {
reject("Workspace not found");
return;
}
this.emit({
type: "workspace.metadata.update.response",
payload: {
requestId,
workspaceId,
accepted: true,
metadata: updated.metadata,
error: null,
},
});
await this.emitWorkspaceUpdatesForWorkspaceIds([workspaceId]);
} catch (error) {
this.sessionLogger.error(
{ err: error, workspaceId, requestId },
"session: workspace.metadata.update.request error",
);
reject(getErrorMessageOr(error, "Failed to update workspace metadata"));
}
}
private async handleWorkspaceBaseBranchSetRequest(
msg: WorkspaceBaseBranchSetRequest,
): Promise<void> {
@@ -4748,6 +4840,7 @@ export class Session {
images,
attachments,
labels: resolvedIntent.intent.labels,
metadata: msg.metadata,
env,
provisionalTitle,
firstAgentContext,
@@ -5962,6 +6055,7 @@ export class Session {
title: workspace.title,
pinnedAt: workspace.pinnedAt,
...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}),
metadata: workspace.metadata,
background: workspace.background,
parentWorkspaceId: workspace.parentWorkspaceId,
archivingAt: null,
@@ -6062,6 +6156,7 @@ export class Session {
...(result.workspace.labels && result.workspace.labels.length > 0
? { labels: result.workspace.labels }
: {}),
metadata: result.workspace.metadata,
archivingAt: null,
status: "done",
statusEnteredAt: result.workspace.createdAt,
@@ -7188,6 +7283,7 @@ export class Session {
{
expectsInitialAgent: Boolean(request.firstAgentContext),
workspaceId,
metadata: request.metadata,
...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)),
},
);
@@ -7251,6 +7347,7 @@ export class Session {
githubPrNumber: source.githubPrNumber,
firstAgentContext: request.firstAgentContext,
title: request.title,
metadata: request.metadata,
...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)),
},
source.baseBranch
@@ -2022,7 +2022,8 @@ test("workspace mark unread selects a finished cross-workspace child with scoped
projectLive: async () => {
throw new Error("No live agents");
},
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
projectStored: (record) =>
buildStoredAgentPayload(record, ["codex"], (_provider, own) => own ?? {}),
isProviderVisible: () => true,
isStoredProviderAvailable: () => true,
});
@@ -12,7 +12,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
readonly loadedAgentIds: string[] = [];
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
readonly optionsCalls: Array<{ agentId: string; patch: Record<string, unknown> }> = [];
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
readonly callLog: string[] = [];
@@ -39,9 +39,9 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
if (this.failWith) throw this.failWith;
}
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
this.featureCalls.push({ agentId, featureId, value });
this.callLog.push(`feature:${featureId}`);
async setOptions(agentId: string, patch: Record<string, unknown>): Promise<void> {
this.optionsCalls.push({ agentId, patch });
this.callLog.push("options");
if (this.failWith) throw this.failWith;
}
@@ -206,7 +206,7 @@ describe("AgentConfigSession", () => {
});
});
test("set feature: forwards the feature value and emits an accepted response with no notice", async () => {
test("set feature: maps the feature value to a one-key options patch and emits an accepted response with no notice", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleSetAgentFeatureRequest({
@@ -217,9 +217,7 @@ describe("AgentConfigSession", () => {
requestId: "req-1",
});
expect(operations.featureCalls).toEqual([
{ agentId: "agent-1", featureId: "web_search", value: true },
]);
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]);
expect(emitted).toEqual([
{
type: "set_agent_feature_response",
@@ -309,7 +307,7 @@ describe("AgentConfigSession", () => {
});
});
test("apply: sets model before thinking and features last, in one accepted response", async () => {
test("apply: sets model before thinking and options last, in one accepted response", async () => {
const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
@@ -319,18 +317,16 @@ describe("AgentConfigSession", () => {
modelId: "opus-5",
modeId: "plan",
thinkingOptionId: "high",
featureValues: { webSearch: true },
options: { webSearch: true },
},
requestId: "req-1",
});
expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]);
expect(operations.callLog).toEqual(["model", "mode", "thinking", "options"]);
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(operations.featureCalls).toEqual([
{ agentId: "agent-1", featureId: "webSearch", value: true },
]);
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { webSearch: true } }]);
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
expect(emitted).toEqual([
{
@@ -346,6 +342,39 @@ describe("AgentConfigSession", () => {
]);
});
test("apply: passes an options patch through, null included, in one call", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { options: { fast_mode: true, service_tier: null } },
requestId: "req-1",
});
expect(operations.optionsCalls).toEqual([
{ agentId: "agent-1", patch: { fast_mode: true, service_tier: null } },
]);
});
test("apply: folds legacy featureValues into the options patch, options winning", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: {
featureValues: { fast_mode: true, service_tier: "fast" },
options: { fast_mode: false },
},
requestId: "req-1",
});
expect(operations.optionsCalls).toEqual([
{ agentId: "agent-1", patch: { fast_mode: false, service_tier: "fast" } },
]);
});
test("apply: leaves omitted fields alone", async () => {
const { subsystem, operations } = makeSubsystem();
@@ -381,11 +410,11 @@ describe("AgentConfigSession", () => {
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } },
config: { modeId: "plan", thinkingOptionId: "high", options: { webSearch: true } },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]);
expect(operations.callLog).toEqual(["mode", "thinking", "options"]);
expect(emitted).toEqual([
{
type: "agent.config.apply.response",
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { ProviderOptionsPatch } from "../../agent/option-controls.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
/**
@@ -22,13 +23,13 @@ export interface AgentConfigSessionHost {
* The per-agent config mutations this subsystem drives. The shell adapts these
* onto the AgentManager and loads a closed agent before mutation (mode still
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
* thinking yield a provider notice; model and feature do not.
* thinking yield a provider notice; model and options do not.
*/
export interface AgentConfigOperations {
ensureLoaded(agentId: string): Promise<void>;
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
setModel(agentId: string, modelId: string | null): Promise<void>;
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>;
setOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void>;
setThinking(
agentId: string,
thinkingOptionId: string | null,
@@ -112,8 +113,9 @@ export class AgentConfigSession {
logLabel: "set_agent_feature_request",
logFields: { agentId, featureId, value, requestId },
failureText: "Failed to set agent feature",
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A feature is a one-key options patch.
run: async () => {
await this.operations.setFeature(agentId, featureId, value);
await this.operations.setOptions(agentId, { [featureId]: value });
return undefined;
},
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
@@ -152,7 +154,7 @@ export class AgentConfigSession {
/**
* Model first, because a thinking option only means anything against the model
* that offers it; features last so they settle on top of the rest. An omitted
* that offers it; options last so they settle on top of the rest. An omitted
* field is left alone. The first notice produced is the one the client sees —
* later steps still run, so a notice never truncates the bundle.
*
@@ -189,8 +191,10 @@ export class AgentConfigSession {
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
notice ??= thinkingNotice;
}
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) {
await this.operations.setFeature(agentId, featureId, value);
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch.
const optionsPatch = { ...config.featureValues, ...config.options };
if (Object.keys(optionsPatch).length > 0) {
await this.operations.setOptions(agentId, optionsPatch);
}
return notice;
@@ -13,7 +13,7 @@ import {
type ProviderSnapshot,
type ProviderSnapshotTransition,
} from "../../agent/provider-snapshot-manager.js";
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
import type { AgentSessionConfig, ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
@@ -58,7 +58,7 @@ function makeSubsystem(options: MakeOptions = {}) {
},
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
listDraftControls: async () => ({ controls: [], options: {} }),
...options.host,
};
const providerSnapshotManager = createStub<ProviderSnapshotManager>({
@@ -296,10 +296,100 @@ describe("ProviderCatalogSession", () => {
});
});
it("lists controls and effective options for a draft config", async () => {
const drafts: AgentSessionConfig[] = [];
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftControls: async (config) => {
drafts.push(config);
return {
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
options: { fast_mode: true },
};
},
},
});
await subsystem.handleProviderControlsListRequest({
type: "provider.controls.list.request",
requestId: "c1",
draftConfig: {
provider: "claude",
cwd: "/tmp/project",
model: "claude-opus-4-8",
providerOptions: { fast_mode: true },
},
});
expect(drafts).toEqual([
{
provider: "claude",
cwd: "/tmp/project",
model: "claude-opus-4-8",
providerOptions: { fast_mode: true },
},
]);
expect(findByType(emitted, "provider.controls.list.response")?.payload).toEqual({
provider: "claude",
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
options: { fast_mode: true },
error: null,
fetchedAt: expect.any(String),
requestId: "c1",
});
});
it("surfaces a controls failure inline, not as an rpc_error", async () => {
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftControls: async () => {
throw new Error("controls probe failed");
},
},
});
await subsystem.handleProviderControlsListRequest({
type: "provider.controls.list.request",
requestId: "c2",
draftConfig: { provider: "codex", cwd: "/tmp/project" },
});
expect(findByType(emitted, "rpc_error")).toBeUndefined();
expect(findByType(emitted, "provider.controls.list.response")?.payload.error).toBe(
"controls probe failed",
);
});
it("answers list_provider_features_request from controls, folding legacy featureValues", async () => {
const drafts: AgentSessionConfig[] = [];
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftControls: async (config) => {
drafts.push(config);
return {
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
options: { fast_mode: true },
};
},
},
});
await subsystem.handleListProviderFeaturesRequest({
type: "list_provider_features_request",
requestId: "f0",
draftConfig: { provider: "claude", cwd: "/tmp/project", featureValues: { fast_mode: true } },
});
expect(drafts[0]?.providerOptions).toEqual({ fast_mode: true });
expect(findByType(emitted, "list_provider_features_response")?.payload.features).toEqual([
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
]);
});
it("surfaces a feature-list failure inline, not as an rpc_error", async () => {
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftFeatures: async () => {
listDraftControls: async () => {
throw new Error("feature probe failed");
},
},
@@ -379,7 +469,7 @@ it("announces shared content without retransmitting models or hashing discovery
},
supportsProviderSnapshotReferences: () => references,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
listDraftControls: async () => ({ controls: [], options: {} }),
},
});
const emitted: SessionOutboundMessage[] = [];
@@ -13,12 +13,15 @@ import {
} from "../../agent/provider-snapshot-manager.js";
import {
filterSelectableAgentModels,
type AgentFeature,
type AgentOptionControl,
type AgentProvider,
type AgentSessionConfig,
type ProviderSnapshotEntry,
} from "../../agent/agent-sdk-types.js";
import type { ProviderAvailability } from "../../agent/agent-manager.js";
import { featuresFromControls } from "@getpaseo/protocol/option-controls";
import { foldFeatureValues } from "../../agent/option-controls.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { expandTilde } from "../../../utils/path.js";
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
@@ -49,7 +52,9 @@ export interface ProviderCatalogSessionHost {
supportsProviderSnapshotReferences(): boolean;
publishSnapshot(project: () => SessionOutboundMessage | null): void;
listProviderAvailability(): Promise<ProviderAvailability[]>;
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
listDraftControls(
config: AgentSessionConfig,
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }>;
}
export interface ProviderCatalogSessionOptions {
@@ -340,29 +345,59 @@ export class ProviderCatalogSession {
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
providerOptions?: ProviderOptions;
}): AgentSessionConfig {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
const { providerOptions } = foldFeatureValues(draftConfig);
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
...(providerOptions ? { providerOptions } : {}),
};
}
async handleProviderControlsListRequest(
msg: Extract<SessionInboundMessage, { type: "provider.controls.list.request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
const provider = msg.draftConfig.provider;
try {
const { controls, options } = await this.host.listDraftControls(
this.buildDraftAgentSessionConfig(msg.draftConfig),
);
this.host.emit({
type: "provider.controls.list.response",
payload: { provider, controls, options, error: null, fetchedAt, requestId: msg.requestId },
});
} catch (error) {
this.logger.error(
{ err: error, provider, draftConfig: msg.draftConfig },
`Failed to list controls for ${provider}`,
);
this.host.emit({
type: "provider.controls.list.response",
payload: { provider, error: getErrorMessage(error), fetchedAt, requestId: msg.requestId },
});
}
}
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Answered from controls.
async handleListProviderFeaturesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
const features = await this.host.listDraftFeatures(sessionConfig);
const { controls, options } = await this.host.listDraftControls(
this.buildDraftAgentSessionConfig(msg.draftConfig),
);
this.host.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
features,
features: featuresFromControls({ controls, options }),
error: null,
fetchedAt,
requestId: msg.requestId,
@@ -1,3 +1,4 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { PluginLifecycle } from "../../plugins/lifecycle/index.js";
import { describeHookWorkspace } from "../../plugins/lifecycle/index.js";
import { basename, resolve } from "node:path";
@@ -51,6 +52,7 @@ export interface CreateWorktreeWorkspaceInput {
branch: string | null;
baseBranch: string | null;
title: string | null;
metadata?: Record<string, JsonValue>;
expectsInitialAgent?: boolean;
untrustedSource?: UntrustedWorkspaceSource;
background?: boolean;
@@ -71,6 +73,7 @@ export interface WorkspaceProvisioningService {
context?: {
expectsInitialAgent?: boolean;
workspaceId?: string;
metadata?: Record<string, JsonValue>;
background?: boolean;
callerWorkspaceId?: string;
},
@@ -251,6 +254,7 @@ export function createWorkspaceProvisioningService(deps: {
context?: {
expectsInitialAgent?: boolean;
workspaceId?: string;
metadata?: Record<string, JsonValue>;
background?: boolean;
callerWorkspaceId?: string;
},
@@ -273,6 +277,7 @@ export function createWorkspaceProvisioningService(deps: {
...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }),
baseBranch: baseMetadata?.baseRef ?? baseMetadata?.baseRefName ?? null,
title: title?.trim() || null,
metadata: context?.metadata,
createdAt: timestamp,
updatedAt: timestamp,
...(await resolveCreationMetadata(context)),
@@ -313,6 +318,7 @@ export function createWorkspaceProvisioningService(deps: {
mainRepoRoot: repoRoot,
}),
title: input.title,
metadata: input.metadata,
createdAt: timestamp,
updatedAt: timestamp,
...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}),
@@ -50,13 +50,13 @@ describe("snapshot mutation ownership boundary", () => {
const applySnapshotSpy = vi.spyOn(daemonHandle.daemon.agentStorage, "applySnapshot");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4");
await daemonHandle.daemon.agentManager.setAgentModel(snapshot.id, "gpt-5.4-mini");
await daemonHandle.daemon.agentManager.flush();
expect(applySnapshotSpy.mock.calls.length).toBeGreaterThanOrEqual(1);
const persisted = await daemonHandle.daemon.agentStorage.get(snapshot.id);
expect(persisted?.config?.model).toBe("gpt-5.4");
expect(persisted?.config?.model).toBe("gpt-5.4-mini");
} finally {
rmSync(cwd, { recursive: true, force: true });
await daemonHandle.close();
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
import { appendFile, mkdir, readFile } from "node:fs/promises";
@@ -6,7 +7,7 @@ import path from "node:path";
import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentOptionControl,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
@@ -15,6 +16,7 @@ import type {
AgentRunOptions,
AgentRunResult,
AgentSession,
AgentSelectOption,
AgentSessionConfig,
AgentStreamEvent,
AgentSlashCommand,
@@ -37,6 +39,26 @@ const TEST_CAPABILITIES: AgentCapabilityFlags = {
};
const TEST_FEATURE_ID = "test_feature";
const TEST_THINKING_OPTIONS: AgentSelectOption[] = [
{ id: "low", label: "Low", isDefault: true },
{ id: "high", label: "High" },
];
const TEST_MODELS: Record<string, Array<{ id: string; label: string }>> = {
claude: [
{ id: "haiku", label: "Haiku" },
{ id: "sonnet", label: "Sonnet" },
],
codex: [
{ id: "gpt-5.4-mini", label: "gpt-5.4-mini" },
{ id: "gpt-5.4", label: "gpt-5.4" },
],
opencode: [
{ id: "test-model", label: "Test Model" },
{ id: "test-model-alt", label: "Test Model Alt" },
],
};
const TEST_MODES: AgentMode[] = [
{ id: "bypassPermissions", label: "Bypass", description: "No permissions" },
{ id: "default", label: "Default", description: "Ask for permissions" },
@@ -379,14 +401,13 @@ class FakeAgentSession implements AgentSession {
return this.providerName;
}
get features(): AgentFeature[] {
get controls(): AgentOptionControl[] {
return [
{
type: "toggle",
id: TEST_FEATURE_ID,
key: TEST_FEATURE_ID,
label: "Test feature",
description: "Deterministic provider feature used by MCP integration tests.",
value: this.config.featureValues?.[TEST_FEATURE_ID] === true,
},
];
}
@@ -873,11 +894,8 @@ class FakeAgentSession implements AgentSession {
this.config.modeId = modeId;
}
async setFeature(featureId: string, value: unknown): Promise<void> {
this.config.featureValues = {
...this.config.featureValues,
[featureId]: value,
};
async applyOptions(options: ProviderOptions): Promise<void> {
this.config.providerOptions = { ...this.config.providerOptions, ...options };
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -1283,30 +1301,18 @@ class FakeAgentClient implements AgentClient {
async fetchCatalog(
_options: FetchCatalogOptions,
): Promise<{ models: AgentModelDefinition[]; modes: AgentMode[] }> {
if (this.provider === "claude") {
return {
models: [
{ provider: this.provider, id: "haiku", label: "Haiku", isDefault: true },
{ provider: this.provider, id: "sonnet", label: "Sonnet", isDefault: false },
],
modes: TEST_MODES,
};
}
if (this.provider === "codex") {
return {
models: [
{
provider: this.provider,
id: "gpt-5.4-mini",
label: "gpt-5.4-mini",
isDefault: true,
},
],
modes: TEST_MODES,
};
}
// Every provider lists two models that each list thinking options, so the live model and
// thinking switches have listed values to switch between.
const listed = TEST_MODELS[this.provider] ?? TEST_MODELS.opencode;
return {
models: [{ provider: this.provider, id: "test-model", label: "Test Model", isDefault: true }],
models: listed.map((model, index) => ({
provider: this.provider,
id: model.id,
label: model.label,
isDefault: index === 0,
thinkingOptions: TEST_THINKING_OPTIONS,
defaultThinkingOptionId: "low",
})),
modes: TEST_MODES,
};
}
@@ -37,6 +37,7 @@ export function asAgentManager(stub: {
return createStub<SessionOptions["agentManager"]>({
listProviderSubagentActivity: () => [],
getRetiredInternalAgent: () => null,
effectiveOptionsFor: (_provider: string, own?: Record<string, unknown>) => own ?? {},
getAgentBackgroundWorkSummary: () => undefined,
...stub,
});
@@ -237,7 +238,7 @@ export function createProviderSnapshotManagerStub(): {
const listModes = vi.fn<ProviderSnapshotManager["listModes"]>(async () => []);
const resolveCreateConfig = vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>(async () => ({
modeId: undefined,
featureValues: undefined,
providerOptions: undefined,
}));
const resolveDefaultModel = vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>(
async () => undefined,

Some files were not shown because too many files have changed in this diff Show More