Compare commits

..
Author SHA1 Message Date
Mohamed Boudra 4a8ed8f588 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.
2026-11-01 21:29:07 +01:00
Mohamed Boudra 4d76f2176b 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.
2026-11-01 21:20:09 +01:00
Mohamed Boudra e4a49b414f 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.
2026-11-01 21:09:10 +01:00
Mohamed Boudra fbd7890f78 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.
2026-11-01 21:03:31 +01:00
Mohamed Boudra 83ae20ff06 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.
2026-11-01 20:53:09 +01:00
Mohamed Boudra 9df005f602 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.
2026-11-01 20:18:18 +01:00
Mohamed Boudra d1a1eba23d 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.
2026-11-01 20:07:00 +01:00
Mohamed Boudra 133481d75c 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.
2026-11-01 19:59:10 +01:00
Mohamed Boudra e640fe4281 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.
2026-11-01 19:49:12 +01:00
Mohamed Boudra 43f7dc4ad1 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.
2026-11-01 19:47:11 +01:00
Mohamed Boudra f783c46301 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.
2026-11-01 19:41:34 +01:00
Mohamed Boudra 2fddf0b98c 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.
2026-11-01 19:33:45 +01:00
Mohamed Boudra 593773a381 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.
2026-11-01 18:57:05 +01:00
Mohamed Boudra e4d4ba7489 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.
2026-11-01 18:49:11 +01:00
Mohamed Boudra 1b5731cebe 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.
2026-11-01 18:41:01 +01:00
Mohamed Boudra 0f1ebda942 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.
2026-11-01 18:32:36 +01:00
Mohamed Boudra 7a1eae245b 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.
2026-11-01 17:51:37 +01:00
Mohamed Boudra 3383ee80bc 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.
2026-11-01 17:44:20 +01:00
Mohamed Boudra 5cdbfef48b 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.
2026-11-01 17:30:53 +01:00
Mohamed Boudra 0c378889d1 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.
2026-11-01 17:20:51 +01:00
Mohamed Boudra 8460e5126a 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.
2026-11-01 17:11:02 +01:00
Mohamed Boudra 51a87b98b9 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.
2026-11-01 17:05:09 +01:00
Mohamed Boudra 427c2d0743 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.
2026-11-01 17:01:54 +01:00
Mohamed Boudra a8d539ca7c 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.
2026-11-01 16:42:17 +01:00
Mohamed Boudra 993eef1668 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.
2026-11-01 16:35:18 +01:00
Mohamed Boudra c2677a2690 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.
2026-11-01 16:03:05 +01:00
Mohamed Boudra f3c3461d67 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.
2026-11-01 15:52:08 +01:00
Mohamed Boudra 85edc9da9c 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.
2026-11-01 15:44:24 +01:00
Mohamed Boudra fd2315495f refactor(server): the legacy featureValues folds take one named-input object 2026-11-01 15:34:06 +01:00
Mohamed Boudra 3a3300b69e fix(server): a plugin that moves settings and then refuses an apply reports what moved 2026-11-01 15:34:06 +01:00
Mohamed Boudra 0fef7414fc 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.
2026-11-01 15:34:06 +01:00
Mohamed Boudra c0c181425f 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.
2026-11-01 15:34:06 +01:00
Mohamed Boudra 301b49e381 test(server): removal of auto_accept returns ACP to asking; Claude keeps opening restrictions across a patch 2026-11-01 15:22:56 +01:00
Mohamed Boudra ee936d51c0 test(server): assert the forwarded plugin setting without nesting callbacks 2026-11-01 15:21:02 +01:00
Mohamed Boudra 8e28e3988b test(server): a setting a plugin changes on its own is forwarded as options_changed 2026-11-01 15:20:05 +01:00
Mohamed Boudra 62df2f3253 test(server): a patch key is resolved against nested configured defaults 2026-11-01 15:18:24 +01:00
Mohamed Boudra aba0c78449 docs(server): a rejected apply reports what the provider moved after the call ends 2026-11-01 15:15:20 +01:00
Mohamed Boudra a0754e9f9b 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.
2026-11-01 15:15:20 +01:00
Mohamed Boudra 9d8747cd3a 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.
2026-11-01 15:15:20 +01:00
Mohamed Boudra 13ad4a13ff 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.
2026-11-01 15:10:35 +01:00
Mohamed Boudra 9de8cbf2b8 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.
2026-11-01 15:10:35 +01:00
Mohamed Boudra 2dd83e43cb 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.
2026-11-01 15:10:35 +01:00
Mohamed Boudra ac6b5440e8 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.
2026-11-01 14:55:27 +01:00
Mohamed Boudra 0da2781520 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.
2026-11-01 14:40:11 +01:00
Mohamed Boudra f487316a9d chore(website): regenerate the config schema for profile options 2026-11-01 08:17:11 +01:00
Mohamed Boudra 1ee149c85b 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.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 72d488f17a 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.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 8e83d9346a 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.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 6c22bc72df 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.
2026-11-01 07:53:29 +01:00
Mohamed Boudra bc00edf904 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.
2026-11-01 07:53:29 +01:00
Mohamed Boudra afce426a43 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).
2026-11-01 07:53:28 +01:00
paseo-bot[bot]andDominic Anaeto 14519bdb5b Read archived Pi chats after their working folder is removed (#8442)
* fix(pi): open an archived Pi agent's history after its folder is removed

Opening an archived agent's timeline resumes its provider for history.
Pi's resumeSession never received the resume purpose, so it launched the
Pi RPC process, and its MCP support probe, in the agent's stored working
directory. When that folder had been removed, for example with a merged
worktree, Node failed the spawn with "spawn <command> ENOENT" and the
history could not be read.

Starting the process elsewhere is not enough: with --session, Pi checks
the cwd stored in the session file's header and, outside interactive
mode, exits when that folder is missing. A history resume whose stored
folder is no longer a directory now starts Pi from the home directory on
a temporary copy of the session file whose header names that directory.
The copy streams everything after the header unchanged and is deleted
when the session closes. The agent keeps reporting its stored folder and
original session file, and the original file is not written to.

The directory check moves out of the ACP provider, which already starts
history loads from the home directory (#6228), into a shared helper both
providers use.

Fixes #7969

* test(terminal): keep the argv test child alive through its assertion

The POSIX argv test reads the terminal screen while its child can exit and dispose the parser. Keep the child alive until the assertion completes, as other screen tests do, and let the existing session cleanup terminate it.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 06:52:48 +00:00
Mohamed Boudra 891e62309c fix(release): use the keychain password for macOS signing setup (#8445)
Backport the pinned packaging tool correction in the workflow so existing release tags can be rebuilt without changing their dependencies.
2026-11-01 13:35:11 +07:00
93 changed files with 5518 additions and 1180 deletions
+26
View File
@@ -135,6 +135,32 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag
shell: bash
run: |
+47 -3
View File
@@ -30,6 +30,50 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples.
### Options flow
`options` on the agent snapshot is the only option state. Controls describe how to draw an
option and read `options`; they hold no value of their own. Any option can be read and set with
or without a control.
- **Create and resume.** The provider opens with the options it is given and emits nothing about
them while opening. `openOptionsPatch` carries every option the session ended up with that
differs from what it was given, including a setting nobody asked for (a native default such as
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
daemon stores it before the agent is published. A reopened agent resumes that state, so a
native default the provider later changes does not follow. `options` on the snapshot is what
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
Editing a provider default in `config.json` affects agents the next time they open.
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
there is none; the provider returns the setting to its own default if it can, or throws to
reject. A key the running session cannot apply (one the provider reads only when it opens) is
rejected too, so a stored option always describes what runs. Claude reads most options when it
starts its query, and replacing a started query would end its background work, so after the
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
it ended up with when they differ from the request. The daemon
stores the patch and that result in one step after the call resolves, onto the agent's options
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
before it refused, it reports the moved values once as `options_changed` after the call has
ended, because the stored options must match what runs.
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
behind the provider id, following `derivedFromProviderId`, so a custom provider with
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
and provider-agnostic; it stays at the protocol and legacy edges.
- **Provider-initiated changes.** A setting the provider changes on its own is an
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools.
@@ -115,7 +159,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
@@ -478,7 +522,7 @@ interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[];
readonly controls?: AgentOptionControl[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -500,7 +544,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
applyOptions?(options: ProviderOptions): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null;
+3
View File
@@ -21,7 +21,10 @@ export const AgentProfileSchema = z
model: z.string().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read as `options`.
featureValues: z.record(z.string(), z.unknown()).optional(),
/** Provider options applied when the profile creates an agent. */
options: z.record(z.string(), z.unknown()).optional(),
/** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
notes: z.string().optional(),
})
+26 -1
View File
@@ -1,5 +1,12 @@
import type { AgentMessage } from "./agent-message.js";
import type { AgentAttachment } from "./messages.js";
import type {
AgentAttachment,
AgentOptionChoiceSchema,
AgentOptionControlSchema,
AgentOptionSelectControlSchema,
AgentOptionToggleControlSchema,
} from "./messages.js";
import type { z } from "zod";
export type AgentProvider = string;
@@ -154,6 +161,20 @@ export interface AgentFeatureSelect {
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
export type AgentOptionChoice = z.infer<typeof AgentOptionChoiceSchema>;
/** `key` is the top-level provider option key the control reads and writes. */
export type AgentOptionToggleControl = z.infer<typeof AgentOptionToggleControlSchema>;
/** `defaultValue` is the choice an unset option stands for. */
export type AgentOptionSelectControl = z.infer<typeof AgentOptionSelectControlSchema>;
/**
* A provider's description of a composer control over one top-level option key.
* The control carries no value: it is read from the agent's effective options.
*/
export type AgentOptionControl = z.infer<typeof AgentOptionControlSchema>;
export interface AgentCapabilityFlags {
[capability: string]: boolean | undefined;
supportsStreaming: boolean;
@@ -523,6 +544,10 @@ export interface AgentSessionConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
/**
* @deprecated COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Folded into `providerOptions` at the daemon's edge; the daemon never reads it internally.
*/
featureValues?: Record<string, unknown>;
title?: string | null;
providerOptions?: ProviderOptions;
+72
View File
@@ -361,6 +361,39 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureSelectSchema,
]);
export const AgentOptionChoiceSchema = z.object({
value: z.string(),
label: z.string(),
description: z.string().optional(),
});
export const AgentOptionToggleControlSchema = z.object({
type: z.literal("toggle"),
key: z.string(),
label: z.string(),
description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.boolean().optional(),
});
export const AgentOptionSelectControlSchema = z.object({
type: z.literal("select"),
key: z.string(),
label: z.string(),
description: z.string().optional(),
tooltip: z.string().optional(),
icon: z.string().optional(),
desktopTrigger: z.enum(["icon", "label"]).optional(),
defaultValue: z.string().optional(),
choices: z.array(AgentOptionChoiceSchema),
});
export const AgentOptionControlSchema = z.discriminatedUnion("type", [
AgentOptionToggleControlSchema,
AgentOptionSelectControlSchema,
]);
const AgentModelDefinitionSchema = z.object({
provider: AgentProviderSchema,
id: z.string(),
@@ -490,6 +523,7 @@ const AgentSessionConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
providerOptions: ProviderOptionsSchema.optional(),
@@ -866,7 +900,11 @@ export const AgentSnapshotPayloadSchema = z.object({
cwd: z.string(),
workspaceId: z.string().optional(),
model: z.string().nullable(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Built from controls + options.
features: z.array(AgentFeatureSchema).optional(),
/** Effective provider options: `config.json` defaults deep-merged with the agent's own. */
options: ProviderOptionsSchema.optional(),
controls: z.array(AgentOptionControlSchema).optional(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
createdAt: z.string(),
@@ -2055,7 +2093,10 @@ export const AgentConfigApplySchema = z.object({
modelId: z.string().nullable().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().nullable().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Maps to an options patch.
featureValues: z.record(z.string(), z.unknown()).optional(),
/** Patch of the agent's own options: replaces top-level keys; `null` removes the key. */
options: ProviderOptionsSchema.optional(),
});
export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
@@ -2971,7 +3012,9 @@ const ListCommandsDraftConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
providerOptions: ProviderOptionsSchema.optional(),
});
export const ListProviderFeaturesRequestMessageSchema = z.object({
@@ -2980,6 +3023,12 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ProviderControlsListRequestMessageSchema = z.object({
type: z.literal("provider.controls.list.request"),
draftConfig: ListCommandsDraftConfigSchema,
requestId: z.string(),
});
export const ListCommandsRequestSchema = z.object({
type: z.literal("list_commands_request"),
agentId: z.string(),
@@ -3157,6 +3206,7 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
model: z.string().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
providerOptions: ProviderOptionsSchema.optional(),
toolPolicy: ToolPolicySchema.optional(),
@@ -3357,6 +3407,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema,
ProviderControlsListRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema,
GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema,
@@ -3836,6 +3887,7 @@ export const ServerInfoStatusPayloadSchema = z
selectiveAgentTimeline: z.boolean().optional(),
explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(),
providerOptionControls: z.boolean().optional(),
binaryChunkingV1: z.boolean().optional(),
workspaceSetupObservation: z.boolean().optional(),
workspaceSortTimes: z.boolean().optional(),
@@ -6303,6 +6355,19 @@ export const ListProviderModesResponseMessageSchema = z.object({
}),
});
export const ProviderControlsListResponseMessageSchema = z.object({
type: z.literal("provider.controls.list.response"),
payload: z.object({
provider: AgentProviderSchema,
controls: z.array(AgentOptionControlSchema).optional(),
/** Effective options for the draft: `config.json` defaults merged with the draft's own. */
options: ProviderOptionsSchema.optional(),
error: z.string().nullable().optional(),
fetchedAt: z.string(),
requestId: z.string(),
}),
});
export const ListProviderFeaturesResponseMessageSchema = z.object({
type: z.literal("list_provider_features_response"),
payload: z.object({
@@ -7195,6 +7260,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
ProviderControlsListResponseMessageSchema,
ListAvailableProvidersResponseSchema,
GetProvidersSnapshotResponseMessageSchema,
ProvidersSnapshotUpdateMessageSchema,
@@ -7449,6 +7515,12 @@ export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
>;
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
export type ProviderControlsListRequestMessage = z.infer<
typeof ProviderControlsListRequestMessageSchema
>;
export type ProviderControlsListResponseMessage = z.infer<
typeof ProviderControlsListResponseMessageSchema
>;
export type ListProviderFeaturesRequestMessage = z.infer<
typeof ListProviderFeaturesRequestMessageSchema
>;
+1
View File
@@ -34,6 +34,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
isolation: z.enum(["local", "worktree"]).optional(),
title: z.string().trim().min(1).nullable().optional(),
providerOptions: z.record(z.string(), z.unknown()).optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
systemPrompt: z.string().optional(),
mcpServers: z.record(z.string(), z.unknown()).optional(),
@@ -23,6 +23,7 @@ import { AgentStorage } from "./agent-storage.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { toAgentPayload } from "./agent-projections.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { projectTimelineRows } from "./timeline-projection.js";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { startAgentRun } from "./agent-prompt.js";
@@ -38,7 +39,7 @@ import type {
import type {
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentOptionControl,
AgentLaunchContext,
AgentPromptInput,
AgentProvider,
@@ -57,6 +58,7 @@ import type {
} from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderDefinition } from "./provider-registry.js";
import { mergeProviderOptions } from "./provider-options.js";
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
@@ -182,13 +184,8 @@ class RecordingTimelineStore implements AgentTimelineStore {
}
}
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
return {
type: "toggle",
id: args.id,
label: args.label,
value: args.value,
};
function createControl(args: { key: string; label: string }): AgentOptionControl {
return { type: "toggle", key: args.key, label: args.label };
}
function expectArchivedAgentRecord(
@@ -2332,7 +2329,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
]);
});
test("listDraftFeatures does not start a fallback session without a model", async () => {
test("listDraftControls does not start a fallback session without a model", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -2365,18 +2362,21 @@ test("listDraftFeatures does not start a fallback session without a model", asyn
logger,
});
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
await expect(manager.listDraftControls({ provider: "codex", cwd: workdir })).resolves.toEqual({
controls: [],
options: {},
});
expect(client.fetchCatalogCalls).toBe(0);
expect(client.createSessionCalls).toBe(0);
expect(client.availabilityCalls).toBe(0);
});
test("listDraftFeatures uses client feature listing without a model", async () => {
test("listDraftControls uses client feature listing without a model", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false });
const draftControl = createControl({ key: "auto_accept", label: "Auto Accept" });
class DraftFeatureClient extends TestAgentClient {
fetchCatalogCalls = 0;
createSessionCalls = 0;
@@ -2398,9 +2398,9 @@ test("listDraftFeatures uses client feature listing without a model", async () =
return await super.createSession(config);
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.featureConfigs.push(config);
return [draftFeature];
return [draftControl];
}
}
const client = new DraftFeatureClient();
@@ -2412,9 +2412,13 @@ test("listDraftFeatures uses client feature listing without a model", async () =
logger,
});
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
draftFeature,
]);
await expect(
manager.listDraftControls({
provider: "codex",
cwd: workdir,
providerOptions: { auto_accept: true },
}),
).resolves.toEqual({ controls: [draftControl], options: { auto_accept: true } });
expect(client.fetchCatalogCalls).toBe(0);
expect(client.createSessionCalls).toBe(0);
@@ -2423,15 +2427,28 @@ test("listDraftFeatures uses client feature listing without a model", async () =
{
provider: "codex",
cwd: workdir,
providerOptions: { auto_accept: true },
},
]);
// Options without a control come back too.
await expect(
manager.listDraftControls({
provider: "codex",
cwd: workdir,
providerOptions: { auto_accept: true, sandbox_mode: "workspace-write" },
}),
).resolves.toEqual({
controls: [draftControl],
options: { auto_accept: true, sandbox_mode: "workspace-write" },
});
});
test("listDraftFeatures uses explicit model config without default model fetching", async () => {
test("listDraftControls uses explicit model config without default model fetching", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false });
const draftControl = createControl({ key: "web_search", label: "Fast mode" });
class DraftFeatureClient extends TestAgentClient {
fetchCatalogCalls = 0;
createSessionCalls = 0;
@@ -2447,9 +2464,9 @@ test("listDraftFeatures uses explicit model config without default model fetchin
return await super.createSession(config);
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.featureConfigs.push(config);
return [draftFeature];
return [draftControl];
}
}
const client = new DraftFeatureClient();
@@ -2461,13 +2478,13 @@ test("listDraftFeatures uses explicit model config without default model fetchin
logger,
});
const features = await manager.listDraftFeatures({
const draft = await manager.listDraftControls({
provider: "codex",
cwd: workdir,
model: "gpt-5.4",
});
expect(features).toEqual([draftFeature]);
expect(draft).toEqual({ controls: [draftControl], options: {} });
expect(client.fetchCatalogCalls).toBe(0);
expect(client.createSessionCalls).toBe(0);
expect(client.featureConfigs).toEqual([
@@ -4550,6 +4567,46 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
});
});
test("importProviderSession reports the defaults the session launched with, not defaults edited during the import", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-options-"));
let tier = "default";
class ImportClient extends TestAgentClient {
effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
return { service_tier: tier, ...own };
}
async importSession(input: ImportProviderSessionInput) {
// The user edits the default in config.json after the provider has launched.
tier = "fast";
return {
session: new TestAgentSession({ provider: "codex", cwd: workdir }),
config: { provider: "codex" as const, cwd: workdir },
persistence: {
provider: "codex" as const,
sessionId: input.providerHandleId,
nativeHandle: input.providerHandleId,
metadata: { provider: "codex", cwd: workdir },
},
timeline: [],
};
}
}
const manager = new AgentManager({
clients: { codex: new ImportClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const imported = await manager.importProviderSession({
provider: "codex",
providerHandleId: "thread-selected",
cwd: workdir,
workspaceId: "ws-imported",
});
expect(imported.effectiveOptions).toEqual({ service_tier: "default" });
});
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
const storagePath = join(workdir, "agents");
@@ -9984,16 +10041,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
expect(client.resumedModeId).toBe("acceptEdits");
});
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
test("respondToPermission applies provider option changes and refreshes runtime info after provider-managed plan approval", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class RefreshingPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: true }),
];
private planMode = true;
private modeId = "auto";
private pending = [
{
@@ -10005,8 +10059,11 @@ test("respondToPermission refreshes features and runtime info after provider-man
},
];
get features(): AgentFeature[] {
return this.featureState;
get controls(): AgentOptionControl[] {
return [
createControl({ key: "web_search", label: "Fast" }),
createControl({ key: "plan_mode", label: "Plan" }),
];
}
override async getRuntimeInfo() {
@@ -10015,7 +10072,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
sessionId: this.id,
model: "gpt-5.4",
modeId: this.modeId,
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
extra: { collaborationMode: this.planMode ? "Plan" : "Code" },
};
}
@@ -10030,10 +10087,12 @@ test("respondToPermission refreshes features and runtime info after provider-man
override async respondToPermission(): Promise<void> {
this.modeId = "auto";
this.pending = [];
this.featureState = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
];
this.planMode = false;
this.pushEvent({
type: "options_changed",
provider: this.provider,
patch: { plan_mode: false },
});
}
}
@@ -10056,6 +10115,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
{
provider: "codex",
cwd: workdir,
providerOptions: { web_search: true, plan_mode: true },
},
undefined,
{ workspaceId: undefined },
@@ -10080,20 +10140,14 @@ test("respondToPermission refreshes features and runtime info after provider-man
const updated = manager.getAgent(snapshot.id);
expect(updated?.pendingPermissions.size).toBe(0);
expect(updated?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
expect(updated?.effectiveOptions).toEqual({ web_search: true, plan_mode: false });
expect(updated?.runtimeInfo).toMatchObject({
model: "gpt-5.4",
extra: { collaborationMode: "Code" },
});
const persisted = await storage.get(snapshot.id);
expect(persisted?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
expect(persisted?.config?.providerOptions).toEqual({ web_search: true, plan_mode: false });
});
test("respondToPermission emits refreshed state before permission_resolved", async () => {
@@ -10102,9 +10156,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
const storage = new AgentStorage(storagePath, logger);
class OrderedPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
];
private modeId = "plan";
private pending = [
{
@@ -10116,8 +10167,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
},
];
get features(): AgentFeature[] {
return this.featureState;
get controls(): AgentOptionControl[] {
return [createControl({ key: "web_search", label: "Fast" })];
}
override async getRuntimeInfo() {
@@ -10138,6 +10189,11 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
}
override async respondToPermission(): Promise<void> {
this.pushEvent({
type: "options_changed",
provider: this.provider,
patch: { web_search: false },
});
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
@@ -10145,7 +10201,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
resolution: { behavior: "allow" },
});
this.modeId = "acceptEdits";
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
this.pending = [];
}
}
@@ -10169,6 +10224,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
{
provider: "codex",
cwd: workdir,
providerOptions: { web_search: true },
},
undefined,
{ workspaceId: undefined },
@@ -10180,9 +10236,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
return;
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
seen.push(
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.web_search)}`,
);
return;
}
@@ -12695,3 +12750,478 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]);
});
describe("agent options", () => {
class OptionsSession extends TestAgentSession {
readonly applied: ProviderOptions[] = [];
rejectWith: Error | null = null;
applyGate: Promise<void> | null = null;
/** What the session says it ended up with after the next apply. */
ended: Record<string, unknown> = {};
openOptionsPatch: Record<string, unknown> = {};
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
if (this.rejectWith) throw this.rejectWith;
await this.applyGate;
this.applied.push(options);
return this.ended;
}
}
class OptionsClient extends TestAgentClient {
session: OptionsSession | null = null;
openOptionsPatch: Record<string, unknown> = {};
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
return { service_tier: "default", ...own };
}
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.session = new OptionsSession(config);
this.session.openOptionsPatch = this.openOptionsPatch;
return this.session;
}
}
async function createOptionsAgent(
providerOptions?: ProviderOptions,
openOptionsPatch: Record<string, unknown> = {},
) {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OptionsClient();
client.openOptionsPatch = openOptionsPatch;
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, ...(providerOptions ? { providerOptions } : {}) },
undefined,
{ workspaceId: undefined },
);
return { manager, client, storage, agentId: agent.id };
}
test("config.json defaults show as effective options without entering the agent's own options", async () => {
const { manager, agentId } = await createOptionsAgent({ web_search: true });
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ web_search: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
});
test("setAgentOptions hands the provider only the keys the patch sets", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent({ web_search: true });
await manager.setAgentOptions(agentId, { service_tier: "fast" });
expect(client.session?.applied).toEqual([{ service_tier: "fast" }]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ web_search: true, service_tier: "fast" });
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", web_search: true });
await manager.flush();
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
web_search: true,
service_tier: "fast",
});
});
test("a patch key is resolved against the configured defaults, merging nested objects", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const client = new OptionsClient();
client.effectiveOptions = (own) =>
mergeProviderOptions({ sandbox: { enabled: true, failIfUnavailable: true } }, own) ?? {};
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.setAgentOptions(agent.id, {
sandbox: { network: { allowedDomains: ["a.test"] } },
});
expect(client.session?.applied).toEqual([
{
sandbox: {
enabled: true,
failIfUnavailable: true,
network: { allowedDomains: ["a.test"] },
},
},
]);
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({
sandbox: { network: { allowedDomains: ["a.test"] } },
});
// The snapshot shows what the provider was given, defaults included, and keeps showing it.
await manager.markAgentUnread(agent.id);
expect(manager.getAgent(agent.id)?.effectiveOptions).toEqual({
sandbox: {
enabled: true,
failIfUnavailable: true,
network: { allowedDomains: ["a.test"] },
},
});
});
test("setAgentOptions sends a removal to the provider like any change, as the default it falls back to", async () => {
const { manager, client, agentId } = await createOptionsAgent({ service_tier: "fast" });
await manager.setAgentOptions(agentId, { service_tier: null });
expect(client.session?.applied).toEqual([{ service_tier: "default" }]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toBeUndefined();
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
});
test("a removal with no default is sent as null", async () => {
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
await manager.setAgentOptions(agentId, { web_search: null });
expect(client.session?.applied).toEqual([{ web_search: null }]);
expect(manager.getAgent(agentId)?.config.providerOptions).toBeUndefined();
});
test("a value the provider kept through a removal is stored", async () => {
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
client.session!.ended = { web_search: true };
await manager.setAgentOptions(agentId, { web_search: null });
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ web_search: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
});
test("what the provider ended up with is stored together with the patch", async () => {
const { manager, client, agentId } = await createOptionsAgent();
client.session!.ended = { service_tier: "slow" };
await manager.setAgentOptions(agentId, { service_tier: "fast", plan_mode: true });
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
service_tier: "slow",
plan_mode: true,
});
});
test("the difference a session opened with is stored before the agent is published", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const client = new OptionsClient();
client.openOptionsPatch = { service_tier: "slow", web_search: null };
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const published: Array<ProviderOptions | undefined> = [];
manager.subscribe((event) => {
if (event.type === "agent_state") published.push(event.agent.config.providerOptions);
});
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { web_search: true } },
undefined,
{ workspaceId: undefined },
);
expect(agent.config.providerOptions).toEqual({ service_tier: "slow" });
expect(published.length).toBeGreaterThan(0);
expect(published.every((options) => options?.service_tier === "slow")).toBe(true);
});
test("a default edited while the session is opening does not change the opening snapshot", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const client = new OptionsClient();
let tier = "default";
client.effectiveOptions = (own) => ({ service_tier: tier, ...own });
const open = client.createSession.bind(client);
client.createSession = async (config) => {
const session = await open(config);
// The user edits the default in config.json after the provider has launched.
tier = "fast";
return session;
};
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
expect(agent.effectiveOptions).toEqual({ service_tier: "default" });
});
test("an option the session dropped when opening does not come back from a configured default", async () => {
const { manager, agentId } = await createOptionsAgent(undefined, { service_tier: null });
const agent = manager.getAgent(agentId);
expect(agent?.effectiveOptions).toEqual({});
await manager.markAgentUnread(agentId);
expect(toAgentPayload(manager.getAgent(agentId)!).options).toEqual({});
});
test("an option without a control is settable through the patch and readable in the snapshot", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent();
await manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
expect(client.session?.applied).toEqual([{ sandbox_mode: "read-only" }]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ sandbox_mode: "read-only" });
expect(toAgentPayload(agent!).options).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
await manager.flush();
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
sandbox_mode: "read-only",
});
});
test("concurrent patches both land: each is applied to the options as they are when its provider call resolves", async () => {
const { manager, client, agentId } = await createOptionsAgent();
let release!: () => void;
client.session!.applyGate = new Promise<void>((resolve) => {
release = resolve;
});
const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
client.session!.applyGate = null;
await manager.setAgentOptions(agentId, { web_search: true });
release();
await slow;
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
sandbox_mode: "read-only",
web_search: true,
});
});
test("a stored agent that is not loaded still reports its effective options", async () => {
const { manager, agentId } = await createOptionsAgent({ web_search: true });
await manager.closeAgent(agentId);
const states: ManagedAgent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === agentId) states.push(event.agent);
},
{ replayState: false },
);
await manager.markAgentUnread(agentId);
expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
});
test("editing a provider default does not change the options of a loaded agent", async () => {
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
expect(manager.getAgent(agentId)?.effectiveOptions).toEqual({
service_tier: "default",
web_search: true,
});
// The user edits the default in config.json while the agent is running.
client.effectiveOptions = (own) => ({ service_tier: "fast", ...own });
await manager.markAgentUnread(agentId);
const agent = manager.getAgent(agentId);
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
});
test("a provider that rejects an option leaves the stored options unchanged", async () => {
const { manager, client, agentId } = await createOptionsAgent({ web_search: false });
client.session!.rejectWith = new Error("fast mode unavailable");
await expect(manager.setAgentOptions(agentId, { web_search: true })).rejects.toThrow(
"fast mode unavailable",
);
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ web_search: false });
});
test("a provider whose session cannot apply options rejects a patch and stores nothing", async () => {
// Like a provider with no live option support: the session has no `applyOptions`.
class NoOptionsSession extends TestAgentSession {
constructor(config: AgentSessionConfig) {
super(config);
Object.defineProperty(this, "applyOptions", { value: undefined });
}
}
class NoOptionsClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new NoOptionsSession(config);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-options-"));
const manager = new AgentManager({
clients: { codex: new NoOptionsClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const agent = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false } },
undefined,
{ workspaceId: undefined },
);
await expect(manager.setAgentOptions(agent.id, { web_search: true })).rejects.toThrow(
/web_search.*cannot change on a running session/,
);
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({ web_search: false });
});
test("agent.create.before hook featureValues fold into providerOptions", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OptionsClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
pluginLifecycle: {
emit: () => {},
before: async (_name, request) => ({
...request,
config: { ...request.config, featureValues: { web_search: true } },
}),
},
});
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ web_search: true });
expect(manager.getAgent(created.id)?.config).not.toHaveProperty("featureValues");
});
test("agent.create.before hook featureValues edit overrides an option the create already set", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OptionsClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
pluginLifecycle: {
emit: () => {},
// A plugin written against the previous SDK: flips one value, leaves the rest.
before: async (name, request) =>
name === "agent.create.before"
? {
...request,
config: {
...request.config,
featureValues: { ...request.config.featureValues, web_search: true },
},
}
: request,
},
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
web_search: true,
plan_mode: true,
});
});
test("agent.create.before hook leaving featureValues untouched keeps providerOptions edits", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OptionsClient();
const manager = new AgentManager({
clients: { codex: client },
registry: storage,
logger,
pluginLifecycle: {
emit: () => {},
before: async (name, request) =>
name === "agent.create.before"
? {
...request,
config: {
...request.config,
providerOptions: { ...request.config.providerOptions, plan_mode: false },
},
}
: request,
},
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
web_search: false,
plan_mode: false,
});
});
describe.each([
{
name: "deletes a key",
edit: (values: Record<string, unknown> | undefined) => {
const { web_search: _removed, ...rest } = values ?? {};
return { featureValues: rest };
},
expected: { plan_mode: true },
},
{
name: "empties featureValues",
edit: () => ({ featureValues: {} }),
expected: {},
},
{
name: "drops featureValues",
edit: () => ({ featureValues: undefined }),
expected: {},
},
])("agent.create.before hook that $name", ({ edit, expected }) => {
test("removes those options from the new agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const manager = new AgentManager({
clients: { codex: new OptionsClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
pluginLifecycle: {
emit: () => {},
before: async (name, request) =>
name === "agent.create.before"
? {
...request,
config: { ...request.config, ...edit(request.config.featureValues) },
}
: request,
},
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { web_search: true, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual(expected);
});
});
});
+224 -31
View File
@@ -1,4 +1,5 @@
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";
@@ -33,7 +34,7 @@ import {
type AgentCreateSessionOptions,
type AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
@@ -59,7 +60,16 @@ import {
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { toStoredAgentRecord } from "./agent-projections.js";
import {
applyProviderOptionsPatch,
exposeFeatureValuesToHook,
convertLegacyOptions,
resolveAdapter,
foldHookFeatureValues,
type ProviderOptionsPatch,
} from "./option-controls.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
import {
@@ -210,9 +220,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;
}
@@ -428,7 +435,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<
@@ -1243,11 +1252,17 @@ export class AgentManager {
}
}
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listDraftControls(
config: AgentSessionConfig,
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
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) {
@@ -1256,13 +1271,18 @@ export class AgentManager {
);
}
if (client.listFeatures) {
return await client.listFeatures(normalizedConfig);
if (client.listControls) {
return { controls: await client.listControls(normalizedConfig), options };
}
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();
@@ -1360,12 +1380,23 @@ export class AgentManager {
): 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,
});
config = { ...request.config, internal: config.internal };
// 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 };
}
await this.deleteAgentState(resolvedAgentId);
@@ -1395,9 +1426,11 @@ export class AgentManager {
);
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,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
@@ -1520,6 +1553,7 @@ export class AgentManager {
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
const session = await client.resumeSession(
handle,
providerLaunchConfig,
@@ -1528,6 +1562,7 @@ export class AgentManager {
);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, {
launchedOptions,
...options,
launchEnv,
persistence: handle,
@@ -1583,6 +1618,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,
@@ -1601,6 +1637,7 @@ export class AgentManager {
handedToRegistration = true;
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
launchedOptions,
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
@@ -1706,6 +1743,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);
@@ -1724,6 +1762,7 @@ export class AgentManager {
// Preserve existing labels and timeline during reload.
handedToRegistration = true;
return this.registerSession(session, storedConfig, agentId, {
launchedOptions,
labels: existing.labels,
workspaceId: existing.workspaceId,
owner: existing.owner,
@@ -2161,7 +2200,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(),
@@ -2254,20 +2297,109 @@ export class AgentManager {
return notice;
}
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
/**
* 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, over the options as they are at that moment, so concurrent
* writes to one agent resolve by arrival order.
*/
async setAgentOptions(agentId: string, requested: ProviderOptionsPatch): Promise<void> {
const agent = this.requireAgent(agentId);
if (!agent.session.setFeature) {
throw new Error("Agent session does not support setting features");
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);
}
await agent.session.setFeature(featureId, value);
await this.drainSessionEvents(agentId);
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
this.updateEffectiveOptions({ agent, sent, reported: ended });
this.touchUpdatedAt(agent);
this.emitState(agent);
}
/** 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 ?? {};
}
private effectiveOptionsFor(
provider: AgentProvider,
own: ProviderOptions | undefined,
): ProviderOptions {
const client = this.clients.get(provider);
return client ? this.launchOptionsFor({ client, own }) : (own ?? {});
}
async setTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim();
@@ -3780,9 +3912,9 @@ export class AgentManager {
private async registerSession(
session: AgentSession,
config: AgentSessionConfig,
openedConfig: AgentSessionConfig,
agentId: string,
options?: {
options: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
@@ -3800,6 +3932,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;
@@ -3811,6 +3949,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`);
@@ -3832,6 +3971,10 @@ export class AgentManager {
resolvedAgentId,
session,
config,
effectiveOptions: this.openedEffectiveOptions({
session,
launchedOptions: options.launchedOptions,
}),
now,
durableTimelineHasRows,
options,
@@ -3976,6 +4119,7 @@ export class AgentManager {
resolvedAgentId: string;
session: AgentSession;
config: AgentSessionConfig;
effectiveOptions: ProviderOptions;
now: Date;
durableTimelineHasRows: boolean;
options:
@@ -3995,7 +4139,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,
@@ -4006,6 +4158,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,
@@ -4311,7 +4465,7 @@ export class AgentManager {
agent.pendingPermissions.clear();
}
this.syncFeaturesFromSession(agent);
this.syncControls(agent);
await this.refreshRuntimeInfo(agent, options);
}
@@ -4657,6 +4811,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;
@@ -4708,6 +4874,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":
@@ -5183,7 +5352,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
this.syncFeaturesFromSession(agent);
this.syncControls(agent);
this.logger.trace(
{
@@ -5205,9 +5374,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;
}
}
@@ -5454,7 +5623,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) {
@@ -5526,6 +5695,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,
@@ -10,7 +10,7 @@ import {
} from "./agent-projections.js";
import type { AgentSession } from "./agent-sdk-types.js";
import type {
AgentFeature,
AgentOptionControl,
ImportableProviderSession,
AgentPermissionRequest,
AgentPersistenceHandle,
@@ -128,16 +128,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({
@@ -474,13 +464,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 "./option-controls.js";
import type { JsonValue } from "../json-utils.js";
import {
isStoredAgentProviderAvailable,
@@ -97,7 +99,7 @@ export function toStoredAgentRecord(
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,
@@ -143,7 +145,12 @@ 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,
@@ -375,12 +382,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)) {
@@ -456,8 +457,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: ProviderOptions): 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;
@@ -610,7 +618,6 @@ export interface AgentSessionConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
title?: string | null;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
@@ -679,7 +686,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[];
@@ -714,7 +728,20 @@ export interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
setFeature?(featureId: string, value: unknown): Promise<void>;
/**
* 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>;
@@ -764,11 +791,17 @@ export interface ResolveAgentDefaultModeInput {
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>,
@@ -797,7 +830,9 @@ export interface AgentClient {
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
listControls?(config: AgentSessionConfig): Promise<AgentOptionControl[]>;
/** 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 { createTestLogger } from "../../test-utils/test-logger.js";
@@ -44,9 +44,6 @@ function buildManagedAgentConfig(
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
config.featureValues = configOverrides.featureValues;
}
return config;
}
@@ -132,6 +129,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;
@@ -195,62 +200,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, logger);
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, logger);
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, logger);
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");
@@ -4,17 +4,19 @@ import { z } from "zod";
import type { Logger } from "pino";
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, 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 +44,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(),
@@ -68,6 +70,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(),
@@ -84,14 +88,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(),
@@ -22,6 +22,8 @@ import { buildAgentPrompt } from "../prompt-attachments.js";
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
import { resolveCreateAgentIntent } from "./intent.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { foldFeatureValues, type LegacyFeatureValues } from "../option-controls.js";
export interface CreateAgentCommandDependencies {
finishSubscriptions?: FinishSubscriptions;
@@ -47,7 +49,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;
initialPrompt?: string;
source?: AgentPromptSource;
@@ -70,7 +73,7 @@ export interface CreateAgentFromMcpInput {
cwd?: string;
workspaceId?: string;
thinking?: string;
features?: Record<string, unknown>;
providerOptions?: ProviderOptions;
labels?: Record<string, string>;
mode?: string;
unattended?: boolean;
@@ -218,19 +221,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;
@@ -323,7 +326,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 } : {}),
@@ -361,18 +364,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;
@@ -380,14 +388,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,
@@ -400,8 +408,8 @@ function buildMcpSessionConfig(params: {
if (provisionalTitle) {
config.title = provisionalTitle;
}
if (featureValues) {
config.featureValues = featureValues;
if (providerOptions) {
config.providerOptions = providerOptions;
}
return config;
}
@@ -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),
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
cancelAgentRun: vi.fn(),
getPendingPermissions: vi.fn(),
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
listDraftFeatures: vi.fn(),
listDraftControls: vi.fn(),
};
}
@@ -472,9 +472,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 };
});
}
@@ -1805,7 +1805,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({
@@ -1834,7 +1834,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" },
@@ -3039,12 +3039,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({
@@ -3072,7 +3072,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
}),
undefined,
{
@@ -3273,7 +3273,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,
@@ -3307,12 +3307,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,
@@ -3332,7 +3332,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" },
);
@@ -3360,7 +3360,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({
@@ -3384,7 +3384,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),
@@ -4717,7 +4717,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" },
@@ -4742,12 +4742,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,
@@ -5065,7 +5065,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) =>
@@ -5093,7 +5093,7 @@ describe("create_schedule MCP tool", () => {
cwd: REPO_CWD,
modeId: "build",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
});
});
@@ -5867,7 +5867,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.",
},
];
@@ -5882,7 +5882,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 () => {
@@ -5934,14 +5948,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({
@@ -5974,13 +5984,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",
@@ -5995,7 +6005,7 @@ describe("provider MCP tools", () => {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
value: true,
},
],
});
@@ -6211,14 +6221,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,304 @@
import { isDeepStrictEqual } from "node:util";
import type {
AgentFeature,
AgentOptionControl,
AgentOptionSelectControl,
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;
}
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 Error("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` 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.
*/
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 base =
ownOptions ??
(stored?.provider === rest.provider ? convert(provider, stored?.options) : undefined);
// The form sends no `featureValues` when no control is set, which is still an edit of a
// profile that may hold other options.
if (!featureValues && !base) return rest;
return {
...rest,
options: applyProviderOptionsPatch({ options: base, patch: featureValues ?? {} }),
};
}
/**
* 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;
}
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
const value = options[control.key];
if (typeof value === "string") {
const choice = control.choices.find((candidate) => candidate.value === value);
if (choice) return choice.value;
}
return control.defaultValue ?? null;
}
function describe(control: { description?: string; tooltip?: string; icon?: string }): {
description?: string;
tooltip?: string;
icon?: string;
} {
return {
...(control.description !== undefined ? { description: control.description } : {}),
...(control.tooltip !== undefined ? { tooltip: control.tooltip } : {}),
...(control.icon !== undefined ? { icon: control.icon } : {}),
};
}
function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature {
if (control.type === "toggle") {
const value = effective[control.key];
return {
type: "toggle",
id: control.key,
label: control.label,
...describe(control),
value: typeof value === "boolean" ? value : (control.defaultValue ?? false),
};
}
return {
type: "select",
id: control.key,
label: control.label,
...describe(control),
...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}),
value: selectedChoice(control, effective),
options: control.choices.map((choice) => ({
id: choice.value,
label: choice.label,
...(choice.description ? { description: choice.description } : {}),
...(choice.value === control.defaultValue ? { isDefault: true } : {}),
})),
};
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Apps that predate controls render `features`; build them from controls + effective options.
*/
export function featuresFromControls(input: {
controls: readonly AgentOptionControl[] | undefined;
options: ProviderOptions | undefined;
}): AgentFeature[] {
const { controls, options } = input;
const effective = options ?? {};
return (controls ?? []).map((control) => featureFromControl(control, effective));
}
/** A recorded feature's value is not carried over: controls hold no state, only `options` do. */
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
const descriptor = {
key: feature.id,
label: feature.label,
...describe(feature),
};
if (feature.type === "toggle") {
return { type: "toggle", ...descriptor };
}
const defaultChoice = feature.options.find((option) => option.isDefault);
return {
type: "select",
...descriptor,
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
...(defaultChoice ? { defaultValue: defaultChoice.id } : {}),
choices: feature.options.map((option) => ({
value: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
})),
};
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Agent records written before controls stored `features`; their descriptors become controls.
*/
export function controlsFromFeatures(
features: readonly AgentFeature[] | undefined,
): AgentOptionControl[] {
return (features ?? []).map(controlFromFeature);
}
@@ -58,6 +58,10 @@ interface ProviderHarnessOptions {
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
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;
@@ -970,7 +974,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 +996,174 @@ 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();
});
describe("settings", () => {
// 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: "direct", loud: false };
let openedSessionId = "";
const 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: [],
settings: settings(),
},
});
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 loud = input.changes.settings?.loud;
if (typeof loud === "boolean") state.loud = loud;
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("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("settings the plugin opened with that nobody set are in the open patch", 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);
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
expect(
Object.keys((configure as { changes: { settings: object } }).changes.settings),
).toEqual(["loud"]);
await session.close();
});
test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => {
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
const configureBefore = lastConfigure(harness.inputs);
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
/launch_key.*cannot change on a running session/,
);
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
// The value it opened with is not a change, so a whole profile can be applied.
await session.applyOptions?.({ loud: true, launch_key: "original" });
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
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 +1676,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 { assertChangesAreLive } from "./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";
@@ -1133,6 +1135,7 @@ class PluginAgentClient implements AgentClient {
});
},
bind,
config.settings,
);
bind(bridge);
return session;
@@ -1201,6 +1204,11 @@ class PluginAgentSession implements AgentSession {
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the settings it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
private knownSettings: 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: {
@@ -1221,8 +1229,12 @@ class PluginAgentSession implements AgentSession {
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
private readonly givenSettings: 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.openOptionsPatch = this.settingDifferences(givenSettings);
this.knownSettings = this.currentSettings();
for (const event of bridge.history) this.accept(event, false);
// Opening notices arrive before the manager subscribes to the session.
// Commit them on registration and replay the same timestamps from history.
@@ -1246,14 +1258,27 @@ 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 })),
},
/** The plugin's `settings` are its option controls; each setting id is an option key. */
get controls(): AgentOptionControl[] {
return this.bridge.config.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,
})),
},
);
}
@@ -1410,11 +1435,38 @@ class PluginAgentSession implements AgentSession {
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
async setFeature(featureId: string, value: unknown): Promise<void> {
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
this.assertReplayComplete();
await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
const before = this.currentSettings();
// A key the plugin does not report as a setting is one it only reads when the session opens;
// a value equal to the one it opened with is not a change.
assertChangesAreLive({
provider: this.provider,
patch: options,
current: this.givenSettings,
live: Object.keys(before),
});
const settings: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
if (!(id in before)) continue;
settings[id] = toJsonValue(value, `setting '${id}'`);
}
if (Object.keys(settings).length === 0) return {};
this.applyingOptions = true;
try {
await this.bridge.configure({ 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.reportSettingChanges()) this.publish(event);
throw error;
} finally {
this.applyingOptions = false;
}
const ended = this.settingDifferences({ ...before, ...settings });
this.knownSettings = this.currentSettings();
return ended;
}
async revertConversation(input: { messageId: string }): Promise<void> {
@@ -1596,10 +1648,36 @@ class PluginAgentSession implements AgentSession {
};
}
private currentSettings(): Record<string, JsonValue> {
return Object.fromEntries(
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
);
}
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
private settingDifferences(
known: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentSettings())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
}
return patch;
}
private reportSettingChanges(): AgentStreamEvent[] {
if (this.applyingOptions) return [];
const patch = this.settingDifferences(this.knownSettings);
if (Object.keys(patch).length === 0) return [];
this.knownSettings = this.currentSettings();
return [{ type: "options_changed", provider: this.provider, patch }];
}
private translateConfig(
event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] {
return [
...this.reportSettingChanges(),
{
type: "mode_changed",
provider: this.provider,
@@ -1852,7 +1930,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,
AgentOptionControl,
AgentModelDefinition,
AgentMode,
AgentSessionConfig,
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
return true;
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
mockState.cursorListFeaturesConfigs.push(config);
return [
{
type: "select",
id: "fast",
key: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
defaultValue: "false",
choices: [{ value: "false", label: "Off" }],
},
];
}
@@ -791,6 +791,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 +850,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,17 +864,17 @@ 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",
key: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
defaultValue: "false",
choices: [{ value: "false", label: "Off" }],
},
]);
expect(mockState.cursorListFeaturesConfigs).toEqual([
@@ -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),
@@ -473,7 +477,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),
@@ -487,14 +491,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,
@@ -509,7 +519,7 @@ function wrapClientProvider(
{
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
providerOptions: withDefaults(config.providerOptions),
},
launchContext,
{ ...options, configuredModelIds },
@@ -526,14 +536,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, {
@@ -548,7 +561,7 @@ function wrapClientProvider(
config: {
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
providerOptions: withDefaults(config.providerOptions),
},
env,
signal,
@@ -557,16 +570,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) => {
@@ -575,10 +590,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,
@@ -198,7 +198,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 {
@@ -208,7 +208,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",
@@ -300,19 +300,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,
@@ -1498,7 +1499,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 }> = [];
@@ -1530,7 +1531,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[] = [];
@@ -1583,11 +1584,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",
@@ -1605,7 +1603,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 } }));
@@ -1765,35 +1763,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" },
],
},
]);
@@ -1816,15 +1797,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" }),
]);
});
@@ -1838,26 +1813,182 @@ 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("overlapping applies that both fail still report what the agent moved", async () => {
const { session, optionEvents } = await openSession({ scenario: { onWrite: "slow-reject" } });
const first = session.applyOptions({ agent: "Probe Agent" });
const second = session.applyOptions({ agent: null });
await expect(first).rejects.toThrow(/write refused/);
await expect(second).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 nothing when that took", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
await expect(session.applyOptions({ agent: null })).resolves.toEqual({});
expect(optionEvents).toEqual([]);
});
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(
@@ -2084,14 +2215,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 },
});
});
@@ -2106,12 +2237,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", () => {
@@ -2125,7 +2256,7 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({
provider: "generic-acp",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
modeId: "bypassPermissions",
@@ -2134,7 +2265,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", () => {
@@ -2150,7 +2281,7 @@ describe("ACPAgentClient config features", () => {
config: {
provider: "generic-acp",
cwd: "/tmp/acp-features",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
availableModes: [],
}),
@@ -2165,22 +2296,17 @@ 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,
}),
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
]);
});
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 {
@@ -2206,23 +2332,18 @@ describe("ACPAgentClient config features", () => {
});
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: "toggle", key: "auto_accept" }),
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 }),
key: "agent",
choices: [
expect.objectContaining({ value: "", label: "Default" }),
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
],
}),
]);
@@ -3997,7 +4118,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 fs from "node:fs/promises";
@@ -65,7 +68,7 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateConfigUnattendedInput,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -120,6 +123,7 @@ import {
} from "../provider-launch-config.js";
import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
import { appendOrReplaceGrowingAssistantMessage, runProviderTurn } from "./provider-runner.js";
import { resolveResumeProcessCwd } from "./resume-process-cwd.js";
import {
buildStringCommandShellInvocation,
createStringCommandShellEnvOverlay,
@@ -165,18 +169,6 @@ function rejectOnProcessFailure(child: ChildProcess, stderrChunks: string[]): Pr
});
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await fs.stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return value != null && typeof value === "object" && !Array.isArray(value);
}
@@ -859,10 +851,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) {
@@ -872,42 +864,42 @@ 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;
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
}
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),
};
}
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 &&
@@ -915,22 +907,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
);
}
@@ -1209,10 +1195,9 @@ export class ACPAgentClient implements AgentClient {
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
if (this.configFeatureOptions.length === 0) {
return [autoAcceptFeature];
return [ACP_AUTO_ACCEPT_CONTROL];
}
this.assertProvider(config);
@@ -1228,8 +1213,8 @@ export class ACPAgentClient implements AgentClient {
probeSessionId = response.sessionId;
const transformed = this.transformSessionResponse(response);
return [
autoAcceptFeature,
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
ACP_AUTO_ACCEPT_CONTROL,
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
];
} finally {
await this.closeProbe(probe, probeSessionId);
@@ -1772,6 +1757,11 @@ 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 applying: Promise<unknown> = Promise.resolve();
private cachedCommands: AgentSlashCommand[] = [];
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
@@ -2065,10 +2055,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),
];
}
@@ -2209,6 +2199,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({
sessionId,
@@ -2223,6 +2214,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId,
label: "mode",
});
this.reportFeatureChanges(before);
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
@@ -2334,6 +2326,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({
sessionId,
@@ -2348,6 +2341,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modelId,
label: "model",
});
this.reportFeatureChanges(before);
this.pushEvent({
type: "model_changed",
provider: this.provider,
@@ -2385,6 +2379,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,
@@ -2399,6 +2394,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: thinkingOptionId,
label: "thought-level",
});
this.reportFeatureChanges(before);
this.pushEvent({
type: "thinking_option_changed",
provider: this.provider,
@@ -2406,14 +2402,103 @@ 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.
*/
/** One apply at a time: each reconciles against the state the one before it left. */
applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
const run = this.applying.then(() => this.applyOptionsNow(options));
this.applying = run.catch(() => undefined);
return run;
}
private async applyOptionsNow(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.
requested[id] = value ?? "";
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (!option || requested[id] !== 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> {
const values: Record<string, string> = {};
for (const featureOption of this.configFeatureOptions) {
const current = findSelectConfigFeatureOption(
this.configOptions,
featureOption,
)?.currentValue;
if (current != null) values[featureOption.id] = current;
}
return values;
}
/** 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;
@@ -2445,14 +2530,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({
@@ -2883,11 +2967,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
// Loading an archived agent's history runs nothing in its working directory, which
// may have been removed with its worktree. session/load still names the original
// directory; only the process starts from the home directory.
private async resolveProcessCwd(): Promise<string> {
if (this.resumePurpose !== "history" || (await isDirectory(this.config.cwd))) {
return this.config.cwd;
}
return homedir();
private resolveProcessCwd(): Promise<string> {
return resolveResumeProcessCwd(this.config.cwd, this.resumePurpose);
}
private async spawnProcess(): Promise<SpawnedACPProcess> {
@@ -3000,7 +3081,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 = resolveACPModeSelection({
@@ -3032,13 +3117,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;
@@ -3049,6 +3134,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);
}
/**
@@ -3262,6 +3350,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
const before = this.featureValues();
this.configOptions = this.transformConfigOptions(update.configOptions);
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const nextMode = modeInfo.currentModeId;
@@ -3272,6 +3361,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[]>;
@@ -953,39 +958,39 @@ 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([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
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([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
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([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
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([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "openrouter/anthropic/claude-opus-4-8",
@@ -993,7 +998,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]);
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
@@ -1001,7 +1006,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]);
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-4-6",
@@ -1009,6 +1014,25 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]);
});
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 () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -1020,7 +1044,7 @@ describe("ClaudeAgentSession features", () => {
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
await expect(
@@ -1492,7 +1516,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 });
@@ -1501,6 +1525,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,7 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -1752,12 +1754,8 @@ 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<AgentOptionControl[]> {
return buildClaudeControls(this.assertConfig(config).model);
}
async listImportableSessions(
@@ -2380,6 +2378,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
@@ -2397,6 +2397,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;
@@ -2440,17 +2441,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> {
@@ -2748,8 +2753,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),
@@ -2801,31 +2818,41 @@ 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. Only
// this call's keys are applied, over the options as they are now: another patch may have
// landed while the runtime was answering.
this.config.providerOptions = applyProviderOptionsPatch({
options: this.config.providerOptions,
patch: options,
});
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -3517,7 +3544,15 @@ class ClaudeAgentSession implements AgentSession {
this.persistence = null;
const input = createAsyncMessageInput<SDKUserMessage>();
const options = await this.buildOptions(launchMode);
// 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);
} catch (error) {
this.queryStarting = false;
throw error;
}
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
this.input = input;
this.runtimeSafety.reset();
@@ -3535,6 +3570,7 @@ class ClaudeAgentSession implements AgentSession {
},
},
);
this.queryStarting = false;
const fastMode = this.resolveFastModeSetting();
if (fastMode !== null) {
await this.query.applyFlagSettings({ fastMode });
@@ -3654,10 +3690,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);
@@ -3776,7 +3811,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,19 @@
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 { convertLegacyOptions, featuresFromControls } 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 +49,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 +153,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 +170,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 +207,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 +231,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 +245,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 +257,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 +298,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 +311,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 +327,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 +391,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 +422,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 +436,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 +458,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 +495,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 +503,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 +516,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 +559,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 +596,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 +963,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 +977,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({
@@ -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 },
@@ -6188,7 +6188,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));
@@ -6258,7 +6258,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));
@@ -6304,7 +6304,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", {
@@ -6337,7 +6337,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));
@@ -6379,7 +6379,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", {
@@ -6433,7 +6433,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", {
@@ -6483,7 +6483,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", {
@@ -7107,7 +7107,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));
@@ -7138,9 +7138,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.
@@ -7161,7 +7162,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));
@@ -7190,11 +7191,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."),
@@ -7203,7 +7205,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,11 +1,13 @@
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 {
getAgentStreamEventTurnId,
type AgentPermissionAction,
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentResumeSessionOptions,
type AgentMode,
@@ -75,7 +77,7 @@ import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import {
buildCodexFeatures,
buildCodexControls,
CodexServiceTierSchema,
readCodexServiceTier,
type CodexServiceTier,
@@ -3429,7 +3431,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;
@@ -3453,11 +3455,14 @@ 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 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.
@@ -3568,10 +3573,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;
@@ -3586,11 +3589,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(),
});
}
@@ -3650,7 +3651,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();
@@ -3863,24 +3864,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 {
@@ -3945,7 +3956,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);
}
@@ -4645,9 +4662,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> {
@@ -4659,35 +4694,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[] {
@@ -5340,7 +5393,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;
return model;
}
@@ -5407,7 +5459,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;
}
@@ -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,43 +163,29 @@ describe("CursorACPAgentClient model discovery", () => {
});
await expect(
client.listFeatures({
client.listControls({
provider: "acp",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "toggle",
id: "auto_accept",
key: "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,
key: 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,
},
choices: [
{ value: "false", label: "Off" },
{ value: "true", label: "Fast" },
],
},
]);
@@ -228,7 +214,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 +265,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 +283,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 +307,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 +322,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,7 @@ import stripAnsi from "strip-ansi";
import {
type AgentCapabilityFlags,
type AgentClient,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -66,9 +67,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 +754,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 +812,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 +827,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 +1110,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;
@@ -2210,7 +2248,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;
@@ -2227,7 +2265,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> {
@@ -2295,7 +2348,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);
@@ -2530,7 +2583,7 @@ export class OmpAgentClient implements AgentClient {
}
}
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return [];
}
@@ -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,22 @@ 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({
const controls = await client.listControls({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
});
const legacyFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
providerOptions: { auto_accept: true },
});
expect(enabledFeatures).toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
expect(controls).toEqual([
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
]);
expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
});
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
@@ -151,7 +140,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");
@@ -170,7 +159,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: "full-access",
featureValues: undefined,
providerOptions: undefined,
parent: null,
unattended: false,
availableModes: [
@@ -178,7 +167,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 +180,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
modeId: "bypassPermissions",
@@ -203,7 +192,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 +202,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: null,
unattended: true,
availableModes: [
@@ -221,7 +210,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 +220,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "opencode",
modeId: "paseo-custom",
@@ -244,7 +233,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 +243,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: "base",
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "opencode",
modeId: "orchestrator",
@@ -267,7 +256,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 +272,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 +290,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 +369,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));
@@ -5274,7 +5274,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,7 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
@@ -232,15 +234,15 @@ 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,
function withOpenCodeAutoAccept(
providerOptions: ProviderOptions | undefined,
enabled: boolean,
): Record<string, unknown> {
): ProviderOptions {
return {
...featureValues,
...providerOptions,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
}
@@ -259,25 +261,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 +287,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 [
@@ -682,10 +676,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),
};
}
@@ -1679,8 +1670,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.assertConfig(config);
return [OPENCODE_AUTO_ACCEPT_CONTROL];
}
async listImportableSessions(
@@ -3565,8 +3557,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> {
@@ -4956,7 +4948,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;
}
@@ -4964,17 +4964,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,11 +100,11 @@ test.each([
});
try {
await writeFile(script, initialSource);
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
expect((await client.listControls({ provider: "opencode", cwd: root }))[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(
expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto-accept",
);
} finally {
@@ -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,119 @@ 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("overlapping auto_accept applies do not undo each other when one fails", async () => {
const harness = new V2Harness();
const permissions = [
{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] },
];
harness.api.permission.list = async () => permissions;
let failFirst!: () => void;
let calls = 0;
harness.api.permission.reply = async () => {
calls += 1;
if (calls > 1) return;
await new Promise<void>((_, reject) => {
failFirst = () => reject(new Error("upstream refused"));
});
};
const client = new OpenCodeV2AgentClient({
logger: createTestLogger(),
runtime: harness.runtime,
});
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try {
const first = session.applyOptions?.({ auto_accept: true });
const second = session.applyOptions?.({ auto_accept: true });
await expect.poll(() => calls).toBe(1);
failFirst();
await expect(first).rejects.toThrow("upstream refused");
await expect(second).resolves.toBeUndefined();
// The accepted second apply is in force: the next permission is approved, not asked.
const next = { id: "next", sessionID: "session", action: "shell", resources: ["ls"] };
permissions.push(next);
harness.push({ id: "asked", created: 2, type: "permission.asked", data: next });
await expect.poll(() => calls).toBeGreaterThan(2);
const pendingIds = () => session.getPendingPermissions().map(({ id }) => id);
expect(pendingIds()).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,
AgentOptionControl,
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(): Promise<AgentOptionControl[]> {
return controls;
}
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", () => {
@@ -49,17 +49,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(
@@ -93,7 +93,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 { SessionChildren } from "./children.js";
import { SessionTurns } from "./turns.js";
import { V2Timeline } from "./timeline.js";
@@ -31,7 +35,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";
@@ -118,8 +122,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;
@@ -358,14 +362,39 @@ 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" });
/** Overlapping applies run one after another, so a failed one restores exactly what it changed. */
private applying: Promise<unknown> = Promise.resolve();
applyOptions(options: ProviderOptions): Promise<void> {
const run = this.applying.then(() => this.applyOptionsNow(options));
this.applying = run.catch(() => undefined);
return run;
}
private async applyOptionsNow(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();
@@ -5,11 +5,13 @@ import {
mkdirSync,
mkdtempSync,
openSync,
readFileSync,
readSync,
realpathSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { homedir, tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { setImmediate as waitForImmediate } from "node:timers/promises";
@@ -23,6 +25,7 @@ import {
PiRpcAgentSession,
transformPiModels,
} from "./agent.js";
import { PiCliRuntime } from "./cli-runtime.js";
import { FakePi } from "./test-utils/fake-pi.js";
import { createPiExtensionHost } from "./extensions/index.js";
import { PiExtensionHost } from "./extensions/host.js";
@@ -2719,6 +2722,7 @@ describe("PiRpcAgentClient", () => {
const root = mkdtempSync(path.join(tmpdir(), "paseo-pi-import-config-"));
const cwd = path.join(root, "workspace");
const sessionsDir = path.join(root, "sessions");
mkdirSync(cwd, { recursive: true });
mkdirSync(sessionsDir, { recursive: true });
const sessionFile = path.join(sessionsDir, "20260103_session.jsonl");
writeFileSync(
@@ -2957,12 +2961,12 @@ 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" })),
client.listControls(createConfig({ model: "openrouter/test/model" })),
).resolves.toEqual([]);
expect(pi.recordedLaunches).toHaveLength(0);
@@ -3532,6 +3536,320 @@ describe("PiRpcAgentClient", () => {
});
});
describe("PiRpcAgentClient history resume", () => {
const BUILTIN_MCP_COMMAND = {
name: "mcp",
source: "extension",
sourceInfo: { path: "builtin:mcp" },
} as const;
const PASEO_MCP_SERVERS = {
paseo: { type: "http", url: "http://127.0.0.1:6767/mcp/agents" },
} as const;
interface ArchivedPiSession {
handle: {
provider: string;
sessionId: string;
nativeHandle: string;
metadata: { cwd: string; model?: string };
};
sessionFile: string;
header: Record<string, unknown>;
body: string;
originalBytes: Buffer;
}
function createDeletedFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-deleted-cwd-"));
rmSync(folder, { recursive: true, force: true });
return folder;
}
function createFileWhereFolderWas(): string {
const folder = createDeletedFolder();
writeFileSync(folder, "not a directory");
onTestFinished(() => rmSync(folder, { force: true }));
return folder;
}
function createExistingFolder(): string {
const folder = mkdtempSync(path.join(tmpdir(), "paseo-pi-existing-cwd-"));
onTestFinished(() => rmSync(folder, { recursive: true, force: true }));
return folder;
}
function writeArchivedSession(cwd: string, metadata: { model?: string } = {}): ArchivedPiSession {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-archived-session-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const sessionFile = path.join(dir, "2026-01-01_pi-session-1.jsonl");
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd,
};
const body = [
JSON.stringify({
type: "message",
id: "entry-1",
parentId: null,
message: { role: "user", content: "Où est le café ☕ ?" },
}),
JSON.stringify({
type: "message",
id: "entry-2",
parentId: "entry-1",
message: { role: "assistant", content: [{ type: "text", text: "Dans la cuisine." }] },
}),
]
.map((line) => `${line}\n`)
.join("");
writeFileSync(sessionFile, `${JSON.stringify(header)}\n${body}`);
return {
handle: {
provider: "pi",
sessionId: "pi-session-1",
nativeHandle: sessionFile,
metadata: { cwd, ...metadata },
},
sessionFile,
header,
body,
originalBytes: readFileSync(sessionFile),
};
}
function readSessionCopy(sessionFile: string): { header: unknown; body: string } {
const content = readUtf8File(sessionFile);
const headerEnd = content.indexOf("\n");
return {
header: JSON.parse(content.slice(0, headerEnd)),
body: content.slice(headerEnd + 1),
};
}
function resumeArchived(
client: PiRpcAgentClient,
archived: ArchivedPiSession,
purpose: "history" | "interactive",
overrides: Partial<AgentSessionConfig> = {},
): Promise<AgentSession> {
return client.resumeSession(archived.handle, overrides, undefined, { purpose });
}
function writeFakePiProcess(): {
command: [string, ...string[]];
readLaunches: () => Array<{ cwd: string; session: string | null }>;
} {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-pi-process-"));
onTestFinished(() => rmSync(dir, { recursive: true, force: true }));
const scriptPath = path.join(dir, "fake-pi.cjs");
const launchesPath = path.join(dir, "launches.jsonl");
writeFileSync(
scriptPath,
`
const fs = require("node:fs");
const readline = require("node:readline");
const [launchesPath, ...piArgs] = process.argv.slice(2);
const sessionFlag = piArgs.indexOf("--session");
const sessionFile = sessionFlag === -1 ? null : piArgs[sessionFlag + 1];
fs.appendFileSync(launchesPath, JSON.stringify({ cwd: process.cwd(), session: sessionFile }) + "\\n");
if (sessionFile) {
const header = JSON.parse(fs.readFileSync(sessionFile, "utf8").split("\\n", 1)[0]);
if (header.cwd && !fs.existsSync(header.cwd)) {
fs.writeSync(
2,
"Stored session working directory does not exist: " + header.cwd + "\\nSession file: " + sessionFile + "\\nCurrent working directory: " + process.cwd() + "\\n",
);
process.exit(1);
}
}
const models = [
{ provider: "openrouter", id: "a" },
{ provider: "openrouter", id: "b" },
];
const state = {
sessionId: "pi-session-1",
sessionFile,
model: models[0],
thinkingLevel: "medium",
isStreaming: false,
isCompacting: false,
messageCount: 0,
pendingMessageCount: 0,
};
const commands = [${JSON.stringify(BUILTIN_MCP_COMMAND)}];
function handle(request) {
switch (request.type) {
case "get_state":
return state;
case "get_commands":
return { commands };
case "get_available_models":
return { models };
case "set_model":
fs.appendFileSync(
sessionFile,
JSON.stringify({ type: "model_change", id: "model-1", provider: request.provider, modelId: request.modelId }) + "\\n",
);
state.model = models.find((model) => model.id === request.modelId);
return state.model;
default:
return {};
}
}
readline.createInterface({ input: process.stdin }).on("line", (line) => {
const request = JSON.parse(line);
process.stdout.write(
JSON.stringify({ id: request.id, type: "response", command: request.type, success: true, data: handle(request) }) + "\\n",
);
});
`,
);
return {
command: [process.execPath, scriptPath, launchesPath],
readLaunches: () =>
readUtf8File(launchesPath)
.trim()
.split("\n")
.map((line) => JSON.parse(line) as { cwd: string; session: string | null }),
};
}
test.each([
["is gone", createDeletedFolder],
["is now a file", createFileWhereFolderWas],
])(
"a history resume of an agent whose folder %s starts Pi from the home directory on a copy of its session",
async (_folderState, createFolder) => {
const archived = writeArchivedSession(createFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toHaveLength(1);
const launch = pi.recordedLaunches[0]!;
expect(launch.cwd).toBe(homedir());
expect(launch.session).not.toBe(archived.sessionFile);
expect(readSessionCopy(launch.session!)).toEqual({
header: { ...archived.header, cwd: homedir() },
body: archived.body,
});
},
);
test("a history resume of an agent whose folder is gone removes its session copy on close and leaves the original unchanged", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
const sessionCopy = pi.recordedLaunches[0]!.session!;
expect(existsSync(sessionCopy)).toBe(true);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
test("a history resume of an agent whose folder is gone persists the original session file while Pi reports the copy", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
await session.getRuntimeInfo();
expect(pi.latestSession().state.sessionFile).toBe(pi.recordedLaunches[0]!.session);
expect(session.describePersistence()).toMatchObject({
nativeHandle: archived.sessionFile,
metadata: { cwd: storedCwd },
});
});
test("a history resume of an agent whose folder is gone removes its session copy when Pi fails to start", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueSessionSetup((session) => {
session.getStateError = new Error("Pi RPC process exited with code 1 and signal null");
});
await expect(resumeArchived(createClient(pi), archived, "history")).rejects.toThrow(
"Pi RPC process exited with code 1",
);
expect(pi.recordedLaunches).toHaveLength(1);
expect(pi.recordedLaunches[0]!.session).not.toBe(archived.sessionFile);
expect(existsSync(path.dirname(pi.recordedLaunches[0]!.session!))).toBe(false);
});
test("a history resume of an agent whose folder is gone checks MCP support from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder());
const pi = new FakePi();
pi.queueCommands([BUILTIN_MCP_COMMAND]);
const session = await resumeArchived(createClient(pi), archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
expect(pi.recordedLaunches.map((launch) => launch.cwd)).toEqual([homedir(), homedir()]);
expect(session.capabilities.supportsMcpServers).toBe(true);
});
test("an interactive resume of an agent whose folder is gone still fails in its stored folder", async () => {
const storedCwd = createDeletedFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
await expect(resumeArchived(createClient(pi), archived, "interactive")).rejects.toThrow(
`Stored session working directory does not exist: ${storedCwd}`,
);
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder exists keeps the stored folder and session file", async () => {
const storedCwd = createExistingFolder();
const archived = writeArchivedSession(storedCwd);
const pi = new FakePi();
const session = await resumeArchived(createClient(pi), archived, "history");
onTestFinished(() => session.close());
expect(pi.recordedLaunches).toMatchObject([{ cwd: storedCwd, session: archived.sessionFile }]);
});
test("a history resume of an agent whose folder is gone opens its session in a real Pi process started from the home directory", async () => {
const archived = writeArchivedSession(createDeletedFolder(), { model: "openrouter/b" });
const fakePiProcess = writeFakePiProcess();
const logger = pino({ level: "silent" });
const client = new PiRpcAgentClient({
logger,
runtime: new PiCliRuntime({ logger, command: fakePiProcess.command }),
});
const session = await resumeArchived(client, archived, "history", {
mcpServers: PASEO_MCP_SERVERS,
});
onTestFinished(() => session.close());
const launches = fakePiProcess.readLaunches();
const home = realpathSync.native(homedir());
expect(launches.map((launch) => realpathSync.native(launch.cwd))).toEqual([home, home]);
expect(session.capabilities.supportsMcpServers).toBe(true);
const sessionCopy = launches[1]!.session!;
expect(readUtf8File(sessionCopy)).toContain('"type":"model_change"');
expect(session.describePersistence()?.nativeHandle).toBe(archived.sessionFile);
await session.close();
expect(existsSync(path.dirname(sessionCopy))).toBe(false);
expect(readFileSync(archived.sessionFile)).toEqual(archived.originalBytes);
});
});
describe("transformPiModels", () => {
test("normalizes labels that include the upstream provider prefix", () => {
expect(
@@ -2,7 +2,7 @@ import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { join, resolve as resolvePath } from "node:path";
import { basename, join, resolve as resolvePath } from "node:path";
import type { Logger } from "pino";
import stripAnsi from "strip-ansi";
import { z } from "zod";
@@ -10,7 +10,7 @@ import { z } from "zod";
import {
type AgentCapabilityFlags,
type AgentClient,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -22,6 +22,7 @@ import {
type AgentPersistenceHandle,
type AgentPromptInput,
type AgentProvider,
type AgentResumeSessionOptions,
type AgentRunOptions,
type AgentRunResult,
type AgentRuntimeInfo,
@@ -75,8 +76,13 @@ import {
piExtensionRuntimeBridge,
type PiExtensionEventOutput,
} from "./extensions/index.js";
import { resolveResumeProcessCwd } from "../resume-process-cwd.js";
import { revertPiConversation } from "./rewind.js";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
import type { PiRuntime, PiRuntimeSession, PiStartSessionInput } from "./runtime.js";
import type {
PiAgentSessionEvent,
@@ -245,6 +251,7 @@ interface PiRpcAgentSessionOptions {
initialState: PiSessionState;
capabilities: AgentCapabilityFlags;
currentModeId?: string | null;
persistedSessionFile?: string;
cleanup?: () => void;
extensionTimeoutMs?: number;
logger: Logger;
@@ -492,13 +499,14 @@ function buildResumeConfig(
function buildResumeStartInput(input: {
resumeConfig: PiResumeConfig;
processCwd: string;
sessionFile: string;
launchContext: AgentLaunchContext | undefined;
mcpConfigFile: PiTempFile | null;
paseoExtension: PiTempFile | null;
}): PiStartSessionInput {
return {
cwd: input.resumeConfig.cwd,
cwd: input.processCwd,
env: input.launchContext?.env,
session: input.sessionFile,
thinkingOptionId: normalizePiThinkingOption(input.resumeConfig.thinkingOptionId) ?? undefined,
@@ -618,6 +626,19 @@ function createPiMcpConfigFile(
};
}
async function createPiSessionCopy(sessionFile: string, cwd: string): Promise<PiTempFile> {
const dir = mkdtempSync(join(tmpdir(), "paseo-pi-session-"));
const cleanup = () => rmSync(dir, { recursive: true, force: true });
const filePath = join(dir, basename(sessionFile));
try {
await copyPiSessionFileWithCwd({ sourceFile: sessionFile, targetFile: filePath, cwd });
} catch (error) {
cleanup();
throw error;
}
return { path: filePath, cleanup };
}
function createPiPaseoExtensionFile({
systemPrompt,
mcpServers = {},
@@ -1232,6 +1253,7 @@ export class PiRpcAgentSession implements AgentSession {
this.capabilities = options.capabilities;
this.provider = PI_PROVIDER;
this.currentModeId = options.currentModeId ?? null;
this.persistedSessionFile = options.persistedSessionFile;
this.cleanup = options.cleanup;
this.extensionTimeoutMs = options.extensionTimeoutMs ?? DEFAULT_PI_EXTENSION_RESULT_TIMEOUT_MS;
this.logger = options.logger;
@@ -1262,6 +1284,7 @@ export class PiRpcAgentSession implements AgentSession {
private readonly runtimeSession: PiRuntimeSession;
private readonly config: AgentSessionConfig;
private readonly persistedSessionFile?: string;
private readonly cleanup?: () => void;
private readonly extensionTimeoutMs: number;
@@ -1496,7 +1519,7 @@ export class PiRpcAgentSession implements AgentSession {
return {
provider: this.provider,
sessionId: this.state.sessionId,
nativeHandle: this.state.sessionFile,
nativeHandle: this.persistedSessionFile ?? this.state.sessionFile,
metadata: {
cwd: this.config.cwd,
...(this.config.model ? { model: this.config.model } : {}),
@@ -2645,6 +2668,7 @@ export class PiRpcAgentClient implements AgentClient {
handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>,
launchContext?: AgentLaunchContext,
options?: AgentResumeSessionOptions,
): Promise<AgentSession> {
const sessionFile = handle.nativeHandle;
if (!sessionFile) {
@@ -2653,6 +2677,7 @@ export class PiRpcAgentClient implements AgentClient {
const persistenceMetadata = parsePersistenceMetadata(handle.metadata);
const resumeConfig = buildResumeConfig(persistenceMetadata, overrides, this.provider);
const processCwd = await resolveResumeProcessCwd(resumeConfig.cwd, options?.purpose);
const providerOptions = PiProviderOptionsSchema.parse(
resumeConfig.config.providerOptions ?? {},
@@ -2663,7 +2688,7 @@ export class PiRpcAgentClient implements AgentClient {
...launchContext?.env,
};
const mcp = await this.prepareMcpInjection(
resumeConfig.cwd,
processCwd,
resumeConfig.config.mcpServers,
mcpEnv,
runtime,
@@ -2676,20 +2701,29 @@ export class PiRpcAgentClient implements AgentClient {
),
mcpServers: mcp?.kind === "builtin" ? mcp.servers : undefined,
});
const tempFiles = mcpConfigFile ? [mcpConfigFile, paseoExtension] : [paseoExtension];
const cleanupTempFiles = () => {
for (const tempFile of tempFiles) tempFile.cleanup();
};
let sessionCopy: PiTempFile | null = null;
let runtimeSession: PiRuntimeSession;
try {
if (processCwd !== resumeConfig.cwd) {
sessionCopy = await createPiSessionCopy(sessionFile, processCwd);
tempFiles.push(sessionCopy);
}
runtimeSession = await runtime.startSession(
buildResumeStartInput({
resumeConfig,
sessionFile,
processCwd,
sessionFile: sessionCopy?.path ?? sessionFile,
launchContext,
mcpConfigFile,
paseoExtension,
}),
);
} catch (error) {
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
cleanupTempFiles();
throw error;
}
try {
@@ -2702,15 +2736,15 @@ export class PiRpcAgentClient implements AgentClient {
},
initialState,
capabilities: capabilitiesForSession(mcp !== null),
cleanup: combineCleanup([mcpConfigFile?.cleanup, paseoExtension?.cleanup]),
persistedSessionFile: sessionCopy ? sessionFile : undefined,
cleanup: cleanupTempFiles,
extensionTimeoutMs: providerOptions.extensionTimeoutMs,
logger: this.logger,
usagePollScheduler: this.usagePollScheduler,
});
} catch (error) {
await runtimeSession.close().catch(() => undefined);
mcpConfigFile?.cleanup();
paseoExtension?.cleanup();
cleanupTempFiles();
throw error;
}
}
@@ -2794,7 +2828,7 @@ export class PiRpcAgentClient implements AgentClient {
}
}
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return [];
}
@@ -1,9 +1,13 @@
import { mkdtemp, mkdir, utimes, writeFile } from "node:fs/promises";
import { mkdtemp, mkdir, readFile, rm, utimes, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import { expect, onTestFinished, test } from "vitest";
import { listPiImportableSessions, readPiImportSessionConfig } from "./session-descriptor.js";
import {
copyPiSessionFileWithCwd,
listPiImportableSessions,
readPiImportSessionConfig,
} from "./session-descriptor.js";
async function writeSession(root: string, lines: unknown[]): Promise<string> {
const sessionsDir = path.join(root, "sessions", "project");
@@ -216,3 +220,97 @@ test("Pi import config preserves thinking before a later model in large sessions
thinkingOptionId: "low",
});
});
test("a Pi session copy names the new cwd in its header and keeps every later byte", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const header = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: path.join(root, "removed-worktree"),
};
const body = Buffer.from(
Array.from(
{ length: 2_000 },
(_, index) =>
`${JSON.stringify({ type: "message", id: `entry-${index}`, message: { role: "user", content: `café ☕ ${index}` } })}\n`,
).join(""),
);
expect(body.length).toBeGreaterThan(64 * 1024);
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, Buffer.concat([Buffer.from(`${JSON.stringify(header)}\n`), body]));
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
const copy = await readFile(targetFile);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(body)).toBe(true);
});
test("a Pi session copy refuses a file without a session header", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
await writeFile(sourceFile, `${JSON.stringify({ type: "message", id: "entry-1" })}\n`);
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file has no session header: ${sourceFile}`);
});
const COPY_BODY = [
{ type: "message", id: "entry-1", message: { role: "user", content: "café ☕" } },
{ type: "message", id: "entry-2", message: { role: "assistant", content: "Dans la cuisine." } },
]
.map((entry) => `${JSON.stringify(entry)}\n`)
.join("");
const COPY_HEADER = {
type: "session",
version: 3,
id: "pi-session-1",
timestamp: "2026-01-01T00:00:00.000Z",
cwd: "/removed-worktree",
};
async function copySessionContent(content: string): Promise<Buffer> {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "source.jsonl");
const targetFile = path.join(root, "copy.jsonl");
await writeFile(sourceFile, content);
await copyPiSessionFileWithCwd({ sourceFile, targetFile, cwd: "/home/user" });
return readFile(targetFile);
}
test.each([
["a header line longer than 64 KiB", "", { ...COPY_HEADER, padding: "x".repeat(70 * 1024) }],
["a header with an empty cwd", "", { ...COPY_HEADER, cwd: "" }],
["blank lines before the header", "\n\r\n", COPY_HEADER],
])("a Pi session copy accepts %s", async (_headerShape, prefix, header) => {
const copy = await copySessionContent(`${prefix}${JSON.stringify(header)}\n${COPY_BODY}`);
const headerEnd = copy.indexOf(0x0a);
expect(JSON.parse(copy.subarray(0, headerEnd).toString("utf8"))).toEqual({
...header,
cwd: "/home/user",
});
expect(copy.subarray(headerEnd + 1).equals(Buffer.from(COPY_BODY))).toBe(true);
});
test("a Pi session copy reports a missing session file", async () => {
const root = await mkdtemp(path.join(tmpdir(), "paseo-pi-session-copy-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));
const sourceFile = path.join(root, "missing.jsonl");
await expect(
copyPiSessionFileWithCwd({ sourceFile, targetFile: path.join(root, "copy.jsonl"), cwd: "/" }),
).rejects.toThrow(`Pi session file is missing: ${sourceFile}`);
});
@@ -1,7 +1,8 @@
import type { Dirent } from "node:fs";
import { open, readdir, readFile, stat } from "node:fs/promises";
import { createReadStream, createWriteStream, type Dirent } from "node:fs";
import { open, readdir, readFile, stat, writeFile } from "node:fs/promises";
import { homedir } from "node:os";
import path from "node:path";
import { pipeline } from "node:stream/promises";
import type {
ImportableProviderSession,
@@ -18,6 +19,7 @@ const PI_SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
const HEAD_BYTES = 64 * 1024;
const TAIL_BYTES = 256 * 1024;
const FULL_SCAN_LINE_LIMIT = 2_000;
const PI_SESSION_HEADER_SCAN_BYTES = 1024 * 1024;
// Rank all discovered files cheaply, then parse only a bounded recent window.
const IMPORT_CANDIDATE_OVERSCAN = 40;
const IMPORT_CANDIDATE_MIN = 400;
@@ -100,6 +102,59 @@ export async function listPiImportableSessions(
);
}
export async function copyPiSessionFileWithCwd(input: {
sourceFile: string;
targetFile: string;
cwd: string;
}): Promise<void> {
const found = findSessionHeaderLine(
await readSessionFileStart(input.sourceFile, PI_SESSION_HEADER_SCAN_BYTES),
);
if (!found) {
throw new Error(`Pi session file has no session header: ${input.sourceFile}`);
}
await writeFile(input.targetFile, `${JSON.stringify({ ...found.header, cwd: input.cwd })}\n`, {
mode: 0o600,
});
await pipeline(
createReadStream(input.sourceFile, { start: found.bodyStart }),
createWriteStream(input.targetFile, { flags: "a" }),
);
}
function findSessionHeaderLine(
scanned: Buffer,
): { header: Record<string, unknown>; bodyStart: number } | null {
let lineStart = 0;
while (lineStart < scanned.length) {
const newline = scanned.indexOf(0x0a, lineStart);
const bodyStart = newline === -1 ? scanned.length : newline + 1;
const line = scanned.subarray(lineStart, bodyStart).toString("utf8").trim();
if (line) {
const header = parseJsonRecord(line);
return header?.type === "session" ? { header, bodyStart } : null;
}
lineStart = bodyStart;
}
return null;
}
async function readSessionFileStart(filePath: string, maxBytes: number): Promise<Buffer> {
const handle = await open(filePath, "r").catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
throw new Error(`Pi session file is missing: ${filePath}`);
}
throw error;
});
try {
const buffer = Buffer.alloc(maxBytes);
const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
return buffer.subarray(0, bytesRead);
} finally {
await handle.close().catch(() => undefined);
}
}
export async function readPiImportSessionConfig(filePath: string): Promise<PiImportSessionConfig> {
const descriptor = await readPiSessionDescriptor(filePath);
if (!descriptor) return {};
@@ -1,3 +1,5 @@
import { existsSync, readFileSync } from "node:fs";
import type {
PiRuntime,
PiRuntimeLaunch,
@@ -75,6 +77,12 @@ export class FakePi implements PiRuntime {
const session = new FakePiSession(launch, this.removedModels);
session.commands = this.queuedCommands.shift() ?? [];
this.queuedSessionSetups.shift()?.(session);
const missingSessionCwd = launch.noSession ? null : readMissingSessionCwd(launch.session);
if (missingSessionCwd !== null) {
session.getStateError = new Error(
`Pi RPC process exited with code 1 and signal null\nStored session working directory does not exist: ${missingSessionCwd}\nSession file: ${launch.session}\nCurrent working directory: ${launch.cwd}`,
);
}
this.sessions.push(session);
return session;
}
@@ -101,6 +109,27 @@ export class FakePi implements PiRuntime {
}
}
function readMissingSessionCwd(sessionFile: string | undefined): string | null {
if (!sessionFile || !existsSync(sessionFile)) {
return null;
}
const [headerLine = ""] = readFileSync(sessionFile, "utf8").split("\n", 1);
let header: unknown;
try {
header = JSON.parse(headerLine);
} catch {
return null;
}
if (typeof header !== "object" || header === null || !("cwd" in header)) {
return null;
}
const { type, cwd } = header as { type?: unknown; cwd?: unknown };
if (type !== "session" || typeof cwd !== "string" || !cwd || existsSync(cwd)) {
return null;
}
return cwd;
}
export class FakePiSession implements PiRuntimeSession {
readonly environment: Record<string, string>;
readonly prompts: Array<{ message: string; imageCount: number }> = [];
@@ -0,0 +1,26 @@
import { stat } from "node:fs/promises";
import { homedir } from "node:os";
import type { AgentResumePurpose } from "../agent-sdk-types.js";
export async function resolveResumeProcessCwd(
cwd: string,
purpose: AgentResumePurpose | undefined,
): Promise<string> {
if (purpose !== "history" || (await isDirectory(cwd))) {
return cwd;
}
return homedir();
}
async function isDirectory(target: string): Promise<boolean> {
try {
return (await stat(target)).isDirectory();
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT" || code === "ENOTDIR") {
return false;
}
throw error;
}
}
@@ -0,0 +1,71 @@
// 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
// "slow-reject" moves the select and announces it for a non-empty value, then refuses
// after a delay; an empty value is refused without moving anything
// 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 }) {
if (scenario.onWrite === "slow-reject" && !value) {
await new Promise((resolve) => setTimeout(resolve, 150));
throw new Error("write refused");
}
set(configId, scenario.answers?.[configId] ?? value);
const announce = () =>
agentConnection.sessionUpdate({
sessionId,
update: { sessionUpdate: "config_option_update", configOptions: options },
});
if (scenario.onWrite === "slow-reject") {
await announce();
await new Promise((resolve) => setTimeout(resolve, 100));
throw new Error("write refused");
}
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 "../option-controls.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -939,9 +940,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 } : {}),
};
@@ -1638,7 +1636,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
@@ -2290,9 +2288,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 });
@@ -3070,8 +3066,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),
@@ -3124,13 +3120,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: [],
@@ -3142,7 +3138,7 @@ function createToolCatalog(
status: summary.status,
modes: summary.modes,
selectedModel: selectedModel ?? null,
features,
features: featuresFromControls({ controls, options: effectiveOptions }),
}),
};
},
@@ -119,6 +119,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",
@@ -356,6 +357,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",
@@ -180,6 +180,56 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
}
});
test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => {
const clients = createTestAgentClients();
const codex = clients.codex as AgentClient;
const applied: unknown[] = [];
// Records what reaches the provider session; behavior is the fake client's.
const recordingCodex: AgentClient = Object.create(codex, {
createSession: {
value: async (...args: Parameters<AgentClient["createSession"]>) => {
const session = await codex.createSession(...args);
const apply = session.applyOptions?.bind(session);
session.applyOptions = async (options) => {
applied.push(options);
await apply?.(options);
};
return session;
},
},
});
const daemon = await createTestPaseoDaemon({
agentClients: { ...clients, codex: recordingCodex },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Options agent",
modeId: "full-access",
model: "gpt-5.4-mini",
});
expect(agent.options?.sandbox_mode).toBeUndefined();
await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } });
expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]);
const next = await client.fetchAgent(agent.id);
expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" });
expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode");
} finally {
await client.close();
await daemon.close();
}
});
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
@@ -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,312 @@ 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 },
},
]);
});
describe("a profile edited by the unchanged app", () => {
function storeWithProfile(): 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: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{
id: "p",
name: "P",
provider: "codex",
options: { sandbox_mode: "read-only", plan_mode: false },
},
],
});
}
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, options: {} }]);
});
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 +1577,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),
});
@@ -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
? {
@@ -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[] = [];
@@ -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,
@@ -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,
@@ -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
@@ -5816,7 +5816,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({
@@ -5840,7 +5840,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")),
}),
});
+10 -6
View File
@@ -80,7 +80,7 @@ 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";
@@ -1036,7 +1036,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,
@@ -1066,8 +1066,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),
},
@@ -2950,7 +2949,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":
@@ -2974,7 +2976,7 @@ export class Session {
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config,
config: toWireDaemonConfig(config),
},
});
});
@@ -3181,6 +3183,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":
@@ -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,14 @@ 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, 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 +51,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 +344,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 { 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,
@@ -379,14 +380,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 +873,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[] {
@@ -223,7 +223,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,
@@ -34,7 +34,11 @@ import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js
import type { ProjectUpdate } from "./workspace-reconciliation-service.js";
import type { ScheduleService } from "./schedule/service.js";
import type { CheckoutDiffManager, CheckoutDiffMetrics } from "./checkout-diff-manager.js";
import type { DaemonConfigStore, MutableDaemonConfig } from "./daemon-config-store.js";
import {
toWireDaemonConfig,
type DaemonConfigStore,
type MutableDaemonConfig,
} from "./daemon-config-store.js";
import {
type ServerInfoStatusPayload,
type ScriptStatusUpdateMessage,
@@ -2007,6 +2011,7 @@ export class VoiceAssistantWebSocketServer {
usageSetting: true,
usageSources: true,
ownedSubscriptions: true,
providerOptionControls: true,
binaryChunkingV1: true,
agentRequestReceipts: true,
workspaceRequestReceipts: true,
@@ -2196,7 +2201,7 @@ export class VoiceAssistantWebSocketServer {
type: "status",
payload: {
status: "daemon_config_changed",
config,
config: toWireDaemonConfig(config),
},
});
}
@@ -410,7 +410,12 @@ describe("createTerminal", () => {
cwd: realpathSync(tmpdir()),
cols: 500,
command: process.execPath,
args: ["-e", "process.stdout.write(JSON.stringify(process.argv.slice(1)))", nastyPrompt],
// Keep the PTY alive until the screen assertion; exit disposes its parser.
args: [
"-e",
"process.stdout.write(JSON.stringify(process.argv.slice(1))); setInterval(() => {}, 100000)",
nastyPrompt,
],
}),
);
@@ -176,6 +176,13 @@
},
"additionalProperties": {}
},
"options": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"notes": {
"type": "string"
}
@@ -21,9 +21,9 @@ export class PromptFiles {
)
throw new AntigravityError("Antigravity supports messages only");
const parts = prompt.input.content.map((part) => {
if (part.type === "text" || part.type === "uploaded_file") return part;
if (part.type === "text") return part;
if (part.type === "image") return { ...part, extension: imageExtension(part.mimeType) };
throw new AntigravityError("Antigravity supports text, image and uploaded file content only");
throw new AntigravityError("Antigravity supports text and image content only");
});
const text: string[] = [];
const native: string[] = [];
@@ -41,19 +41,6 @@ export class PromptFiles {
await writeFile(file, Buffer.from(part.data, "base64"), { flag: "wx", mode: 0o600 });
await chmod(file, 0o600);
native.push(`Attached image: ${file}`);
} else if (part.type === "uploaded_file") {
if (part.mimeType.startsWith("image/")) {
native.push(`Attached image: ${part.path}`);
} else {
const reference = [
`Uploaded file: ${part.fileName}`,
`Path: ${part.path}`,
`MIME: ${part.mimeType}`,
`Size: ${part.size} bytes`,
].join("\n");
text.push(reference);
native.push(reference);
}
}
}
return { text: text.join("\n"), nativeText: native.join("\n") };
@@ -774,54 +774,6 @@ describe("image prompts", () => {
});
});
describe("uploaded file prompts", () => {
it("references uploaded text, images and large files without changing their bytes", async () => {
const h = await harness();
await h.open();
const txtFile = path.join(h.cwd, "notes.txt");
await writeFile(txtFile, "export const meaning = 42;");
const imgFile = path.join(h.cwd, "photo.png");
await writeFile(imgFile, "png bytes");
await h.completed(
await h.message([
{ type: "text", text: "Please review these attachments:" },
{
type: "uploaded_file",
id: "upload-txt-1",
fileName: "notes.txt",
mimeType: "text/plain",
size: 26,
path: txtFile,
},
{
type: "uploaded_file",
id: "upload-img-1",
fileName: "photo.png",
mimeType: "image/png",
size: 9,
path: imgFile,
},
{
type: "uploaded_file",
id: "upload-big-1",
fileName: "dataset.bin",
mimeType: "application/octet-stream",
size: 3 * 1024 * 1024,
path: "/tmp/dataset.bin",
},
]),
);
const sent = (await h.records()).find((entry) => entry.input).input.message.content;
expect(sent).toBe(
`SYSTEM PREFIX\n\nPlease review these attachments:\nUploaded file: notes.txt\nPath: ${txtFile}\nMIME: text/plain\nSize: 26 bytes\nAttached image: ${imgFile}\nUploaded file: dataset.bin\nPath: /tmp/dataset.bin\nMIME: application/octet-stream\nSize: 3145728 bytes`,
);
expect(await readFile(txtFile, "utf8")).toBe("export const meaning = 42;");
expect(await readFile(imgFile, "utf8")).toBe("png bytes");
});
});
it.runIf(process.platform === "win32")(
"launches an Antigravity command shim for probes and conversation startup",
async () => {
@@ -837,29 +789,3 @@ it.runIf(process.platform === "win32")(
});
},
);
it("preserves a usable reference for an uploaded binary file", async () => {
const h = await harness();
await h.open();
const file = path.join(h.cwd, "document.pdf");
const bytes = Buffer.from([0x25, 0x50, 0x44, 0x46, 0x00, 0xff, 0xfe]);
await writeFile(file, bytes);
await h.completed(
await h.message([
{ type: "text", text: "Read the attachment" },
{
type: "uploaded_file",
id: "pdf-upload",
fileName: "document.pdf",
mimeType: "application/pdf",
size: 7,
path: file,
},
]),
);
const sent = (await h.records()).find((entry) => entry.input).input.message.content;
expect(sent).toContain(file);
expect(sent).not.toContain("\u0000");
expect(sent).not.toContain("\ufffd");
expect(await readFile(file)).toEqual(bytes);
});
+51 -1
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync, readdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { readFileSync, readdirSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -679,3 +679,53 @@ test("macOS jobs use latest runners while preserving release architectures", ()
// GitHub has no standard macos-latest-intel alias; use the newest Intel image.
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
});
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
assert.ok(match, "the release workflow repairs the pinned signing tool");
const script = match[1].replace(/^ /gm, "");
const original = [
"return await importCerts(keychainFile, certPaths, cscPasswords);",
"async function importCerts(keychainFile, paths, keyPasswords) {",
'["import", paths[i], "-P", password]',
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
"}",
].join("\n");
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
try {
const packageDirectory = join(directory, "node_modules/app-builder-lib");
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
const manifest = join(packageDirectory, "package.json");
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
for (const [version, source, succeeds] of [
["26.8.1", original, true],
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
["26.18.0", original, false],
]) {
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
writeFileSync(sourcePath, source);
const result = spawnSync(process.execPath, ["-e", script], {
cwd: directory,
encoding: "utf8",
});
const actual = readFileSync(sourcePath, "utf8");
if (succeeds) {
assert.equal(result.status, 0, result.stderr);
assert.ok(
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
);
assert.ok(
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
);
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
} else {
assert.notEqual(result.status, 0);
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
}
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});