mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77f90d3460 | ||
|
|
7a651fceb3 | ||
|
|
4095d3784d | ||
|
|
21365c571b | ||
|
|
f4dea7413e | ||
|
|
6bf7a47a96 | ||
|
|
d0a5d4d970 | ||
|
|
377b06be0d | ||
|
|
25a3efe0ac | ||
|
|
5b4636b856 | ||
|
|
00ec4bc80c | ||
|
|
970c281daf | ||
|
|
389db81784 | ||
|
|
c3a7547ce5 | ||
|
|
d86b6dec6e | ||
|
|
e4876ed09d | ||
|
|
ea8c9e7dc4 | ||
|
|
ddae0a4c36 | ||
|
|
4a8ed8f588 | ||
|
|
4d76f2176b | ||
|
|
e4a49b414f | ||
|
|
fbd7890f78 | ||
|
|
83ae20ff06 | ||
|
|
9df005f602 | ||
|
|
d1a1eba23d | ||
|
|
133481d75c | ||
|
|
e640fe4281 | ||
|
|
43f7dc4ad1 | ||
|
|
f783c46301 | ||
|
|
2fddf0b98c | ||
|
|
593773a381 | ||
|
|
e4d4ba7489 | ||
|
|
1b5731cebe | ||
|
|
0f1ebda942 | ||
|
|
7a1eae245b | ||
|
|
3383ee80bc | ||
|
|
5cdbfef48b | ||
|
|
0c378889d1 | ||
|
|
8460e5126a | ||
|
|
51a87b98b9 | ||
|
|
427c2d0743 | ||
|
|
a8d539ca7c | ||
|
|
993eef1668 | ||
|
|
c2677a2690 | ||
|
|
f3c3461d67 | ||
|
|
85edc9da9c | ||
|
|
fd2315495f | ||
|
|
3a3300b69e | ||
|
|
0fef7414fc | ||
|
|
c0c181425f | ||
|
|
301b49e381 | ||
|
|
ee936d51c0 | ||
|
|
8e28e3988b | ||
|
|
62df2f3253 | ||
|
|
aba0c78449 | ||
|
|
a0754e9f9b | ||
|
|
9d8747cd3a | ||
|
|
13ad4a13ff | ||
|
|
9de8cbf2b8 | ||
|
|
2dd83e43cb | ||
|
|
ac6b5440e8 | ||
|
|
0da2781520 | ||
|
|
f487316a9d | ||
|
|
1ee149c85b | ||
|
|
72d488f17a | ||
|
|
8e83d9346a | ||
|
|
6c22bc72df | ||
|
|
bc00edf904 | ||
|
|
afce426a43 |
@@ -135,6 +135,32 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
- name: Set desktop package version from tag
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -74,10 +74,7 @@ registrations remain inspectable; a changed destination requires explicit identi
|
|||||||
|
|
||||||
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
|
||||||
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
|
||||||
operations; requesting this feature gives update guidance. The registry retries an older destination
|
operations; requesting this feature gives update guidance.
|
||||||
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
|
|
||||||
authentication failures stay in `error` until an update changes the connection, `enabled`, or
|
|
||||||
`replaceIdentity`, or A restarts.
|
|
||||||
|
|
||||||
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
Disposing a routed API releases its observations and session. It does not remove the saved host or
|
||||||
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ Each agent is stored as a separate JSON file, grouped by project directory.
|
|||||||
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
|
||||||
| `title` | `string?` | User-visible title |
|
| `title` | `string?` | User-visible title |
|
||||||
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
|
||||||
|
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
|
||||||
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
|
||||||
| `lastModeId` | `string?` | Last active mode ID |
|
| `lastModeId` | `string?` | Last active mode ID |
|
||||||
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
|
||||||
@@ -478,6 +479,7 @@ Array of workspace records. A workspace is a specific working directory within a
|
|||||||
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
|
||||||
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
|
||||||
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
|
||||||
|
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
|
||||||
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
|
||||||
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -511,13 +511,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
|
|||||||
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
|
||||||
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
|
||||||
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
Route messages, structured commands, steering, and command side effects through `session.prompt`.
|
||||||
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
|
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
|
||||||
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
|
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
|
||||||
It contains the provider defaults and per-agent overrides merged by the daemon.
|
|
||||||
Validate and apply it inside the provider; core does not know your option shape.
|
Validate and apply it inside the provider; core does not know your option shape.
|
||||||
See [provider options](custom-providers.md#provider-options) for configuration and
|
See [provider options](custom-providers.md#provider-options) for configuration and
|
||||||
merge semantics.
|
merge semantics.
|
||||||
|
|
||||||
|
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
|
||||||
|
(`providerOptions`) in `session.config`. A running-agent change arrives as
|
||||||
|
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
|
||||||
|
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
|
||||||
|
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
|
||||||
|
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
|
||||||
|
its value into an option and is sent both shapes in `session.configure`; delete it with
|
||||||
|
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
|
||||||
|
|
||||||
Providers add an optional veto to daemon baseline cleanup through the per-session
|
Providers add an optional veto to daemon baseline cleanup through the per-session
|
||||||
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
|
||||||
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
|
||||||
|
|||||||
+62
-3
@@ -30,6 +30,65 @@ 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 defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||||
for native keys and examples.
|
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.
|
||||||
|
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
|
||||||
|
agent snapshot for a running or closed agent. A draft and a profile form send everything they
|
||||||
|
would create with (seed, the provider's last-used control values once the provider has said it
|
||||||
|
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
|
||||||
|
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
|
||||||
|
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
|
||||||
|
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
|
||||||
|
replaces provider and options in one step, while choosing a provider by hand starts over. A
|
||||||
|
profile saves its stored options plus the form's edits, so options no control draws survive an
|
||||||
|
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
|
||||||
|
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
|
||||||
|
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
|
||||||
|
create that carries options first lists the controls for its own provider, model, mode and
|
||||||
|
thinking level, since which options are controls differs by model, and sends those as
|
||||||
|
`featureValues`; callers do not prime anything.
|
||||||
|
- **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
|
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
|
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||||
server and tool identity without approving native tools.
|
server and tool identity without approving native tools.
|
||||||
@@ -115,7 +174,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.
|
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.
|
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 +537,7 @@ interface AgentSession {
|
|||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
readonly capabilities: AgentCapabilityFlags;
|
||||||
readonly features?: AgentFeature[];
|
readonly controls?: AgentOptionControl[];
|
||||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||||
@@ -500,7 +559,7 @@ interface AgentSession {
|
|||||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
setModel?(modelId: string | null): Promise<void>;
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||||
} | null;
|
} | null;
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
|||||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||||
# disable the hardened runtime that requires a matching signature.
|
# disable the hardened runtime that requires a matching signature.
|
||||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||||
npm exec --offline -- electron-builder \
|
../../node_modules/.bin/electron-builder \
|
||||||
--config electron-builder.yml \
|
--config electron-builder.yml \
|
||||||
--dir \
|
--dir \
|
||||||
--mac \
|
--mac \
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
||||||
|
|||||||
Generated
+1295
-1018
File diff suppressed because it is too large
Load Diff
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
|
|||||||
const raw = localStorage.getItem(preferencesKey);
|
const raw = localStorage.getItem(preferencesKey);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const preferences = JSON.parse(raw) as FormPreferences;
|
const preferences = JSON.parse(raw) as FormPreferences;
|
||||||
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
|
return preferences.providerPreferences?.[providerId]?.options ?? null;
|
||||||
},
|
},
|
||||||
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
|
||||||
);
|
);
|
||||||
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
|||||||
provider: featureProviderId,
|
provider: featureProviderId,
|
||||||
model: featureModelId,
|
model: featureModelId,
|
||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
featureValues: { fast_mode: true },
|
options: { fast_mode: true },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
const provider = await seedModelProvider({
|
const provider = await seedModelProvider({
|
||||||
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
|
|||||||
expect(createAgentRequest).toMatchObject({
|
expect(createAgentRequest).toMatchObject({
|
||||||
config: {
|
config: {
|
||||||
provider: featureProviderId,
|
provider: featureProviderId,
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { fast_mode: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
featureValues?: Record<string, unknown>;
|
providerOptions?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeAgentCommandsCwd(cwd: string): string {
|
export function normalizeAgentCommandsCwd(cwd: string): string {
|
||||||
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
|
|||||||
"thinking",
|
"thinking",
|
||||||
draftConfig.thinkingOptionId ?? null,
|
draftConfig.thinkingOptionId ?? null,
|
||||||
"features",
|
"features",
|
||||||
draftConfig.featureValues ?? null,
|
draftConfig.providerOptions ?? null,
|
||||||
] as const;
|
] as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { isControlActive } from "./option-controls";
|
||||||
|
import {
|
||||||
|
draftControlsRequest,
|
||||||
|
openDraftOptions,
|
||||||
|
ownDraftOptions,
|
||||||
|
viewDraftOptions,
|
||||||
|
} from "./draft-options-model";
|
||||||
|
|
||||||
|
const SPEED: AgentOptionControl = {
|
||||||
|
type: "select",
|
||||||
|
key: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
defaultValue: "default",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Standard" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" };
|
||||||
|
|
||||||
|
function answer(
|
||||||
|
provider: string,
|
||||||
|
requestKey: string,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
controls = [SPEED, WEB_SEARCH],
|
||||||
|
) {
|
||||||
|
return { provider, requestKey, controls, options };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("draft options model", () => {
|
||||||
|
it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => {
|
||||||
|
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } });
|
||||||
|
model.applyResolved(answer("codex", "k1", { service_tier: "priority" }));
|
||||||
|
|
||||||
|
const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" });
|
||||||
|
|
||||||
|
expect(view?.options).toEqual({ service_tier: "priority" });
|
||||||
|
expect(view && isControlActive(SPEED, view.options)).toBe(true);
|
||||||
|
// The daemon still gets the stored value to resolve, and so does create.
|
||||||
|
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applying a profile from another provider keeps its options through the provider change", () => {
|
||||||
|
const model = openDraftOptions({ provider: "claude" });
|
||||||
|
model.applyResolved(answer("claude", "k1", {}));
|
||||||
|
|
||||||
|
model.applyProfile({
|
||||||
|
provider: "codex",
|
||||||
|
options: { sandbox_mode: "read-only", service_tier: "fast" },
|
||||||
|
});
|
||||||
|
// The form follows the profile to its provider.
|
||||||
|
model.observeProvider("codex");
|
||||||
|
|
||||||
|
expect(ownDraftOptions(model.getState())).toEqual({
|
||||||
|
sandbox_mode: "read-only",
|
||||||
|
service_tier: "fast",
|
||||||
|
});
|
||||||
|
expect(model.getState().resolved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("choosing another provider by hand starts the options over", () => {
|
||||||
|
const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } });
|
||||||
|
model.setOption("web_search", true);
|
||||||
|
|
||||||
|
model.observeProvider("claude");
|
||||||
|
|
||||||
|
expect(ownDraftOptions(model.getState())).toBeUndefined();
|
||||||
|
expect(model.getState().resolved).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never pairs a manually chosen provider with the previous provider's options", () => {
|
||||||
|
const model = openDraftOptions({ provider: "claude" });
|
||||||
|
model.applyResolved(answer("claude", "k1", { fast_mode: true }));
|
||||||
|
model.setOption("fast_mode", true);
|
||||||
|
const form = { provider: "claude", cwd: "/repo" };
|
||||||
|
expect(draftControlsRequest(model.getState(), form)).toMatchObject({
|
||||||
|
provider: "claude",
|
||||||
|
providerOptions: { fast_mode: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The form shows OpenCode on its next render; the model has not observed it yet.
|
||||||
|
expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull();
|
||||||
|
|
||||||
|
model.observeProvider("opencode");
|
||||||
|
const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" });
|
||||||
|
expect(request).toEqual({ provider: "opencode", cwd: "/repo" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a draft's seed when the first provider is chosen", () => {
|
||||||
|
const model = openDraftOptions({ provider: null, seed: { web_search: true } });
|
||||||
|
|
||||||
|
model.observeProvider("claude");
|
||||||
|
|
||||||
|
expect(ownDraftOptions(model.getState())).toEqual({ web_search: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends a remembered value to the daemon, and draws only what comes back", () => {
|
||||||
|
const model = openDraftOptions({ provider: "codex" });
|
||||||
|
model.setRemembered({ service_tier: "fast", retired: true });
|
||||||
|
// Nothing is known about the provider's controls yet.
|
||||||
|
expect(ownDraftOptions(model.getState())).toBeUndefined();
|
||||||
|
|
||||||
|
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
|
||||||
|
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
|
||||||
|
// The answer to the first request does not show the remembered value as if it applied.
|
||||||
|
expect(
|
||||||
|
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
|
||||||
|
).toEqual({ service_tier: "default" });
|
||||||
|
|
||||||
|
model.applyResolved(answer("codex", "k2", { service_tier: "priority" }));
|
||||||
|
expect(
|
||||||
|
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
|
||||||
|
).toEqual({ service_tier: "priority" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let a remembered value override the seed", () => {
|
||||||
|
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } });
|
||||||
|
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
|
||||||
|
model.setRemembered({ service_tier: "fast" });
|
||||||
|
|
||||||
|
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lays a toggle over the previous answer until the new one arrives", () => {
|
||||||
|
const model = openDraftOptions({ provider: "claude" });
|
||||||
|
model.applyResolved(answer("claude", "k1", { web_search: false }));
|
||||||
|
|
||||||
|
model.setOption("web_search", true);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
|
||||||
|
).toEqual({ web_search: true });
|
||||||
|
model.applyResolved(answer("claude", "k2", { web_search: true }));
|
||||||
|
expect(
|
||||||
|
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
|
||||||
|
).toEqual({ web_search: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws nothing for a provider the answer is not for", () => {
|
||||||
|
const model = openDraftOptions({ provider: "claude" });
|
||||||
|
model.applyResolved(answer("claude", "k1", {}));
|
||||||
|
|
||||||
|
expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import type {
|
||||||
|
AgentOptionControl,
|
||||||
|
AgentSessionConfig,
|
||||||
|
ProviderOptions,
|
||||||
|
} from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
|
||||||
|
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
|
||||||
|
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
|
||||||
|
* default or legacy key is resolved here.
|
||||||
|
*
|
||||||
|
* - `seed`: options the draft started from (a profile, a forked agent).
|
||||||
|
* - `remembered`: the provider's last-used control values.
|
||||||
|
* - `edits`: toggles made in this draft.
|
||||||
|
*
|
||||||
|
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
|
||||||
|
* and the options in one step, so the provider change that follows does not clear them.
|
||||||
|
*/
|
||||||
|
export interface ResolvedDraftOptions {
|
||||||
|
provider: string;
|
||||||
|
/** Names the request that produced this answer; see `viewDraftOptions`. */
|
||||||
|
requestKey: string;
|
||||||
|
controls: AgentOptionControl[];
|
||||||
|
options: ProviderOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftOptionsState {
|
||||||
|
provider: string | null;
|
||||||
|
seed: ProviderOptions;
|
||||||
|
remembered: ProviderOptions;
|
||||||
|
edits: ProviderOptions;
|
||||||
|
resolved: ResolvedDraftOptions | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DraftOptionsModel {
|
||||||
|
getState: () => DraftOptionsState;
|
||||||
|
subscribe: (listener: () => void) => () => void;
|
||||||
|
/** The provider the draft's form now has. A different one than the model's is a manual choice. */
|
||||||
|
observeProvider: (provider: string | null) => void;
|
||||||
|
applyProfile: (profile: { provider: string; options: ProviderOptions }) => void;
|
||||||
|
setRemembered: (remembered: ProviderOptions) => void;
|
||||||
|
setOption: (key: string, value: unknown) => void;
|
||||||
|
applyResolved: (resolved: ResolvedDraftOptions) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openDraftOptions(input: {
|
||||||
|
provider: string | null;
|
||||||
|
seed?: ProviderOptions;
|
||||||
|
}): DraftOptionsModel {
|
||||||
|
let state: DraftOptionsState = {
|
||||||
|
provider: input.provider,
|
||||||
|
seed: input.seed ?? {},
|
||||||
|
remembered: {},
|
||||||
|
edits: {},
|
||||||
|
resolved: null,
|
||||||
|
};
|
||||||
|
const listeners = new Set<() => void>();
|
||||||
|
|
||||||
|
function publish(update: (current: DraftOptionsState) => DraftOptionsState): void {
|
||||||
|
const next = update(state);
|
||||||
|
if (next === state) return;
|
||||||
|
state = next;
|
||||||
|
for (const listener of listeners) listener();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getState: () => state,
|
||||||
|
subscribe: (listener) => {
|
||||||
|
listeners.add(listener);
|
||||||
|
return () => {
|
||||||
|
listeners.delete(listener);
|
||||||
|
};
|
||||||
|
},
|
||||||
|
observeProvider: (provider) =>
|
||||||
|
publish((current) => {
|
||||||
|
if (provider === current.provider) return current;
|
||||||
|
// The first provider a draft picks does not discard what it was opened with.
|
||||||
|
if (current.provider === null) return { ...current, provider };
|
||||||
|
return { ...current, provider, seed: {}, edits: {}, resolved: null };
|
||||||
|
}),
|
||||||
|
applyProfile: (profile) =>
|
||||||
|
publish((current) => ({
|
||||||
|
...current,
|
||||||
|
provider: profile.provider,
|
||||||
|
seed: profile.options,
|
||||||
|
edits: {},
|
||||||
|
resolved: current.provider === profile.provider ? current.resolved : null,
|
||||||
|
})),
|
||||||
|
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
|
||||||
|
setOption: (key, value) =>
|
||||||
|
publish((current) =>
|
||||||
|
Object.is(current.edits[key], value)
|
||||||
|
? current
|
||||||
|
: { ...current, edits: { ...current.edits, [key]: value } },
|
||||||
|
),
|
||||||
|
applyResolved: (resolved) => publish((current) => ({ ...current, resolved })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the draft would create with, for the daemon to resolve. A last-used value applies
|
||||||
|
* once the provider has told us it still draws a control for that key, and never over the seed.
|
||||||
|
*/
|
||||||
|
export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined {
|
||||||
|
const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key));
|
||||||
|
const remembered = Object.fromEntries(
|
||||||
|
Object.entries(state.remembered).filter(
|
||||||
|
([key]) => controlKeys.has(key) && !(key in state.seed),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const own = { ...state.seed, ...remembered, ...state.edits };
|
||||||
|
return Object.keys(own).length > 0 ? own : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DraftControlsRequest = Pick<
|
||||||
|
AgentSessionConfig,
|
||||||
|
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
|
||||||
|
>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The listing request for the draft's form, built from one snapshot of the model. The form
|
||||||
|
* reaches a new provider before the model has observed it, and the model still holds the
|
||||||
|
* previous provider's options until it does; that render has no request, so a request never
|
||||||
|
* pairs one provider with another's options.
|
||||||
|
*/
|
||||||
|
export function draftControlsRequest(
|
||||||
|
state: DraftOptionsState,
|
||||||
|
form: {
|
||||||
|
provider: string | null;
|
||||||
|
cwd: string;
|
||||||
|
modeId?: string | null;
|
||||||
|
modelId?: string | null;
|
||||||
|
thinkingOptionId?: string | null;
|
||||||
|
},
|
||||||
|
): DraftControlsRequest | null {
|
||||||
|
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
|
||||||
|
const providerOptions = ownDraftOptions(state);
|
||||||
|
return {
|
||||||
|
provider: form.provider,
|
||||||
|
cwd: form.cwd,
|
||||||
|
...(form.modeId ? { modeId: form.modeId } : {}),
|
||||||
|
...(form.modelId ? { model: form.modelId } : {}),
|
||||||
|
...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}),
|
||||||
|
...(providerOptions ? { providerOptions } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the controls draw: the daemon's answer for this provider. The last answer stays up while
|
||||||
|
* a newer request is in flight, with the toggles made since laid over it, so a switch does not
|
||||||
|
* wait for the round trip. It never carries a seed or remembered value the daemon has not
|
||||||
|
* resolved.
|
||||||
|
*/
|
||||||
|
export function viewDraftOptions(
|
||||||
|
state: DraftOptionsState,
|
||||||
|
current: { provider: string | null; requestKey: string },
|
||||||
|
): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined {
|
||||||
|
const { resolved } = state;
|
||||||
|
if (!resolved || resolved.provider !== current.provider) return undefined;
|
||||||
|
return {
|
||||||
|
controls: resolved.controls,
|
||||||
|
options:
|
||||||
|
resolved.requestKey === current.requestKey
|
||||||
|
? resolved.options
|
||||||
|
: { ...resolved.options, ...state.edits },
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { Settings2 } from "lucide-react-native";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { getAgentFeatureIcon } from "./icons";
|
||||||
|
|
||||||
|
describe("getAgentFeatureIcon", () => {
|
||||||
|
it("draws the Lucide icon a plugin provider names", () => {
|
||||||
|
const icon = getAgentFeatureIcon("Mic");
|
||||||
|
expect(icon).not.toBe(Settings2);
|
||||||
|
expect(getAgentFeatureIcon("Mic")).toBe(icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the generic icon for a missing or unknown name", () => {
|
||||||
|
expect(getAgentFeatureIcon()).toBe(Settings2);
|
||||||
|
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ShieldQuestionMark,
|
ShieldQuestionMark,
|
||||||
Zap,
|
Zap,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
|
||||||
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
|
|
||||||
export interface AgentControlIconProps {
|
export interface AgentControlIconProps {
|
||||||
@@ -60,6 +61,9 @@ export function getAgentModeOptionIcon(
|
|||||||
return icon ? MODE_ICONS[icon] : undefined;
|
return icon ? MODE_ICONS[icon] : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
|
||||||
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
|
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
|
||||||
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
|
if (!icon) return Settings2;
|
||||||
|
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
|
||||||
|
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
|
||||||
|
import {
|
||||||
|
getControlTooltip,
|
||||||
|
isControlActive,
|
||||||
|
readSelectOption,
|
||||||
|
readToggleOption,
|
||||||
|
} from "./option-controls";
|
||||||
|
|
||||||
|
const speed: AgentOptionControl = {
|
||||||
|
type: "select",
|
||||||
|
key: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
defaultValue: "default",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Normal" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
|
||||||
|
|
||||||
|
describe("option controls", () => {
|
||||||
|
it("reads a select from options and falls back to the control's default", () => {
|
||||||
|
if (speed.type !== "select") throw new Error("select expected");
|
||||||
|
expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority");
|
||||||
|
expect(readSelectOption(speed, {})).toBe("default");
|
||||||
|
expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a toggle from options and is off when unset", () => {
|
||||||
|
if (fast.type !== "toggle") throw new Error("toggle expected");
|
||||||
|
expect(readToggleOption(fast, { fast_mode: true })).toBe(true);
|
||||||
|
expect(readToggleOption(fast, {})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is active when the option differs from the control's default", () => {
|
||||||
|
expect(isControlActive(speed, { service_tier: "priority" })).toBe(true);
|
||||||
|
expect(isControlActive(speed, { service_tier: "default" })).toBe(false);
|
||||||
|
expect(isControlActive(speed, {})).toBe(false);
|
||||||
|
expect(isControlActive(fast, { fast_mode: true })).toBe(true);
|
||||||
|
expect(isControlActive(fast, {})).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prefers a control's tooltip over its label", () => {
|
||||||
|
expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe(
|
||||||
|
"Toggle plan mode",
|
||||||
|
);
|
||||||
|
expect(getControlTooltip({ label: "Custom" })).toBe("Custom");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import type {
|
||||||
|
AgentOptionControl,
|
||||||
|
AgentOptionSelectControl,
|
||||||
|
AgentOptionToggleControl,
|
||||||
|
ProviderOptions,
|
||||||
|
} from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The composer's option controls. A control only describes how to draw one option key; the
|
||||||
|
* value is read from `options`, which the daemon resolved (defaults, aliases, what runs).
|
||||||
|
*/
|
||||||
|
export interface ComposerOptionControls {
|
||||||
|
controls: AgentOptionControl[];
|
||||||
|
options: ProviderOptions;
|
||||||
|
onSetOption: (key: string, value: unknown) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readToggleOption(
|
||||||
|
control: AgentOptionToggleControl,
|
||||||
|
options: ProviderOptions,
|
||||||
|
): boolean {
|
||||||
|
const value = options[control.key];
|
||||||
|
return typeof value === "boolean" ? value : (control.defaultValue ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selected choice's value, or the control's default when the option names no choice. */
|
||||||
|
export function readSelectOption(
|
||||||
|
control: AgentOptionSelectControl,
|
||||||
|
options: ProviderOptions,
|
||||||
|
): string | null {
|
||||||
|
const value = options[control.key];
|
||||||
|
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return control.defaultValue ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the option is set to something other than the control's default. */
|
||||||
|
export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean {
|
||||||
|
if (control.type === "toggle") return readToggleOption(control, options);
|
||||||
|
const value = readSelectOption(control, options);
|
||||||
|
return value !== null && value !== control.defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getControlTooltip(control: Pick<AgentOptionControl, "label" | "tooltip">): string {
|
||||||
|
return control.tooltip ?? control.label;
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
|
|||||||
model: "claude-opus-5",
|
model: "claude-opus-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
featureValues: { web_search: true },
|
options: { web_search: true },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
|
|||||||
modelId: "claude-opus-5",
|
modelId: "claude-opus-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
featureValues: { web_search: true },
|
options: { web_search: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
|
|||||||
modelId: "",
|
modelId: "",
|
||||||
modeId: "",
|
modeId: "",
|
||||||
thinkingOptionId: "",
|
thinkingOptionId: "",
|
||||||
featureValues: {},
|
options: {},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
|
|||||||
model: "claude-opus-5",
|
model: "claude-opus-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
featureValues: { web_search: true },
|
options: { web_search: true },
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
|
|||||||
modelId: "claude-opus-5",
|
modelId: "claude-opus-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
featureValues: { web_search: true },
|
options: { web_search: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => {
|
|||||||
|
|
||||||
it("omits an empty feature map so the agent keeps its own feature values", () => {
|
it("omits an empty feature map so the agent keeps its own feature values", () => {
|
||||||
expect(
|
expect(
|
||||||
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
|
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))),
|
||||||
).not.toHaveProperty("featureValues");
|
).not.toHaveProperty("options");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never carries a provider, which a running agent cannot change", () => {
|
it("never carries a provider, which a running agent cannot change", () => {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
modeId: string;
|
modeId: string;
|
||||||
thinkingOptionId: string;
|
thinkingOptionId: string;
|
||||||
featureValues: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimmed(value: string | undefined): string {
|
function trimmed(value: string | undefined): string {
|
||||||
@@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
|
|||||||
modelId: trimmed(profile.model),
|
modelId: trimmed(profile.model),
|
||||||
modeId: trimmed(profile.modeId),
|
modeId: trimmed(profile.modeId),
|
||||||
thinkingOptionId: trimmed(profile.thinkingOptionId),
|
thinkingOptionId: trimmed(profile.thinkingOptionId),
|
||||||
featureValues: profile.featureValues ?? {},
|
options: profile.options ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,8 +52,6 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
|
|||||||
...(profile.modelId ? { modelId: profile.modelId } : {}),
|
...(profile.modelId ? { modelId: profile.modelId } : {}),
|
||||||
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
...(profile.modeId ? { modeId: profile.modeId } : {}),
|
||||||
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
|
||||||
...(Object.keys(profile.featureValues).length > 0
|
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
|
||||||
? { featureValues: profile.featureValues }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
buildFeatureRequestKey,
|
buildControlsRequestKey,
|
||||||
openAgentProfileForm,
|
openAgentProfileForm,
|
||||||
type AgentProfileFormModel,
|
type AgentProfileFormModel,
|
||||||
} from "./profile-form-model";
|
} from "./profile-form-model";
|
||||||
@@ -173,7 +173,7 @@ describe("openAgentProfileForm", () => {
|
|||||||
model: "claude-opus-5",
|
model: "claude-opus-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
featureValues: { webSearch: true },
|
options: { webSearch: true },
|
||||||
notes: "Visual work only.",
|
notes: "Visual work only.",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -229,10 +229,11 @@ describe("openAgentProfileForm", () => {
|
|||||||
selectClaude(model);
|
selectClaude(model);
|
||||||
model.setModel("claude-opus-5", { label: "Opus 5" });
|
model.setModel("claude-opus-5", { label: "Opus 5" });
|
||||||
model.setThinking("think-hard", { label: "Think hard" });
|
model.setThinking("think-hard", { label: "Think hard" });
|
||||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||||
]);
|
options: { webSearch: false },
|
||||||
model.setFeatureValue("webSearch", true);
|
});
|
||||||
|
model.setOption("webSearch", true);
|
||||||
|
|
||||||
model.setProvider("codex", { label: "Codex" });
|
model.setProvider("codex", { label: "Codex" });
|
||||||
const state = model.getState();
|
const state = model.getState();
|
||||||
@@ -242,8 +243,9 @@ describe("openAgentProfileForm", () => {
|
|||||||
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
|
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
|
||||||
expect(state.thinkingOptionId).toBe("");
|
expect(state.thinkingOptionId).toBe("");
|
||||||
expect(state.thinkingDisplay).toBeNull();
|
expect(state.thinkingDisplay).toBeNull();
|
||||||
expect(state.featureValues).toEqual({});
|
expect(state.options).toEqual({});
|
||||||
expect(state.features).toEqual([]);
|
expect(state.optionEdits).toEqual({});
|
||||||
|
expect(state.controls).toEqual([]);
|
||||||
expect(state.name).toBe("Test writer");
|
expect(state.name).toBe("Test writer");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -334,23 +336,28 @@ describe("openAgentProfileForm", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("features", () => {
|
const WEB_SEARCH_CONTROLS = {
|
||||||
it("asks for features per provider/model/mode/thinking combination", () => {
|
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }],
|
||||||
|
options: { webSearch: false },
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("controls", () => {
|
||||||
|
it("asks for controls per provider/model/mode/thinking combination", () => {
|
||||||
const model = openWithCatalog({ mode: "create" });
|
const model = openWithCatalog({ mode: "create" });
|
||||||
selectClaude(model);
|
selectClaude(model);
|
||||||
|
|
||||||
expect(model.getState().featureRequest).toEqual({
|
expect(model.getState().controlsRequest).toEqual({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
model: "claude-opus-5",
|
model: "claude-opus-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
thinkingOptionId: "think",
|
thinkingOptionId: "think",
|
||||||
});
|
});
|
||||||
const firstKey = model.getState().featureRequestKey;
|
const firstKey = model.getState().controlsRequestKey;
|
||||||
|
|
||||||
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||||
|
|
||||||
expect(model.getState().featureRequestKey).not.toBe(firstKey);
|
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
|
||||||
expect(model.getState().featureRequest).toEqual({
|
expect(model.getState().controlsRequest).toEqual({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
model: "claude-haiku-4-5",
|
model: "claude-haiku-4-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
@@ -361,77 +368,168 @@ describe("openAgentProfileForm", () => {
|
|||||||
it("moves from pending to complete only for the current request", () => {
|
it("moves from pending to complete only for the current request", () => {
|
||||||
const model = openWithCatalog({ mode: "create" });
|
const model = openWithCatalog({ mode: "create" });
|
||||||
selectClaude(model);
|
selectClaude(model);
|
||||||
const key = model.getState().featureRequestKey ?? "";
|
const key = model.getState().controlsRequestKey ?? "";
|
||||||
expect(model.getState().featureResolution).toBe("pending");
|
expect(model.getState().controlsResolution).toBe("pending");
|
||||||
|
|
||||||
model.applyFeatures("stale-key", [
|
model.applyControls("stale-key", {
|
||||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||||
]);
|
options: { webSearch: false },
|
||||||
expect(model.getState().featureResolution).toBe("pending");
|
});
|
||||||
expect(model.getState().features).toEqual([]);
|
expect(model.getState().controlsResolution).toBe("pending");
|
||||||
|
expect(model.getState().controls).toEqual([]);
|
||||||
|
|
||||||
model.applyFeatures(key, [
|
model.applyControls(key, {
|
||||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
|
||||||
]);
|
options: { webSearch: false },
|
||||||
expect(model.getState().featureResolution).toBe("complete");
|
});
|
||||||
expect(model.getState().disclosure.showFeaturesField).toBe(true);
|
expect(model.getState().controlsResolution).toBe("complete");
|
||||||
|
expect(model.getState().disclosure.showControlsField).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reflects a set feature value back into the rendered feature", () => {
|
it("reflects a set option back into the rendered controls", () => {
|
||||||
|
const model = openWithCatalog({ mode: "create" });
|
||||||
|
model.setName("Searcher");
|
||||||
|
selectClaude(model);
|
||||||
|
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||||
|
|
||||||
|
model.setOption("webSearch", true);
|
||||||
|
|
||||||
|
expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" });
|
||||||
|
expect(model.getState().controlOptions).toEqual({ webSearch: true });
|
||||||
|
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps stored options while the controls are still pending", () => {
|
||||||
|
const model = openAgentProfileForm({
|
||||||
|
mode: "edit",
|
||||||
|
profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } },
|
||||||
|
});
|
||||||
|
model.applyProviderCatalog(ENTRIES);
|
||||||
|
|
||||||
|
expect(model.getState().controlsResolution).toBe("pending");
|
||||||
|
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps options no control draws, so editing a profile does not drop them", () => {
|
||||||
|
const model = openAgentProfileForm({
|
||||||
|
mode: "edit",
|
||||||
|
profile: {
|
||||||
|
id: "p1",
|
||||||
|
name: "UI work",
|
||||||
|
provider: "claude",
|
||||||
|
options: { webSearch: true, sandbox_mode: "read-only" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
model.applyProviderCatalog(ENTRIES);
|
||||||
|
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||||
|
model.setOption("webSearch", false);
|
||||||
|
|
||||||
|
expect(model.getState().submitValue?.options).toEqual({
|
||||||
|
webSearch: false,
|
||||||
|
sandbox_mode: "read-only",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws the options the daemon resolved from the stored ones, then the edits", () => {
|
||||||
|
const model = openAgentProfileForm({
|
||||||
|
mode: "edit",
|
||||||
|
profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } },
|
||||||
|
});
|
||||||
|
model.applyProviderCatalog(ENTRIES);
|
||||||
|
|
||||||
|
// The request carries the stored options so the daemon can resolve the legacy tier.
|
||||||
|
expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" });
|
||||||
|
|
||||||
|
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||||
|
controls: [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
defaultValue: "default",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Standard" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: { service_tier: "priority" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(model.getState().controlOptions).toEqual({ service_tier: "priority" });
|
||||||
|
// Nothing was edited, so the stored value is saved as it was.
|
||||||
|
expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends an edit to the daemon with the stored options, and draws the answer", () => {
|
||||||
|
const model = openAgentProfileForm({
|
||||||
|
mode: "edit",
|
||||||
|
profile: {
|
||||||
|
id: "p1",
|
||||||
|
name: "Fast",
|
||||||
|
provider: "codex",
|
||||||
|
options: { service_tier: "fast", sandbox_mode: "read-only" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
model.applyProviderCatalog(ENTRIES);
|
||||||
|
const speed = {
|
||||||
|
controls: [
|
||||||
|
{
|
||||||
|
type: "select" as const,
|
||||||
|
key: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
defaultValue: "default",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Standard" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
options: { service_tier: "priority", sandbox_mode: "read-only" },
|
||||||
|
};
|
||||||
|
model.applyControls(model.getState().controlsRequestKey ?? "", speed);
|
||||||
|
|
||||||
|
model.setOption("service_tier", "default");
|
||||||
|
|
||||||
|
// The controls stay up while the daemon answers, with the toggle laid over the last answer.
|
||||||
|
expect(model.getState().controls).toHaveLength(1);
|
||||||
|
expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" });
|
||||||
|
// The request carries everything the profile would save, so the daemon resolves it.
|
||||||
|
expect(model.getState().controlsRequest?.providerOptions).toEqual({
|
||||||
|
service_tier: "default",
|
||||||
|
sandbox_mode: "read-only",
|
||||||
|
});
|
||||||
|
expect(model.getState().controlsResolution).toBe("pending");
|
||||||
|
|
||||||
|
model.applyControls(model.getState().controlsRequestKey ?? "", {
|
||||||
|
...speed,
|
||||||
|
options: { service_tier: "default", sandbox_mode: "read-only" },
|
||||||
|
});
|
||||||
|
expect(model.getState().controlsResolution).toBe("complete");
|
||||||
|
expect(model.getState().controlOptions).toEqual({
|
||||||
|
service_tier: "default",
|
||||||
|
sandbox_mode: "read-only",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("draws no controls for the previous model while another model's answer is pending", () => {
|
||||||
const model = openWithCatalog({ mode: "create" });
|
const model = openWithCatalog({ mode: "create" });
|
||||||
selectClaude(model);
|
selectClaude(model);
|
||||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
|
||||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
model.setFeatureValue("webSearch", true);
|
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
|
||||||
|
|
||||||
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
|
expect(model.getState().controls).toEqual([]);
|
||||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps stored feature values while the feature list is still pending", () => {
|
|
||||||
const model = openAgentProfileForm({
|
|
||||||
mode: "edit",
|
|
||||||
profile: {
|
|
||||||
id: "p1",
|
|
||||||
name: "UI work",
|
|
||||||
provider: "claude",
|
|
||||||
featureValues: { webSearch: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
model.applyProviderCatalog(ENTRIES);
|
|
||||||
|
|
||||||
expect(model.getState().featureResolution).toBe("pending");
|
|
||||||
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prunes stored values the provider no longer reports", () => {
|
|
||||||
const model = openAgentProfileForm({
|
|
||||||
mode: "edit",
|
|
||||||
profile: {
|
|
||||||
id: "p1",
|
|
||||||
name: "UI work",
|
|
||||||
provider: "claude",
|
|
||||||
featureValues: { webSearch: true, retiredFlag: true },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
model.applyProviderCatalog(ENTRIES);
|
|
||||||
model.applyFeatures(model.getState().featureRequestKey ?? "", [
|
|
||||||
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(model.getState().featureValues).toEqual({ webSearch: true });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves an unavailable feature listing without showing the field", () => {
|
it("resolves an unavailable feature listing without showing the field", () => {
|
||||||
const model = openWithCatalog({ mode: "create" });
|
const model = openWithCatalog({ mode: "create" });
|
||||||
selectClaude(model);
|
selectClaude(model);
|
||||||
|
|
||||||
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
|
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
|
||||||
|
|
||||||
expect(model.getState().featureResolution).toBe("complete");
|
expect(model.getState().controlsResolution).toBe("complete");
|
||||||
expect(model.getState().disclosure.showFeaturesField).toBe(false);
|
expect(model.getState().disclosure.showControlsField).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -464,15 +562,15 @@ describe("openAgentProfileForm", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("buildFeatureRequestKey", () => {
|
describe("buildControlsRequestKey", () => {
|
||||||
it("is null without a provider", () => {
|
it("is null without a provider", () => {
|
||||||
expect(buildFeatureRequestKey(null)).toBeNull();
|
expect(buildControlsRequestKey(null)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("separates the fields so a value cannot bleed across positions", () => {
|
it("separates the fields so a value cannot bleed across positions", () => {
|
||||||
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
|
||||||
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
|
||||||
buildFeatureRequestKey({ provider: "claude", model: "a" }),
|
buildControlsRequestKey({ provider: "claude", model: "a" }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
|
ProviderOptions,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentSelectOption,
|
AgentSelectOption,
|
||||||
@@ -7,7 +8,6 @@ import type {
|
|||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||||
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
|
|
||||||
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
import { filterSelectableModels } from "@/provider-selection/model-catalog";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,11 +57,15 @@ export interface AgentProfileFormSnapshot {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The inputs a feature listing needs. Features are provider-scoped and change
|
* The inputs a controls listing needs. Controls are provider-scoped and change
|
||||||
* with model/mode/thinking, so every one of those is part of the request.
|
* with model/mode/thinking, so every one of those is part of the request. The
|
||||||
|
* profile's own options ride along so the daemon can resolve them (a legacy
|
||||||
|
* `service_tier: "fast"` is drawn as the model's Fast tier); they are the same
|
||||||
|
* for the life of one provider, so they are not part of the key.
|
||||||
*/
|
*/
|
||||||
export interface AgentProfileFeatureRequest {
|
export interface AgentProfileControlsRequest {
|
||||||
provider: string;
|
provider: string;
|
||||||
|
providerOptions?: ProviderOptions;
|
||||||
model?: string;
|
model?: string;
|
||||||
modeId?: string;
|
modeId?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
@@ -73,7 +77,7 @@ export interface AgentProfileFormDisclosure {
|
|||||||
showModelField: boolean;
|
showModelField: boolean;
|
||||||
showModeField: boolean;
|
showModeField: boolean;
|
||||||
showThinkingField: boolean;
|
showThinkingField: boolean;
|
||||||
showFeaturesField: boolean;
|
showControlsField: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentProfileFormState {
|
export interface AgentProfileFormState {
|
||||||
@@ -86,13 +90,18 @@ export interface AgentProfileFormState {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
modeId: string;
|
modeId: string;
|
||||||
thinkingOptionId: string;
|
thinkingOptionId: string;
|
||||||
featureValues: Record<string, unknown>;
|
/** The options the profile was opened with, including ones no control draws. */
|
||||||
|
options: ProviderOptions;
|
||||||
|
/** Changes made in this form, over `options`. */
|
||||||
|
optionEdits: ProviderOptions;
|
||||||
|
|
||||||
providerOptions: AgentProfileFormOption[];
|
providerOptions: AgentProfileFormOption[];
|
||||||
modelOptions: AgentProfileFormOption[];
|
modelOptions: AgentProfileFormOption[];
|
||||||
modeOptions: AgentProfileFormOption[];
|
modeOptions: AgentProfileFormOption[];
|
||||||
thinkingOptions: AgentProfileFormOption[];
|
thinkingOptions: AgentProfileFormOption[];
|
||||||
features: AgentFeature[];
|
controls: AgentOptionControl[];
|
||||||
|
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
|
||||||
|
controlOptions: ProviderOptions;
|
||||||
|
|
||||||
providerDisplay: AgentProfileFormDisplay | null;
|
providerDisplay: AgentProfileFormDisplay | null;
|
||||||
modelDisplay: AgentProfileFormDisplay | null;
|
modelDisplay: AgentProfileFormDisplay | null;
|
||||||
@@ -100,9 +109,9 @@ export interface AgentProfileFormState {
|
|||||||
thinkingDisplay: AgentProfileFormDisplay | null;
|
thinkingDisplay: AgentProfileFormDisplay | null;
|
||||||
|
|
||||||
catalogResolution: AgentProfileResolutionStatus;
|
catalogResolution: AgentProfileResolutionStatus;
|
||||||
featureResolution: AgentProfileResolutionStatus;
|
controlsResolution: AgentProfileResolutionStatus;
|
||||||
featureRequest: AgentProfileFeatureRequest | null;
|
controlsRequest: AgentProfileControlsRequest | null;
|
||||||
featureRequestKey: string | null;
|
controlsRequestKey: string | null;
|
||||||
|
|
||||||
disclosure: AgentProfileFormDisclosure;
|
disclosure: AgentProfileFormDisclosure;
|
||||||
isSubmitting: boolean;
|
isSubmitting: boolean;
|
||||||
@@ -118,9 +127,12 @@ export interface AgentProfileFormModel {
|
|||||||
/** Late input: the host-scoped provider catalog. Never touches selections. */
|
/** Late input: the host-scoped provider catalog. Never touches selections. */
|
||||||
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
|
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
|
||||||
/** Late input: the feature list for one request. Stale keys are ignored. */
|
/** Late input: the feature list for one request. Stale keys are ignored. */
|
||||||
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
|
applyControls: (
|
||||||
|
requestKey: string,
|
||||||
|
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
|
||||||
|
) => void;
|
||||||
/** Resolve a request that produced no usable features (provider error). */
|
/** Resolve a request that produced no usable features (provider error). */
|
||||||
applyFeaturesUnavailable: (requestKey: string) => void;
|
applyControlsUnavailable: (requestKey: string) => void;
|
||||||
setName: (value: string) => void;
|
setName: (value: string) => void;
|
||||||
setAppearance: (value: { icon: string; color: string }) => void;
|
setAppearance: (value: { icon: string; color: string }) => void;
|
||||||
setNotes: (value: string) => void;
|
setNotes: (value: string) => void;
|
||||||
@@ -128,7 +140,7 @@ export interface AgentProfileFormModel {
|
|||||||
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
|
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
|
||||||
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
|
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
|
||||||
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
|
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
|
||||||
setFeatureValue: (featureId: string, value: unknown) => void;
|
setOption: (key: string, value: unknown) => void;
|
||||||
setSubmitting: (value: boolean) => void;
|
setSubmitting: (value: boolean) => void;
|
||||||
setSubmitError: (value: string | null) => void;
|
setSubmitError: (value: string | null) => void;
|
||||||
}
|
}
|
||||||
@@ -312,19 +324,29 @@ function refineDisplay(
|
|||||||
return current ?? { label: selectedId };
|
return current ?? { label: selectedId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
|
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null {
|
||||||
if (!state.provider) {
|
if (!state.provider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const ownOptions = ownOptionsOf(state);
|
||||||
return {
|
return {
|
||||||
provider: state.provider,
|
provider: state.provider,
|
||||||
...(state.modelId ? { model: state.modelId } : {}),
|
...(state.modelId ? { model: state.modelId } : {}),
|
||||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||||
|
...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
|
/** Everything the profile would save as its options; the daemon resolves them for the form. */
|
||||||
|
function ownOptionsOf(
|
||||||
|
state: Pick<AgentProfileFormState, "options" | "optionEdits">,
|
||||||
|
): ProviderOptions {
|
||||||
|
return { ...state.options, ...state.optionEdits };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of a request that decides which controls exist; options only change their values. */
|
||||||
|
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
|
||||||
if (!request) {
|
if (!request) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -336,12 +358,25 @@ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | nul
|
|||||||
].join("|");
|
].join("|");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function buildControlsRequestKey(
|
||||||
|
request: AgentProfileControlsRequest | null,
|
||||||
|
): string | null {
|
||||||
|
if (!request) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const scope = buildControlsScopeKey(request);
|
||||||
|
return request.providerOptions
|
||||||
|
? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}`
|
||||||
|
: scope;
|
||||||
|
}
|
||||||
|
|
||||||
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
|
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
|
||||||
const name = state.name.trim();
|
const name = state.name.trim();
|
||||||
const notes = state.notes.trim();
|
const notes = state.notes.trim();
|
||||||
if (!name || !state.provider) {
|
if (!name || !state.provider) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
const options = ownOptionsOf(state);
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
...(state.icon ? { icon: state.icon } : {}),
|
...(state.icon ? { icon: state.icon } : {}),
|
||||||
@@ -350,16 +385,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
|
|||||||
...(state.modelId ? { model: state.modelId } : {}),
|
...(state.modelId ? { model: state.modelId } : {}),
|
||||||
...(state.modeId ? { modeId: state.modeId } : {}),
|
...(state.modeId ? { modeId: state.modeId } : {}),
|
||||||
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
|
||||||
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
|
...(Object.keys(options).length > 0 ? { options } : {}),
|
||||||
...(notes ? { notes } : {}),
|
...(notes ? { notes } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveFeatureStatus(
|
function resolveControlsStatus(
|
||||||
featureRequestKey: string | null,
|
controlsRequestKey: string | null,
|
||||||
featuresAreCurrent: boolean,
|
featuresAreCurrent: boolean,
|
||||||
): AgentProfileResolutionStatus {
|
): AgentProfileResolutionStatus {
|
||||||
if (featureRequestKey === null) {
|
if (controlsRequestKey === null) {
|
||||||
return "idle";
|
return "idle";
|
||||||
}
|
}
|
||||||
return featuresAreCurrent ? "complete" : "pending";
|
return featuresAreCurrent ? "complete" : "pending";
|
||||||
@@ -392,25 +427,27 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
|||||||
modelId,
|
modelId,
|
||||||
modeId: profile.modeId ?? "",
|
modeId: profile.modeId ?? "",
|
||||||
thinkingOptionId: profile.thinkingOptionId ?? "",
|
thinkingOptionId: profile.thinkingOptionId ?? "",
|
||||||
featureValues: { ...profile.featureValues },
|
options: { ...profile.options },
|
||||||
|
optionEdits: {},
|
||||||
providerOptions: [],
|
providerOptions: [],
|
||||||
modelOptions: [],
|
modelOptions: [],
|
||||||
modeOptions: [],
|
modeOptions: [],
|
||||||
thinkingOptions: [],
|
thinkingOptions: [],
|
||||||
features: [],
|
controls: [],
|
||||||
|
controlOptions: {},
|
||||||
providerDisplay: seedDisplay(provider),
|
providerDisplay: seedDisplay(provider),
|
||||||
modelDisplay: seedDisplay(modelId),
|
modelDisplay: seedDisplay(modelId),
|
||||||
modeDisplay: seedDisplay(profile.modeId),
|
modeDisplay: seedDisplay(profile.modeId),
|
||||||
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
|
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
|
||||||
catalogResolution: "idle",
|
catalogResolution: "idle",
|
||||||
featureResolution: "idle",
|
controlsResolution: "idle",
|
||||||
featureRequest: null,
|
controlsRequest: null,
|
||||||
featureRequestKey: null,
|
controlsRequestKey: null,
|
||||||
disclosure: {
|
disclosure: {
|
||||||
showModelField: false,
|
showModelField: false,
|
||||||
showModeField: false,
|
showModeField: false,
|
||||||
showThinkingField: false,
|
showThinkingField: false,
|
||||||
showFeaturesField: false,
|
showControlsField: false,
|
||||||
},
|
},
|
||||||
isSubmitting: false,
|
isSubmitting: false,
|
||||||
submitError: null,
|
submitError: null,
|
||||||
@@ -422,8 +459,10 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
|
|||||||
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
|
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
|
||||||
let entries: readonly ProviderSnapshotEntry[] = [];
|
let entries: readonly ProviderSnapshotEntry[] = [];
|
||||||
let catalogResolution: AgentProfileResolutionStatus = "idle";
|
let catalogResolution: AgentProfileResolutionStatus = "idle";
|
||||||
let resolvedFeatureKey: string | null = null;
|
let resolvedControlsKey: string | null = null;
|
||||||
let resolvedFeatures: AgentFeature[] = [];
|
let resolvedScopeKey: string | null = null;
|
||||||
|
let resolvedControls: AgentOptionControl[] = [];
|
||||||
|
let resolvedOptions: ProviderOptions = {};
|
||||||
let listeners = new Set<() => void>();
|
let listeners = new Set<() => void>();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
|
|
||||||
@@ -436,14 +475,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
|||||||
modes,
|
modes,
|
||||||
});
|
});
|
||||||
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
|
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
|
||||||
const featureRequest = buildFeatureRequest(next);
|
const controlsRequest = buildControlsRequest(next);
|
||||||
const featureRequestKey = buildFeatureRequestKey(featureRequest);
|
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
|
||||||
|
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
|
||||||
const featuresAreCurrent =
|
const featuresAreCurrent =
|
||||||
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
|
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey;
|
||||||
const features = featuresAreCurrent
|
// A toggle changes only the options, so the controls stay up while the daemon answers and the
|
||||||
? applyFeatureValues(resolvedFeatures, next.featureValues)
|
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none
|
||||||
: [];
|
// until its own answer arrives.
|
||||||
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
|
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey;
|
||||||
|
const controls = controlsApply ? resolvedControls : [];
|
||||||
|
let controlOptions: ProviderOptions = next.options;
|
||||||
|
if (controlsApply) {
|
||||||
|
controlOptions = featuresAreCurrent
|
||||||
|
? resolvedOptions
|
||||||
|
: { ...resolvedOptions, ...next.optionEdits };
|
||||||
|
}
|
||||||
|
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
|
||||||
|
|
||||||
const withOptions: AgentProfileFormState = {
|
const withOptions: AgentProfileFormState = {
|
||||||
...next,
|
...next,
|
||||||
@@ -451,7 +499,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
|||||||
modelOptions: buildModelOptions(models),
|
modelOptions: buildModelOptions(models),
|
||||||
modeOptions: buildModeOptions(modes),
|
modeOptions: buildModeOptions(modes),
|
||||||
thinkingOptions: buildThinkingOptions(thinking),
|
thinkingOptions: buildThinkingOptions(thinking),
|
||||||
features,
|
controls,
|
||||||
|
controlOptions,
|
||||||
providerDisplay: refineDisplay(
|
providerDisplay: refineDisplay(
|
||||||
next.providerDisplay,
|
next.providerDisplay,
|
||||||
next.provider,
|
next.provider,
|
||||||
@@ -479,9 +528,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
|||||||
})(),
|
})(),
|
||||||
),
|
),
|
||||||
catalogResolution,
|
catalogResolution,
|
||||||
featureResolution,
|
controlsResolution,
|
||||||
featureRequest,
|
controlsRequest,
|
||||||
featureRequestKey,
|
controlsRequestKey,
|
||||||
};
|
};
|
||||||
|
|
||||||
// A field appears once the catalog can populate it, and stays visible while
|
// A field appears once the catalog can populate it, and stays visible while
|
||||||
@@ -492,7 +541,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
|||||||
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
|
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
|
||||||
showThinkingField:
|
showThinkingField:
|
||||||
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
|
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
|
||||||
showFeaturesField: hasProvider && features.length > 0,
|
showControlsField: hasProvider && controls.length > 0,
|
||||||
};
|
};
|
||||||
const canSubmit =
|
const canSubmit =
|
||||||
withOptions.name.trim().length > 0 &&
|
withOptions.name.trim().length > 0 &&
|
||||||
@@ -531,23 +580,24 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
|||||||
catalogResolution = "complete";
|
catalogResolution = "complete";
|
||||||
publish((current) => current);
|
publish((current) => current);
|
||||||
},
|
},
|
||||||
applyFeatures: (requestKey, features) => {
|
applyControls: (requestKey, resolved) => {
|
||||||
if (requestKey !== state.featureRequestKey) {
|
if (requestKey !== state.controlsRequestKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedFeatureKey = requestKey;
|
resolvedControlsKey = requestKey;
|
||||||
resolvedFeatures = [...features];
|
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
|
||||||
publish((current) => ({
|
resolvedControls = [...resolved.controls];
|
||||||
...current,
|
resolvedOptions = resolved.options;
|
||||||
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
|
publish((current) => current);
|
||||||
}));
|
|
||||||
},
|
},
|
||||||
applyFeaturesUnavailable: (requestKey) => {
|
applyControlsUnavailable: (requestKey) => {
|
||||||
if (requestKey !== state.featureRequestKey) {
|
if (requestKey !== state.controlsRequestKey) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
resolvedFeatureKey = requestKey;
|
resolvedControlsKey = requestKey;
|
||||||
resolvedFeatures = [];
|
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
|
||||||
|
resolvedControls = [];
|
||||||
|
resolvedOptions = {};
|
||||||
publish((current) => current);
|
publish((current) => current);
|
||||||
},
|
},
|
||||||
setName: (value) => publish((current) => ({ ...current, name: value })),
|
setName: (value) => publish((current) => ({ ...current, name: value })),
|
||||||
@@ -572,7 +622,8 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
|||||||
modeDisplay: null,
|
modeDisplay: null,
|
||||||
thinkingOptionId: "",
|
thinkingOptionId: "",
|
||||||
thinkingDisplay: null,
|
thinkingDisplay: null,
|
||||||
featureValues: {},
|
options: {},
|
||||||
|
optionEdits: {},
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
setModel: (modelId, display) =>
|
setModel: (modelId, display) =>
|
||||||
@@ -598,10 +649,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
|
|||||||
publish((current) => ({ ...current, modeId, modeDisplay: display })),
|
publish((current) => ({ ...current, modeId, modeDisplay: display })),
|
||||||
setThinking: (thinkingOptionId, display) =>
|
setThinking: (thinkingOptionId, display) =>
|
||||||
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
|
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
|
||||||
setFeatureValue: (featureId, value) =>
|
setOption: (key, value) =>
|
||||||
publish((current) => ({
|
publish((current) => ({
|
||||||
...current,
|
...current,
|
||||||
featureValues: { ...current.featureValues, [featureId]: value },
|
optionEdits: { ...current.optionEdits, [key]: value },
|
||||||
})),
|
})),
|
||||||
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
|
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
|
||||||
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
|
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
|
|||||||
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
|
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
|
const featureCount = Object.keys(input.profile.options ?? {}).length;
|
||||||
if (featureCount > 0) {
|
if (featureCount > 0) {
|
||||||
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
|
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,9 +117,7 @@ export function useAgentProfilePicker(
|
|||||||
modelId: resolved.modelId,
|
modelId: resolved.modelId,
|
||||||
modeId: resolved.modeId,
|
modeId: resolved.modeId,
|
||||||
thinkingOptionId: resolved.thinkingOptionId,
|
thinkingOptionId: resolved.thinkingOptionId,
|
||||||
...(Object.keys(resolved.featureValues).length > 0
|
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
|
||||||
? { featureValues: resolved.featureValues }
|
|
||||||
: {}),
|
|
||||||
}),
|
}),
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
|
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
|
|||||||
}, [entries, input.model]);
|
}, [entries, input.model]);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useAgentProfileFormFeatures(input: {
|
export function useAgentProfileFormControls(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
model: AgentProfileFormModel;
|
model: AgentProfileFormModel;
|
||||||
state: AgentProfileFormState;
|
state: AgentProfileFormState;
|
||||||
@@ -35,14 +35,14 @@ export function useAgentProfileFormFeatures(input: {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const client = useHostRuntimeClient(input.serverId);
|
const client = useHostRuntimeClient(input.serverId);
|
||||||
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
const isConnected = useHostRuntimeIsConnected(input.serverId);
|
||||||
const request = input.state.featureRequest;
|
const request = input.state.controlsRequest;
|
||||||
const requestKey = input.state.featureRequestKey;
|
const requestKey = input.state.controlsRequestKey;
|
||||||
|
|
||||||
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
|
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
|
||||||
// page's data while the next request is in flight, which would hand the
|
// page's data while the next request is in flight, which would hand the
|
||||||
// features of one provider/model combination to the key of another.
|
// features of one provider/model combination to the key of another.
|
||||||
const featuresQuery = useFetchQuery({
|
const controlsQuery = useFetchQuery({
|
||||||
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
|
queryKey: ["agentProfileControls", input.serverId, requestKey],
|
||||||
dataShape: "value",
|
dataShape: "value",
|
||||||
staleTimeMs: 5 * 60 * 1000,
|
staleTimeMs: 5 * 60 * 1000,
|
||||||
enabled: Boolean(client && isConnected && request),
|
enabled: Boolean(client && isConnected && request),
|
||||||
@@ -51,28 +51,28 @@ export function useAgentProfileFormFeatures(input: {
|
|||||||
if (!client || !request) {
|
if (!client || !request) {
|
||||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
}
|
}
|
||||||
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
|
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(payload.error);
|
throw new Error(payload.error);
|
||||||
}
|
}
|
||||||
return payload.features ?? [];
|
return { controls: payload.controls ?? [], options: payload.options ?? {} };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data, isError } = featuresQuery;
|
const { data, isError } = controlsQuery;
|
||||||
const { model } = input;
|
const { model } = input;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestKey || !data) {
|
if (!requestKey || !data) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
model.applyFeatures(requestKey, data);
|
model.applyControls(requestKey, data);
|
||||||
}, [data, model, requestKey]);
|
}, [data, model, requestKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!requestKey || !isError) {
|
if (!requestKey || !isError) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
model.applyFeaturesUnavailable(requestKey);
|
model.applyControlsUnavailable(requestKey);
|
||||||
}, [isError, model, requestKey]);
|
}, [isError, model, requestKey]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
|
|||||||
import { Text, View } from "react-native";
|
import { Text, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { StyleSheet } from "react-native-unistyles";
|
import { StyleSheet } from "react-native-unistyles";
|
||||||
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
|
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
|
||||||
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
import type { AgentProfile } from "@getpaseo/protocol/messages";
|
||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -25,7 +26,7 @@ import type {
|
|||||||
} from "../internal/profile-form-model";
|
} from "../internal/profile-form-model";
|
||||||
import {
|
import {
|
||||||
useAgentProfileFormCatalog,
|
useAgentProfileFormCatalog,
|
||||||
useAgentProfileFormFeatures,
|
useAgentProfileFormControls,
|
||||||
} from "../internal/use-profile-form-inputs";
|
} from "../internal/use-profile-form-inputs";
|
||||||
import {
|
import {
|
||||||
useAgentProfileFormModel,
|
useAgentProfileFormModel,
|
||||||
@@ -141,7 +142,7 @@ function OpenAgentProfileEditModal({
|
|||||||
const model = useAgentProfileFormModel(snapshot);
|
const model = useAgentProfileFormModel(snapshot);
|
||||||
const state = useAgentProfileFormState(model);
|
const state = useAgentProfileFormState(model);
|
||||||
useAgentProfileFormCatalog({ serverId, model });
|
useAgentProfileFormCatalog({ serverId, model });
|
||||||
useAgentProfileFormFeatures({ serverId, model, state });
|
useAgentProfileFormControls({ serverId, model, state });
|
||||||
|
|
||||||
const sheetHeader = useMemo<SheetHeader>(
|
const sheetHeader = useMemo<SheetHeader>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -328,16 +329,17 @@ function OpenAgentProfileEditModal({
|
|||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{state.disclosure.showFeaturesField ? (
|
{state.disclosure.showControlsField ? (
|
||||||
<Field
|
<Field
|
||||||
label={t("settings.host.agentProfiles.featuresLabel")}
|
label={t("settings.host.agentProfiles.featuresLabel")}
|
||||||
testID="agent-profile-features-field"
|
testID="agent-profile-features-field"
|
||||||
>
|
>
|
||||||
<View style={styles.featureCard} testID="agent-profile-features-card">
|
<View style={styles.featureCard} testID="agent-profile-features-card">
|
||||||
{state.features.map((feature, index) => (
|
{state.controls.map((control, index) => (
|
||||||
<AgentProfileFeatureRow
|
<AgentProfileControlRow
|
||||||
key={feature.id}
|
key={control.key}
|
||||||
feature={feature}
|
control={control}
|
||||||
|
options={state.controlOptions}
|
||||||
isFirst={index === 0}
|
isFirst={index === 0}
|
||||||
disabled={state.isSubmitting}
|
disabled={state.isSubmitting}
|
||||||
size={controlSize}
|
size={controlSize}
|
||||||
@@ -400,14 +402,16 @@ function OpenAgentProfileEditModal({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AgentProfileFeatureRow({
|
function AgentProfileControlRow({
|
||||||
feature,
|
control,
|
||||||
|
options,
|
||||||
isFirst,
|
isFirst,
|
||||||
disabled,
|
disabled,
|
||||||
size,
|
size,
|
||||||
model,
|
model,
|
||||||
}: {
|
}: {
|
||||||
feature: AgentFeature;
|
control: AgentOptionControl;
|
||||||
|
options: ProviderOptions;
|
||||||
isFirst: boolean;
|
isFirst: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
size: FieldControlSize;
|
size: FieldControlSize;
|
||||||
@@ -418,74 +422,75 @@ function AgentProfileFeatureRow({
|
|||||||
[isFirst],
|
[isFirst],
|
||||||
);
|
);
|
||||||
const handleToggle = useCallback(
|
const handleToggle = useCallback(
|
||||||
(value: boolean) => model.setFeatureValue(feature.id, value),
|
(value: boolean) => model.setOption(control.key, value),
|
||||||
[feature.id, model],
|
[control.key, model],
|
||||||
);
|
);
|
||||||
const handleSelect = useCallback(
|
const handleSelect = useCallback(
|
||||||
(value: string) => model.setFeatureValue(feature.id, value),
|
(value: string) => model.setOption(control.key, value),
|
||||||
[feature.id, model],
|
[control.key, model],
|
||||||
);
|
);
|
||||||
const selectOptions = useMemo<SelectFieldOption<string>[]>(
|
const selectOptions = useMemo<SelectFieldOption<string>[]>(
|
||||||
() =>
|
() =>
|
||||||
feature.type === "select"
|
control.type === "select"
|
||||||
? feature.options.map((option) => ({
|
? control.choices.map((choice) => ({
|
||||||
id: option.id,
|
id: choice.value,
|
||||||
value: option.id,
|
value: choice.value,
|
||||||
label: option.label,
|
label: choice.label,
|
||||||
...(option.description ? { description: option.description } : {}),
|
...(choice.description ? { description: choice.description } : {}),
|
||||||
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
|
testID: `agent-profile-feature-option-${control.key}-${choice.value}`,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
[feature],
|
[control],
|
||||||
);
|
);
|
||||||
|
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
|
||||||
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
|
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
|
||||||
if (feature.type !== "select" || !feature.value) {
|
if (control.type !== "select" || !selectedValue) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const option = feature.options.find((candidate) => candidate.id === feature.value);
|
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
|
||||||
return { label: option?.label ?? feature.value };
|
return { label: choice?.label ?? selectedValue };
|
||||||
}, [feature]);
|
}, [control, selectedValue]);
|
||||||
|
|
||||||
if (feature.type === "select") {
|
if (control.type === "select") {
|
||||||
return (
|
return (
|
||||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||||
<SelectField
|
<SelectField
|
||||||
label={feature.label}
|
label={control.label}
|
||||||
value={feature.value}
|
value={selectedValue}
|
||||||
selectedDisplay={selectedDisplay}
|
selectedDisplay={selectedDisplay}
|
||||||
options={selectOptions}
|
options={selectOptions}
|
||||||
onChange={handleSelect}
|
onChange={handleSelect}
|
||||||
placeholder={feature.label}
|
placeholder={control.label}
|
||||||
emptyText={feature.label}
|
emptyText={control.label}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
{...(feature.description ? { hint: feature.description } : {})}
|
{...(control.description ? { hint: control.description } : {})}
|
||||||
title={feature.label}
|
title={control.label}
|
||||||
size={size}
|
size={size}
|
||||||
testID={`agent-profile-feature-field-${feature.id}`}
|
testID={`agent-profile-feature-field-${control.key}`}
|
||||||
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
|
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
|
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
|
||||||
<View style={styles.featureMeta}>
|
<View style={styles.featureMeta}>
|
||||||
<Text style={styles.featureLabel} numberOfLines={1}>
|
<Text style={styles.featureLabel} numberOfLines={1}>
|
||||||
{feature.label}
|
{control.label}
|
||||||
</Text>
|
</Text>
|
||||||
{feature.description ? (
|
{control.description ? (
|
||||||
<Text style={styles.featureDescription} numberOfLines={2}>
|
<Text style={styles.featureDescription} numberOfLines={2}>
|
||||||
{feature.description}
|
{control.description}
|
||||||
</Text>
|
</Text>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<Switch
|
||||||
value={feature.value === true}
|
value={readToggleOption(control, options)}
|
||||||
onValueChange={handleToggle}
|
onValueChange={handleToggle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
accessibilityLabel={feature.label}
|
accessibilityLabel={control.label}
|
||||||
testID={`agent-profile-feature-switch-${feature.id}`}
|
testID={`agent-profile-feature-switch-${control.key}`}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1304,7 +1304,8 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
|
|||||||
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
|
||||||
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
reasons.push("agent.runtimeInfo.thinkingOptionId");
|
||||||
}
|
}
|
||||||
if (left.features !== right.features) reasons.push("agent.features");
|
if (left.options !== right.options) reasons.push("agent.options");
|
||||||
|
if (left.controls !== right.controls) reasons.push("agent.controls");
|
||||||
return reasons;
|
return reasons;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,16 +40,7 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
|
|||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
model: "agent-model",
|
model: "agent-model",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
features: [
|
options: { "web-search": true, effort: "high" },
|
||||||
{ type: "toggle", id: "web-search", label: "Web search", value: true },
|
|
||||||
{
|
|
||||||
type: "select",
|
|
||||||
id: "effort",
|
|
||||||
label: "Effort",
|
|
||||||
value: "high",
|
|
||||||
options: [{ id: "high", label: "High" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
parentAgentId: null,
|
parentAgentId: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -114,7 +105,7 @@ describe("buildDraftAgentSetup", () => {
|
|||||||
modeId: "mode-current",
|
modeId: "mode-current",
|
||||||
model: "agent-model",
|
model: "agent-model",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
featureValues: {
|
providerOptions: {
|
||||||
"web-search": true,
|
"web-search": true,
|
||||||
effort: "high",
|
effort: "high",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -65,17 +65,12 @@ export function resolveClientSlashCommand(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
|
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
|
||||||
const featureValues: Record<string, unknown> = {};
|
|
||||||
for (const feature of agent.features ?? []) {
|
|
||||||
featureValues[feature.id] = feature.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||||
featureValues,
|
providerOptions: { ...agent.options },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
|
import type {
|
||||||
|
AgentMode,
|
||||||
|
AgentOptionControl,
|
||||||
|
AgentSelectOption,
|
||||||
|
} from "@getpaseo/protocol/agent-types";
|
||||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||||
import type { CommandCenterContribution } from "./contributions";
|
import type { CommandCenterContribution } from "./contributions";
|
||||||
import {
|
import {
|
||||||
@@ -31,7 +35,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
|
|||||||
thinking: TestIcon,
|
thinking: TestIcon,
|
||||||
planMode: TestIcon,
|
planMode: TestIcon,
|
||||||
mode: () => TestIcon,
|
mode: () => TestIcon,
|
||||||
feature: () => TestIcon,
|
control: () => TestIcon,
|
||||||
};
|
};
|
||||||
|
|
||||||
function provider(input: {
|
function provider(input: {
|
||||||
@@ -65,8 +69,8 @@ function provider(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
|
function toggleControl(key: string, icon?: string): AgentOptionControl {
|
||||||
return { type: "toggle", id, label: id, value, icon };
|
return { type: "toggle", key, label: key, icon };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeSource(
|
function makeSource(
|
||||||
@@ -75,7 +79,7 @@ function makeSource(
|
|||||||
models?: Partial<AgentControlContributionSource["models"]>;
|
models?: Partial<AgentControlContributionSource["models"]>;
|
||||||
thinking?: Partial<AgentControlContributionSource["thinking"]>;
|
thinking?: Partial<AgentControlContributionSource["thinking"]>;
|
||||||
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
|
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
|
||||||
features?: Partial<AgentControlContributionSource["features"]>;
|
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
|
||||||
} = {},
|
} = {},
|
||||||
): AgentControlContributionSource {
|
): AgentControlContributionSource {
|
||||||
return {
|
return {
|
||||||
@@ -104,10 +108,11 @@ function makeSource(
|
|||||||
select: () => undefined,
|
select: () => undefined,
|
||||||
...overrides.modes,
|
...overrides.modes,
|
||||||
},
|
},
|
||||||
features: {
|
optionControls: {
|
||||||
list: [],
|
controls: [],
|
||||||
set: () => undefined,
|
options: {},
|
||||||
...overrides.features,
|
onSetOption: () => undefined,
|
||||||
|
...overrides.optionControls,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,7 +138,7 @@ describe("Command Center agent-control contributions", () => {
|
|||||||
const contributions = buildAgentControlContributions({
|
const contributions = buildAgentControlContributions({
|
||||||
...makeSource({
|
...makeSource({
|
||||||
models: { providers: [provider({ id: "claude", label: "Claude" })] },
|
models: { providers: [provider({ id: "claude", label: "Claude" })] },
|
||||||
features: { list: [toggleFeature("plan_mode", true)] },
|
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } },
|
||||||
}),
|
}),
|
||||||
modes: undefined,
|
modes: undefined,
|
||||||
});
|
});
|
||||||
@@ -323,9 +328,10 @@ describe("Command Center agent-control contributions", () => {
|
|||||||
selectedId: "default",
|
selectedId: "default",
|
||||||
defaultModeId: "default",
|
defaultModeId: "default",
|
||||||
},
|
},
|
||||||
features: {
|
optionControls: {
|
||||||
list: [toggleFeature("plan_mode", true, "list-todo")],
|
controls: [toggleControl("plan_mode", "list-todo")],
|
||||||
set: (id, value) => {
|
options: { plan_mode: true },
|
||||||
|
onSetOption: (id, value) => {
|
||||||
calls.push([id, value]);
|
calls.push([id, value]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -341,9 +347,10 @@ describe("Command Center agent-control contributions", () => {
|
|||||||
const calls: [string, unknown][] = [];
|
const calls: [string, unknown][] = [];
|
||||||
const contributions = buildAgentControlContributions(
|
const contributions = buildAgentControlContributions(
|
||||||
makeSource({
|
makeSource({
|
||||||
features: {
|
optionControls: {
|
||||||
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
|
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")],
|
||||||
set: (id, value) => {
|
options: { fast_mode: false, auto_accept: true },
|
||||||
|
onSetOption: (id, value) => {
|
||||||
calls.push([id, value]);
|
calls.push([id, value]);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -376,7 +383,7 @@ describe("Command Center agent-control contributions", () => {
|
|||||||
selectedId: "default",
|
selectedId: "default",
|
||||||
defaultModeId: "default",
|
defaultModeId: "default",
|
||||||
},
|
},
|
||||||
features: { list: [toggleFeature("fast_mode")] },
|
optionControls: { controls: [toggleControl("fast_mode")], options: {} },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const ranks = new Map<string, number>();
|
const ranks = new Map<string, number>();
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentFeature,
|
|
||||||
AgentMode,
|
AgentMode,
|
||||||
|
AgentOptionControl,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentSelectOption,
|
AgentSelectOption,
|
||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
|
import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||||
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||||
import {
|
import {
|
||||||
@@ -61,7 +62,7 @@ export interface AgentControlContributionIcons {
|
|||||||
thinking: CommandCenterIcon;
|
thinking: CommandCenterIcon;
|
||||||
planMode: CommandCenterIcon;
|
planMode: CommandCenterIcon;
|
||||||
mode(modeId: string): CommandCenterIcon;
|
mode(modeId: string): CommandCenterIcon;
|
||||||
feature(feature: AgentFeature): CommandCenterIcon;
|
control(control: AgentOptionControl): CommandCenterIcon;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface CommandCenterChoice {
|
interface CommandCenterChoice {
|
||||||
@@ -105,10 +106,7 @@ export interface AgentControlContributionSource {
|
|||||||
defaultModeId: string | null;
|
defaultModeId: string | null;
|
||||||
select(modeId: string): void | Promise<void>;
|
select(modeId: string): void | Promise<void>;
|
||||||
};
|
};
|
||||||
features: {
|
optionControls: ComposerOptionControls;
|
||||||
list: readonly AgentFeature[];
|
|
||||||
set(featureId: string, value: unknown): void | Promise<void>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildContributions(
|
function buildContributions(
|
||||||
@@ -242,18 +240,19 @@ function toggleChoices(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||||
const planFeature = source.features.list.find(
|
const { controls, options, onSetOption } = source.optionControls;
|
||||||
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
|
const planControl = controls.find(
|
||||||
|
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
|
||||||
);
|
);
|
||||||
let choices: CommandCenterChoice[] = [];
|
let choices: CommandCenterChoice[] = [];
|
||||||
if (planFeature?.type === "toggle") {
|
if (planControl?.type === "toggle") {
|
||||||
choices = toggleChoices({
|
choices = toggleChoices({
|
||||||
source,
|
source,
|
||||||
key: "plan",
|
key: "plan",
|
||||||
icon: source.icons.feature(planFeature),
|
icon: source.icons.control(planControl),
|
||||||
isOn: planFeature.value,
|
isOn: readToggleOption(planControl, options),
|
||||||
turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
|
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true),
|
||||||
turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
|
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false),
|
||||||
});
|
});
|
||||||
} else if (source.modes) {
|
} else if (source.modes) {
|
||||||
const modes = source.modes;
|
const modes = source.modes;
|
||||||
@@ -280,18 +279,19 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
|
||||||
const fastFeature = source.features.list.find(
|
const { controls, options, onSetOption } = source.optionControls;
|
||||||
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
|
const fastControl = controls.find(
|
||||||
|
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
|
||||||
);
|
);
|
||||||
const choices =
|
const choices =
|
||||||
fastFeature?.type === "toggle"
|
fastControl?.type === "toggle"
|
||||||
? toggleChoices({
|
? toggleChoices({
|
||||||
source,
|
source,
|
||||||
key: "fast",
|
key: "fast",
|
||||||
icon: source.icons.feature(fastFeature),
|
icon: source.icons.control(fastControl),
|
||||||
isOn: fastFeature.value,
|
isOn: readToggleOption(fastControl, options),
|
||||||
turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
|
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true),
|
||||||
turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
|
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false),
|
||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import type {
|
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types";
|
||||||
AgentFeature,
|
|
||||||
AgentMode,
|
|
||||||
AgentProvider,
|
|
||||||
AgentSelectOption,
|
|
||||||
} from "@getpaseo/protocol/agent-types";
|
|
||||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
import {
|
import {
|
||||||
getAgentFeatureIcon,
|
getAgentFeatureIcon,
|
||||||
@@ -14,6 +9,7 @@ import {
|
|||||||
ThinkingIcon,
|
ThinkingIcon,
|
||||||
} from "@/agent-controls/icons";
|
} from "@/agent-controls/icons";
|
||||||
import { useProviderIcons } from "@/components/provider-icons";
|
import { useProviderIcons } from "@/components/provider-icons";
|
||||||
|
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||||
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
|
||||||
import {
|
import {
|
||||||
buildAgentControlContributions,
|
buildAgentControlContributions,
|
||||||
@@ -44,15 +40,15 @@ export interface AgentControlCommandCenterSource {
|
|||||||
selectedId: string | null | undefined;
|
selectedId: string | null | undefined;
|
||||||
select(modeId: string): void | Promise<void>;
|
select(modeId: string): void | Promise<void>;
|
||||||
};
|
};
|
||||||
features: {
|
/** Absent when the surface cannot change options (the agent has no client). */
|
||||||
list: readonly AgentFeature[] | undefined;
|
optionControls: ComposerOptionControls | null;
|
||||||
set?: (featureId: string, value: unknown) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ignoreAgentFeatureChange(): void {
|
const NO_OPTION_CONTROLS: ComposerOptionControls = {
|
||||||
return;
|
controls: [],
|
||||||
}
|
options: {},
|
||||||
|
onSetOption: () => undefined,
|
||||||
|
};
|
||||||
|
|
||||||
function resolveDefaultModeId(
|
function resolveDefaultModeId(
|
||||||
provider: string | null | undefined,
|
provider: string | null | undefined,
|
||||||
@@ -87,7 +83,7 @@ export function useAgentControlCommandCenterActions(input: {
|
|||||||
}): void {
|
}): void {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { controls } = input;
|
const { controls } = input;
|
||||||
const { features, models, modes, thinking } = controls;
|
const { optionControls, models, modes, thinking } = controls;
|
||||||
const getProviderIcon = useProviderIcons(controls.serverId);
|
const getProviderIcon = useProviderIcons(controls.serverId);
|
||||||
const actions = useMemo(
|
const actions = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -104,7 +100,7 @@ export function useAgentControlCommandCenterActions(input: {
|
|||||||
getCommandCenterIcon(
|
getCommandCenterIcon(
|
||||||
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
|
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
|
||||||
),
|
),
|
||||||
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
|
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)),
|
||||||
},
|
},
|
||||||
models: {
|
models: {
|
||||||
providers: models.providers,
|
providers: models.providers,
|
||||||
@@ -123,10 +119,7 @@ export function useAgentControlCommandCenterActions(input: {
|
|||||||
modes?.select,
|
modes?.select,
|
||||||
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
|
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
|
||||||
),
|
),
|
||||||
features: {
|
optionControls: optionControls ?? NO_OPTION_CONTROLS,
|
||||||
list: features.set ? (features.list ?? []) : [],
|
|
||||||
set: features.set ?? ignoreAgentFeatureChange,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
getProviderIcon,
|
getProviderIcon,
|
||||||
@@ -134,8 +127,7 @@ export function useAgentControlCommandCenterActions(input: {
|
|||||||
controls.provider,
|
controls.provider,
|
||||||
controls.providerDefinitions,
|
controls.providerDefinitions,
|
||||||
controls.serverId,
|
controls.serverId,
|
||||||
features.list,
|
optionControls,
|
||||||
features.set,
|
|
||||||
models.providers,
|
models.providers,
|
||||||
models.select,
|
models.select,
|
||||||
models.selectedModelId,
|
models.selectedModelId,
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|||||||
import { useShallow } from "zustand/shallow";
|
import { useShallow } from "zustand/shallow";
|
||||||
import { Settings2 } from "lucide-react-native";
|
import { Settings2 } from "lucide-react-native";
|
||||||
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
|
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
|
||||||
|
import {
|
||||||
|
getControlTooltip,
|
||||||
|
isControlActive,
|
||||||
|
readSelectOption,
|
||||||
|
readToggleOption,
|
||||||
|
type ComposerOptionControls,
|
||||||
|
} from "@/agent-controls/option-controls";
|
||||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||||
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
|
||||||
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
import { CombinedModelSelector } from "@/components/combined-model-selector";
|
||||||
@@ -47,7 +54,8 @@ import {
|
|||||||
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import type {
|
import type {
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
|
ProviderOptions,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -55,8 +63,6 @@ import type {
|
|||||||
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||||
import {
|
import {
|
||||||
getFeatureHighlightColor,
|
getFeatureHighlightColor,
|
||||||
isFeatureActive,
|
|
||||||
getFeatureTooltip,
|
|
||||||
getAgentControlHintKey,
|
getAgentControlHintKey,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "@/composer/agent-controls/utils";
|
} from "@/composer/agent-controls/utils";
|
||||||
@@ -101,6 +107,7 @@ interface AgentControlOption {
|
|||||||
type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
|
||||||
|
|
||||||
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
|
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
|
||||||
|
const EMPTY_OPTIONS: ProviderOptions = {};
|
||||||
|
|
||||||
interface ControlledAgentControlsProps {
|
interface ControlledAgentControlsProps {
|
||||||
provider: string;
|
provider: string;
|
||||||
@@ -122,8 +129,7 @@ interface ControlledAgentControlsProps {
|
|||||||
onEditAgentProfiles?: () => void;
|
onEditAgentProfiles?: () => void;
|
||||||
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
||||||
onEditAgentProfile?: (profileId: string) => void;
|
onEditAgentProfile?: (profileId: string) => void;
|
||||||
features?: AgentFeature[];
|
optionControls?: ComposerOptionControls;
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
onModelSelectorOpen?: () => void;
|
onModelSelectorOpen?: () => void;
|
||||||
onRetryModelProvider?: (provider: AgentProvider) => void;
|
onRetryModelProvider?: (provider: AgentProvider) => void;
|
||||||
@@ -150,8 +156,7 @@ export interface DraftAgentControlsProps {
|
|||||||
selectedThinkingOptionId: string;
|
selectedThinkingOptionId: string;
|
||||||
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
onSelectThinkingOption: (thinkingOptionId: string) => void;
|
||||||
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
|
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
|
||||||
features?: AgentFeature[];
|
optionControls?: ComposerOptionControls;
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
|
||||||
onDropdownClose?: () => void;
|
onDropdownClose?: () => void;
|
||||||
onModelSelectorOpen?: () => void;
|
onModelSelectorOpen?: () => void;
|
||||||
onRetryModelProvider?: (provider: AgentProvider) => void;
|
onRetryModelProvider?: (provider: AgentProvider) => void;
|
||||||
@@ -212,7 +217,8 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getFeatureIconColor(
|
function getFeatureIconColor(
|
||||||
feature: AgentFeature,
|
control: AgentOptionControl,
|
||||||
|
options: ProviderOptions,
|
||||||
palette: {
|
palette: {
|
||||||
blue: { 400: string };
|
blue: { 400: string };
|
||||||
green: { 400: string };
|
green: { 400: string };
|
||||||
@@ -220,11 +226,11 @@ function getFeatureIconColor(
|
|||||||
},
|
},
|
||||||
foregroundMuted: string,
|
foregroundMuted: string,
|
||||||
): string {
|
): string {
|
||||||
if (!isFeatureActive(feature)) {
|
if (!isControlActive(control, options)) {
|
||||||
return foregroundMuted;
|
return foregroundMuted;
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (getFeatureHighlightColor(feature.id)) {
|
switch (getFeatureHighlightColor(control.key)) {
|
||||||
case "blue":
|
case "blue":
|
||||||
return palette.blue[400];
|
return palette.blue[400];
|
||||||
case "green":
|
case "green":
|
||||||
@@ -242,20 +248,20 @@ function resolveHasAnyControl({
|
|||||||
providerOptions,
|
providerOptions,
|
||||||
canSelectModel,
|
canSelectModel,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
features,
|
controls,
|
||||||
hasMode,
|
hasMode,
|
||||||
}: {
|
}: {
|
||||||
providerOptions: AgentControlOption[] | undefined;
|
providerOptions: AgentControlOption[] | undefined;
|
||||||
canSelectModel: boolean;
|
canSelectModel: boolean;
|
||||||
thinkingOptions: AgentControlOption[] | undefined;
|
thinkingOptions: AgentControlOption[] | undefined;
|
||||||
features: AgentFeature[] | undefined;
|
controls: readonly AgentOptionControl[] | undefined;
|
||||||
hasMode: boolean;
|
hasMode: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
Boolean(providerOptions?.length) ||
|
Boolean(providerOptions?.length) ||
|
||||||
canSelectModel ||
|
canSelectModel ||
|
||||||
Boolean(thinkingOptions?.length) ||
|
Boolean(thinkingOptions?.length) ||
|
||||||
Boolean(features?.length) ||
|
Boolean(controls?.length) ||
|
||||||
hasMode
|
hasMode
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -393,7 +399,8 @@ type AgentControlsSlice = {
|
|||||||
cwd: string | null;
|
cwd: string | null;
|
||||||
runtimeModelId: string | null;
|
runtimeModelId: string | null;
|
||||||
model: string | null | undefined;
|
model: string | null | undefined;
|
||||||
features: AgentFeature[] | undefined;
|
options: ProviderOptions | undefined;
|
||||||
|
controls: AgentOptionControl[] | undefined;
|
||||||
runtimeThinkingOptionId: string | null;
|
runtimeThinkingOptionId: string | null;
|
||||||
thinkingOptionId: string | null | undefined;
|
thinkingOptionId: string | null | undefined;
|
||||||
lastUsage: unknown;
|
lastUsage: unknown;
|
||||||
@@ -413,7 +420,8 @@ function selectAgentControlsSlice(
|
|||||||
cwd: currentAgent.cwd,
|
cwd: currentAgent.cwd,
|
||||||
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
|
||||||
model: currentAgent.model,
|
model: currentAgent.model,
|
||||||
features: currentAgent.features,
|
options: currentAgent.options,
|
||||||
|
controls: currentAgent.controls,
|
||||||
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
|
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
|
||||||
thinkingOptionId: currentAgent.thinkingOptionId,
|
thinkingOptionId: currentAgent.thinkingOptionId,
|
||||||
lastUsage: currentAgent.lastUsage,
|
lastUsage: currentAgent.lastUsage,
|
||||||
@@ -493,8 +501,7 @@ function ControlledAgentControls({
|
|||||||
onEditAgentProfiles,
|
onEditAgentProfiles,
|
||||||
onCreateAgentProfile,
|
onCreateAgentProfile,
|
||||||
onEditAgentProfile,
|
onEditAgentProfile,
|
||||||
features,
|
optionControls,
|
||||||
onSetFeature,
|
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen,
|
onModelSelectorOpen,
|
||||||
onRetryModelProvider,
|
onRetryModelProvider,
|
||||||
@@ -546,20 +553,21 @@ function ControlledAgentControls({
|
|||||||
providerOptions,
|
providerOptions,
|
||||||
canSelectModel,
|
canSelectModel,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
features,
|
controls: optionControls?.controls,
|
||||||
hasMode: modeControl !== null && modeControl !== undefined,
|
hasMode: modeControl !== null && modeControl !== undefined,
|
||||||
});
|
});
|
||||||
const featureControls = useMemo(
|
const featureControls = useMemo(
|
||||||
() =>
|
() =>
|
||||||
(features ?? []).map((feature) => {
|
(optionControls?.controls ?? []).map((control) => {
|
||||||
if (feature.type === "toggle") return { type: "toggle" as const };
|
if (control.type === "toggle") return { type: "toggle" as const };
|
||||||
const selectedOption = feature.options.find((option) => option.id === feature.value);
|
const selected = readSelectOption(control, optionControls?.options ?? {});
|
||||||
|
const choice = control.choices.find((candidate) => candidate.value === selected);
|
||||||
return {
|
return {
|
||||||
type: "select" as const,
|
type: "select" as const,
|
||||||
label: selectedOption?.label ?? feature.label,
|
label: choice?.label ?? control.label,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
[features],
|
[optionControls],
|
||||||
);
|
);
|
||||||
const controlPresence = useMemo(
|
const controlPresence = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -743,8 +751,7 @@ function ControlledAgentControls({
|
|||||||
selectedModelId={selectedModelId}
|
selectedModelId={selectedModelId}
|
||||||
thinkingOptions={formattedThinkingOptions}
|
thinkingOptions={formattedThinkingOptions}
|
||||||
selectedThinkingOptionId={selectedThinkingOptionId}
|
selectedThinkingOptionId={selectedThinkingOptionId}
|
||||||
features={features}
|
optionControls={optionControls}
|
||||||
onSetFeature={onSetFeature}
|
|
||||||
onApplyAgentProfile={onApplyAgentProfile}
|
onApplyAgentProfile={onApplyAgentProfile}
|
||||||
onEditAgentProfiles={onEditAgentProfiles}
|
onEditAgentProfiles={onEditAgentProfiles}
|
||||||
onCreateAgentProfile={onCreateAgentProfile}
|
onCreateAgentProfile={onCreateAgentProfile}
|
||||||
@@ -792,8 +799,7 @@ function ControlledAgentControls({
|
|||||||
provider={provider}
|
provider={provider}
|
||||||
selectedModelId={selectedModelId}
|
selectedModelId={selectedModelId}
|
||||||
selectedThinkingOptionId={selectedThinkingOptionId}
|
selectedThinkingOptionId={selectedThinkingOptionId}
|
||||||
features={features}
|
optionControls={optionControls}
|
||||||
onSetFeature={onSetFeature}
|
|
||||||
onApplyAgentProfile={onApplyAgentProfile}
|
onApplyAgentProfile={onApplyAgentProfile}
|
||||||
onEditAgentProfiles={onEditAgentProfiles}
|
onEditAgentProfiles={onEditAgentProfiles}
|
||||||
onCreateAgentProfile={onCreateAgentProfile}
|
onCreateAgentProfile={onCreateAgentProfile}
|
||||||
@@ -838,8 +844,7 @@ interface DesktopAgentControlsContentProps {
|
|||||||
selectedModelId?: string;
|
selectedModelId?: string;
|
||||||
thinkingOptions?: AgentControlOption[];
|
thinkingOptions?: AgentControlOption[];
|
||||||
selectedThinkingOptionId?: string;
|
selectedThinkingOptionId?: string;
|
||||||
features?: AgentFeature[];
|
optionControls?: ComposerOptionControls;
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
|
||||||
onApplyAgentProfile?: (profileId: string) => void;
|
onApplyAgentProfile?: (profileId: string) => void;
|
||||||
onEditAgentProfiles?: () => void;
|
onEditAgentProfiles?: () => void;
|
||||||
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
||||||
@@ -900,8 +905,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
|||||||
selectedModelId,
|
selectedModelId,
|
||||||
thinkingOptions,
|
thinkingOptions,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
features,
|
optionControls,
|
||||||
onSetFeature,
|
|
||||||
onApplyAgentProfile,
|
onApplyAgentProfile,
|
||||||
onEditAgentProfiles,
|
onEditAgentProfiles,
|
||||||
onCreateAgentProfile,
|
onCreateAgentProfile,
|
||||||
@@ -1057,7 +1061,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
|||||||
|
|
||||||
{modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
|
{modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
|
||||||
|
|
||||||
{presentation.aggregateFeatures && features?.length ? (
|
{presentation.aggregateFeatures && optionControls?.controls.length ? (
|
||||||
<>
|
<>
|
||||||
<Pressable
|
<Pressable
|
||||||
onPress={handleOpenFeatures}
|
onPress={handleOpenFeatures}
|
||||||
@@ -1077,27 +1081,29 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
|
|||||||
onClose={handleCloseSheet}
|
onClose={handleCloseSheet}
|
||||||
testID="agent-features-sheet"
|
testID="agent-features-sheet"
|
||||||
>
|
>
|
||||||
{features.map((feature) => (
|
{optionControls.controls.map((control) => (
|
||||||
<SheetFeatureItem
|
<SheetFeatureItem
|
||||||
key={`feature-${feature.id}`}
|
key={`feature-${control.key}`}
|
||||||
feature={feature}
|
control={control}
|
||||||
|
options={optionControls.options}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
openSelector={openSelector}
|
openSelector={openSelector}
|
||||||
handleOpenChange={handleNestedOpenChange}
|
handleOpenChange={handleNestedOpenChange}
|
||||||
onSetFeature={onSetFeature}
|
onSetOption={optionControls.onSetOption}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</AdaptiveModalSheet>
|
</AdaptiveModalSheet>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
features?.map((feature) => (
|
optionControls?.controls.map((control) => (
|
||||||
<DesktopFeatureItem
|
<DesktopFeatureItem
|
||||||
key={`feature-${feature.id}`}
|
key={`feature-${control.key}`}
|
||||||
feature={feature}
|
control={control}
|
||||||
|
options={optionControls.options}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
openSelector={openSelector}
|
openSelector={openSelector}
|
||||||
handleOpenChange={handleOpenChange}
|
handleOpenChange={handleOpenChange}
|
||||||
onSetFeature={onSetFeature}
|
onSetOption={optionControls.onSetOption}
|
||||||
onActionComplete={onDropdownClose}
|
onActionComplete={onDropdownClose}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
@@ -1110,8 +1116,7 @@ interface SheetAgentControlsContentProps {
|
|||||||
provider: string;
|
provider: string;
|
||||||
selectedModelId?: string;
|
selectedModelId?: string;
|
||||||
selectedThinkingOptionId?: string;
|
selectedThinkingOptionId?: string;
|
||||||
features?: AgentFeature[];
|
optionControls?: ComposerOptionControls;
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
|
||||||
onApplyAgentProfile?: (profileId: string) => void;
|
onApplyAgentProfile?: (profileId: string) => void;
|
||||||
onEditAgentProfiles?: () => void;
|
onEditAgentProfiles?: () => void;
|
||||||
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
|
||||||
@@ -1154,8 +1159,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
provider,
|
provider,
|
||||||
selectedModelId,
|
selectedModelId,
|
||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
features,
|
optionControls,
|
||||||
onSetFeature,
|
|
||||||
onApplyAgentProfile,
|
onApplyAgentProfile,
|
||||||
onEditAgentProfiles,
|
onEditAgentProfiles,
|
||||||
onCreateAgentProfile,
|
onCreateAgentProfile,
|
||||||
@@ -1188,6 +1192,7 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const thinkingAnchorRef = useRef<View | null>(null);
|
const thinkingAnchorRef = useRef<View | null>(null);
|
||||||
|
const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS;
|
||||||
|
|
||||||
const hasThinking = comboboxThinkingOptions.length > 0;
|
const hasThinking = comboboxThinkingOptions.length > 0;
|
||||||
|
|
||||||
@@ -1238,14 +1243,15 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
|
|
||||||
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
|
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
|
||||||
|
|
||||||
{(features ?? []).map((feature) => (
|
{(optionControls?.controls ?? []).map((control) => (
|
||||||
<SheetFeatureItem
|
<SheetFeatureItem
|
||||||
key={`feature-${feature.id}`}
|
key={`feature-${control.key}`}
|
||||||
feature={feature}
|
control={control}
|
||||||
|
options={sheetOptions}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
openSelector={openSelector}
|
openSelector={openSelector}
|
||||||
handleOpenChange={handleOpenChange}
|
handleOpenChange={handleOpenChange}
|
||||||
onSetFeature={onSetFeature}
|
onSetOption={optionControls?.onSetOption}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</View>
|
</View>
|
||||||
@@ -1279,22 +1285,24 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function DesktopFeatureItem({
|
function DesktopFeatureItem({
|
||||||
feature,
|
control,
|
||||||
|
options,
|
||||||
disabled,
|
disabled,
|
||||||
openSelector,
|
openSelector,
|
||||||
handleOpenChange,
|
handleOpenChange,
|
||||||
onSetFeature,
|
onSetOption,
|
||||||
onActionComplete,
|
onActionComplete,
|
||||||
}: {
|
}: {
|
||||||
feature: AgentFeature;
|
control: AgentOptionControl;
|
||||||
|
options: ProviderOptions;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
openSelector: AgentControlSelector | null;
|
openSelector: AgentControlSelector | null;
|
||||||
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
|
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetOption?: (key: string, value: unknown) => void;
|
||||||
onActionComplete?: () => void;
|
onActionComplete?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
|
const featureSelector: AgentControlSelector = `feature-${control.key}`;
|
||||||
const featureAnchorRef = useRef<View>(null);
|
const featureAnchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const handleFeatureOpenChange = useMemo(
|
const handleFeatureOpenChange = useMemo(
|
||||||
@@ -1307,120 +1315,117 @@ function DesktopFeatureItem({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleTogglePress = useCallback(() => {
|
const handleTogglePress = useCallback(() => {
|
||||||
if (feature.type === "toggle") {
|
if (control.type === "toggle") {
|
||||||
onSetFeature?.(feature.id, !feature.value);
|
onSetOption?.(control.key, !readToggleOption(control, options));
|
||||||
onActionComplete?.();
|
onActionComplete?.();
|
||||||
}
|
}
|
||||||
}, [feature, onActionComplete, onSetFeature]);
|
}, [control, onActionComplete, onSetOption, options]);
|
||||||
|
|
||||||
const handleSelectOption = useCallback(
|
const handleSelectOption = useCallback(
|
||||||
(optionId: string) => {
|
(choiceValue: string) => {
|
||||||
onSetFeature?.(feature.id, optionId);
|
onSetOption?.(control.key, choiceValue);
|
||||||
},
|
},
|
||||||
[feature.id, onSetFeature],
|
[control.key, onSetOption],
|
||||||
);
|
);
|
||||||
const comboboxOptions = useMemo<ComboboxOption[]>(
|
const comboboxOptions = useMemo<ComboboxOption[]>(
|
||||||
() =>
|
() =>
|
||||||
feature.type === "select"
|
control.type === "select"
|
||||||
? feature.options.map((option) => ({ id: option.id, label: option.label }))
|
? control.choices.map((choice) => ({ id: choice.value, label: choice.label }))
|
||||||
: [],
|
: [],
|
||||||
[feature],
|
[control],
|
||||||
);
|
);
|
||||||
|
|
||||||
if (feature.type === "toggle") {
|
const FeatureIcon = getAgentFeatureIcon(control.icon);
|
||||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
const iconColor = getFeatureIconColor(
|
||||||
|
control,
|
||||||
|
options,
|
||||||
|
theme.colors.palette,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (control.type === "toggle") {
|
||||||
return (
|
return (
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<AgentControlTrigger
|
<AgentControlTrigger
|
||||||
icon={FeatureIcon}
|
icon={FeatureIcon}
|
||||||
iconColor={getFeatureIconColor(
|
iconColor={iconColor}
|
||||||
feature,
|
|
||||||
theme.colors.palette,
|
|
||||||
theme.colors.foregroundMuted,
|
|
||||||
)}
|
|
||||||
surface="toolbar"
|
surface="toolbar"
|
||||||
label={feature.label}
|
label={control.label}
|
||||||
showToolbarLabel={false}
|
showToolbarLabel={false}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onPress={handleTogglePress}
|
onPress={handleTogglePress}
|
||||||
accessibilityLabel={getFeatureTooltip(feature)}
|
accessibilityLabel={getControlTooltip(control)}
|
||||||
testID={`agent-feature-${feature.id}`}
|
testID={`agent-feature-${control.key}`}
|
||||||
/>
|
/>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
|
<Text style={styles.tooltipText}>{getControlTooltip(control)}</Text>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (feature.type === "select") {
|
const selectedValue = readSelectOption(control, options);
|
||||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
const selectedChoice = control.choices.find((choice) => choice.value === selectedValue);
|
||||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
const iconOnly = control.desktopTrigger === "icon";
|
||||||
const iconOnly = feature.desktopTrigger === "icon";
|
const tooltip = iconOnly
|
||||||
const tooltip = iconOnly
|
? `${control.label}: ${selectedChoice?.label ?? control.label}`
|
||||||
? `${feature.label}: ${selectedOption?.label ?? feature.label}`
|
: getControlTooltip(control);
|
||||||
: getFeatureTooltip(feature);
|
return (
|
||||||
return (
|
<>
|
||||||
<>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<TooltipTrigger asChild triggerRefProp="ref">
|
||||||
<TooltipTrigger asChild triggerRefProp="ref">
|
<AgentControlTrigger
|
||||||
<AgentControlTrigger
|
ref={featureAnchorRef}
|
||||||
ref={featureAnchorRef}
|
icon={FeatureIcon}
|
||||||
icon={FeatureIcon}
|
iconColor={iconColor}
|
||||||
iconColor={getFeatureIconColor(
|
surface="toolbar"
|
||||||
feature,
|
label={control.label}
|
||||||
theme.colors.palette,
|
value={selectedChoice?.label ?? control.label}
|
||||||
theme.colors.foregroundMuted,
|
showToolbarLabel={!iconOnly}
|
||||||
)}
|
open={openSelector === featureSelector}
|
||||||
surface="toolbar"
|
disabled={disabled}
|
||||||
label={feature.label}
|
onPress={handleSelectPress}
|
||||||
value={selectedOption?.label ?? feature.label}
|
accessibilityLabel={tooltip}
|
||||||
showToolbarLabel={!iconOnly}
|
testID={`agent-feature-${control.key}`}
|
||||||
open={openSelector === featureSelector}
|
/>
|
||||||
disabled={disabled}
|
</TooltipTrigger>
|
||||||
onPress={handleSelectPress}
|
<TooltipContent side="top" align="center" offset={8}>
|
||||||
accessibilityLabel={tooltip}
|
<Text style={styles.tooltipText}>{tooltip}</Text>
|
||||||
testID={`agent-feature-${feature.id}`}
|
</TooltipContent>
|
||||||
/>
|
</Tooltip>
|
||||||
</TooltipTrigger>
|
<Combobox
|
||||||
<TooltipContent side="top" align="center" offset={8}>
|
options={comboboxOptions}
|
||||||
<Text style={styles.tooltipText}>{tooltip}</Text>
|
value={String(selectedValue)}
|
||||||
</TooltipContent>
|
onSelect={handleSelectOption}
|
||||||
</Tooltip>
|
open={openSelector === featureSelector}
|
||||||
<Combobox
|
onOpenChange={handleFeatureOpenChange}
|
||||||
options={comboboxOptions}
|
anchorRef={featureAnchorRef}
|
||||||
value={String(feature.value)}
|
desktopPlacement="top-start"
|
||||||
onSelect={handleSelectOption}
|
/>
|
||||||
open={openSelector === featureSelector}
|
</>
|
||||||
onOpenChange={handleFeatureOpenChange}
|
);
|
||||||
anchorRef={featureAnchorRef}
|
|
||||||
desktopPlacement="top-start"
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function SheetFeatureItem({
|
function SheetFeatureItem({
|
||||||
feature,
|
control,
|
||||||
|
options,
|
||||||
disabled,
|
disabled,
|
||||||
openSelector,
|
openSelector,
|
||||||
handleOpenChange,
|
handleOpenChange,
|
||||||
onSetFeature,
|
onSetOption,
|
||||||
}: {
|
}: {
|
||||||
feature: AgentFeature;
|
control: AgentOptionControl;
|
||||||
|
options: ProviderOptions;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
openSelector: AgentControlSelector | null;
|
openSelector: AgentControlSelector | null;
|
||||||
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
|
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
|
||||||
onSetFeature?: (featureId: string, value: unknown) => void;
|
onSetOption?: (key: string, value: unknown) => void;
|
||||||
}) {
|
}) {
|
||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
|
const featureSelector: AgentControlSelector = `feature-${control.key}`;
|
||||||
const featureAnchorRef = useRef<View>(null);
|
const featureAnchorRef = useRef<View>(null);
|
||||||
|
|
||||||
const handleFeatureOpenChange = useMemo(
|
const handleFeatureOpenChange = useMemo(
|
||||||
@@ -1431,97 +1436,71 @@ function SheetFeatureItem({
|
|||||||
() => handleFeatureOpenChange(openSelector !== featureSelector),
|
() => handleFeatureOpenChange(openSelector !== featureSelector),
|
||||||
[featureSelector, handleFeatureOpenChange, openSelector],
|
[featureSelector, handleFeatureOpenChange, openSelector],
|
||||||
);
|
);
|
||||||
const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
|
const sheetHeader = useMemo<SheetHeader>(() => ({ title: control.label }), [control.label]);
|
||||||
|
|
||||||
const handleSelectOption = useCallback(
|
const handleSelectOption = useCallback(
|
||||||
(optionId: string) => {
|
(choiceValue: string) => {
|
||||||
onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
|
onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue);
|
||||||
},
|
},
|
||||||
[feature.id, feature.type, onSetFeature],
|
[control.key, control.type, onSetOption],
|
||||||
);
|
);
|
||||||
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
|
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
|
||||||
if (feature.type === "select") {
|
if (control.type === "select") {
|
||||||
return feature.options.map((option) => ({ id: option.id, label: option.label }));
|
return control.choices.map((choice) => ({ id: choice.value, label: choice.label }));
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
{ id: "true", label: t("agentControls.features.on") },
|
{ id: "true", label: t("agentControls.features.on") },
|
||||||
{ id: "false", label: t("agentControls.features.off") },
|
{ id: "false", label: t("agentControls.features.off") },
|
||||||
];
|
];
|
||||||
}, [feature, t]);
|
}, [control, t]);
|
||||||
|
|
||||||
if (feature.type === "toggle") {
|
const FeatureIcon = getAgentFeatureIcon(control.icon);
|
||||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
const iconColor = getFeatureIconColor(
|
||||||
return (
|
control,
|
||||||
<>
|
options,
|
||||||
<AgentControlTrigger
|
theme.colors.palette,
|
||||||
ref={featureAnchorRef}
|
theme.colors.foregroundMuted,
|
||||||
icon={FeatureIcon}
|
);
|
||||||
iconColor={getFeatureIconColor(
|
let selectedValue: string;
|
||||||
feature,
|
let displayValue: string;
|
||||||
theme.colors.palette,
|
if (control.type === "toggle") {
|
||||||
theme.colors.foregroundMuted,
|
const isOn = readToggleOption(control, options);
|
||||||
)}
|
selectedValue = String(isOn);
|
||||||
surface="sheet"
|
displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off");
|
||||||
label={feature.label}
|
} else {
|
||||||
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
|
const selected = readSelectOption(control, options);
|
||||||
open={openSelector === featureSelector}
|
selectedValue = String(selected);
|
||||||
disabled={disabled}
|
displayValue =
|
||||||
onPress={handleSelectPress}
|
control.choices.find((choice) => choice.value === selected)?.label ?? control.label;
|
||||||
accessibilityLabel={getFeatureTooltip(feature)}
|
|
||||||
testID={`agent-feature-${feature.id}`}
|
|
||||||
/>
|
|
||||||
<Combobox
|
|
||||||
options={comboboxOptions}
|
|
||||||
value={String(feature.value)}
|
|
||||||
onSelect={handleSelectOption}
|
|
||||||
open={openSelector === featureSelector}
|
|
||||||
onOpenChange={handleFeatureOpenChange}
|
|
||||||
anchorRef={featureAnchorRef}
|
|
||||||
presentation="push"
|
|
||||||
searchable={false}
|
|
||||||
header={sheetHeader}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
return (
|
||||||
if (feature.type === "select") {
|
<>
|
||||||
const FeatureIcon = getAgentFeatureIcon(feature.icon);
|
<AgentControlTrigger
|
||||||
const selectedOption = feature.options.find((o) => o.id === feature.value);
|
ref={featureAnchorRef}
|
||||||
return (
|
icon={FeatureIcon}
|
||||||
<>
|
iconColor={iconColor}
|
||||||
<AgentControlTrigger
|
surface="sheet"
|
||||||
ref={featureAnchorRef}
|
label={control.label}
|
||||||
icon={FeatureIcon}
|
value={displayValue}
|
||||||
iconColor={getFeatureIconColor(
|
open={openSelector === featureSelector}
|
||||||
feature,
|
disabled={disabled}
|
||||||
theme.colors.palette,
|
onPress={handleSelectPress}
|
||||||
theme.colors.foregroundMuted,
|
accessibilityLabel={getControlTooltip(control)}
|
||||||
)}
|
testID={`agent-feature-${control.key}`}
|
||||||
surface="sheet"
|
/>
|
||||||
label={feature.label}
|
<Combobox
|
||||||
value={selectedOption?.label ?? feature.label}
|
options={comboboxOptions}
|
||||||
open={openSelector === featureSelector}
|
value={selectedValue}
|
||||||
disabled={disabled}
|
onSelect={handleSelectOption}
|
||||||
onPress={handleSelectPress}
|
open={openSelector === featureSelector}
|
||||||
accessibilityLabel={getFeatureTooltip(feature)}
|
onOpenChange={handleFeatureOpenChange}
|
||||||
testID={`agent-feature-${feature.id}`}
|
anchorRef={featureAnchorRef}
|
||||||
/>
|
presentation="push"
|
||||||
<Combobox
|
searchable={control.type === "toggle" ? false : undefined}
|
||||||
options={comboboxOptions}
|
header={sheetHeader}
|
||||||
value={String(feature.value)}
|
/>
|
||||||
onSelect={handleSelectOption}
|
</>
|
||||||
open={openSelector === featureSelector}
|
);
|
||||||
onOpenChange={handleFeatureOpenChange}
|
|
||||||
anchorRef={featureAnchorRef}
|
|
||||||
presentation="push"
|
|
||||||
header={sheetHeader}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ThinkingComboboxOption({
|
function ThinkingComboboxOption({
|
||||||
@@ -1549,6 +1528,18 @@ function ThinkingComboboxOption({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function useAgentOptionControls(
|
||||||
|
agent: { controls?: AgentOptionControl[]; options?: ProviderOptions } | null | undefined,
|
||||||
|
onSetOption: ComposerOptionControls["onSetOption"],
|
||||||
|
): ComposerOptionControls {
|
||||||
|
const controls = agent?.controls;
|
||||||
|
const options = agent?.options;
|
||||||
|
return useMemo(
|
||||||
|
() => ({ controls: controls ?? [], options: options ?? EMPTY_OPTIONS, onSetOption }),
|
||||||
|
[controls, onSetOption, options],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const AgentControls = memo(function AgentControls({
|
export const AgentControls = memo(function AgentControls({
|
||||||
agentId,
|
agentId,
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1700,8 +1691,8 @@ export const AgentControls = memo(function AgentControls({
|
|||||||
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
|
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSetFeature = useCallback(
|
const handleSetOption = useCallback(
|
||||||
(featureId: string, value: unknown) => {
|
(key: string, value: unknown) => {
|
||||||
if (!client || !agentProvider) {
|
if (!client || !agentProvider) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1710,22 +1701,24 @@ export const AgentControls = memo(function AgentControls({
|
|||||||
preferences: current,
|
preferences: current,
|
||||||
provider: agentProvider,
|
provider: agentProvider,
|
||||||
updates: {
|
updates: {
|
||||||
featureValues: {
|
options: {
|
||||||
[featureId]: value,
|
[key]: value,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).catch((error) => {
|
).catch((error) => {
|
||||||
console.warn("[AgentControls] persist feature preference failed", error);
|
console.warn("[AgentControls] persist option preference failed", error);
|
||||||
});
|
});
|
||||||
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
|
void client.setAgentOptions(agentId, { [key]: value }).catch((error) => {
|
||||||
console.warn("[AgentControls] setAgentFeature failed", error);
|
console.warn("[AgentControls] setAgentOptions failed", error);
|
||||||
toast.error(toErrorMessage(error));
|
toast.error(toErrorMessage(error));
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[agentId, agentProvider, client, toast, updatePreferences],
|
[agentId, agentProvider, client, toast, updatePreferences],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
|
||||||
|
|
||||||
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
|
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
|
||||||
() => ({
|
() => ({
|
||||||
serverId,
|
serverId,
|
||||||
@@ -1744,21 +1737,18 @@ export const AgentControls = memo(function AgentControls({
|
|||||||
select: handleSelectThinkingOption,
|
select: handleSelectThinkingOption,
|
||||||
},
|
},
|
||||||
modes: commandCenterModes,
|
modes: commandCenterModes,
|
||||||
features: {
|
optionControls: client ? agentOptionControls : null,
|
||||||
list: agent?.features,
|
|
||||||
set: handleSetFeature,
|
|
||||||
},
|
|
||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
activeModelId,
|
activeModelId,
|
||||||
agent?.features,
|
agentOptionControls,
|
||||||
|
client,
|
||||||
agentId,
|
agentId,
|
||||||
agentModelSelectorProviders,
|
agentModelSelectorProviders,
|
||||||
agentProvider,
|
agentProvider,
|
||||||
commandCenterModes,
|
commandCenterModes,
|
||||||
handleSelectCommandCenterModel,
|
handleSelectCommandCenterModel,
|
||||||
handleSelectThinkingOption,
|
handleSelectThinkingOption,
|
||||||
handleSetFeature,
|
|
||||||
modeProviderDefinitions,
|
modeProviderDefinitions,
|
||||||
modelSelection.selectedThinkingId,
|
modelSelection.selectedThinkingId,
|
||||||
modelSelection.thinkingOptions,
|
modelSelection.thinkingOptions,
|
||||||
@@ -1807,8 +1797,7 @@ export const AgentControls = memo(function AgentControls({
|
|||||||
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
|
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
|
||||||
onSelectThinkingOption={handleSelectThinkingOption}
|
onSelectThinkingOption={handleSelectThinkingOption}
|
||||||
features={agent.features}
|
optionControls={agentOptionControls}
|
||||||
onSetFeature={handleSetFeature}
|
|
||||||
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
|
||||||
onModelSelectorOpen={handleModelSelectorOpen}
|
onModelSelectorOpen={handleModelSelectorOpen}
|
||||||
onRetryModelProvider={handleRetryModelProvider}
|
onRetryModelProvider={handleRetryModelProvider}
|
||||||
@@ -1840,8 +1829,7 @@ export function DraftAgentControls({
|
|||||||
selectedThinkingOptionId,
|
selectedThinkingOptionId,
|
||||||
onSelectThinkingOption,
|
onSelectThinkingOption,
|
||||||
onApplyAgentProfile,
|
onApplyAgentProfile,
|
||||||
features,
|
optionControls,
|
||||||
onSetFeature,
|
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen,
|
onModelSelectorOpen,
|
||||||
onRetryModelProvider,
|
onRetryModelProvider,
|
||||||
@@ -1927,8 +1915,7 @@ export function DraftAgentControls({
|
|||||||
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
|
||||||
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
selectedThinkingOptionId={effectiveSelectedThinkingOption}
|
||||||
onSelectThinkingOption={onSelectThinkingOption}
|
onSelectThinkingOption={onSelectThinkingOption}
|
||||||
features={features}
|
optionControls={optionControls}
|
||||||
onSetFeature={onSetFeature}
|
|
||||||
onDropdownClose={onDropdownClose}
|
onDropdownClose={onDropdownClose}
|
||||||
onModelSelectorOpen={onModelSelectorOpen}
|
onModelSelectorOpen={onModelSelectorOpen}
|
||||||
onRetryModelProvider={onRetryModelProvider}
|
onRetryModelProvider={onRetryModelProvider}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
getFeatureHighlightColor,
|
getFeatureHighlightColor,
|
||||||
getFeatureTooltip,
|
|
||||||
getAgentControlHintKey,
|
getAgentControlHintKey,
|
||||||
isFeatureActive,
|
|
||||||
normalizeModelId,
|
normalizeModelId,
|
||||||
resolveAgentModelSelection,
|
resolveAgentModelSelection,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
@@ -17,51 +15,12 @@ describe("getAgentControlHintKey", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("feature metadata helpers", () => {
|
describe("feature metadata helpers", () => {
|
||||||
it("prefers explicit feature tooltip copy", () => {
|
|
||||||
expect(
|
|
||||||
getFeatureTooltip({
|
|
||||||
label: "Plan",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
}),
|
|
||||||
).toBe("Toggle plan mode");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("falls back to the feature label when no tooltip is provided", () => {
|
|
||||||
expect(
|
|
||||||
getFeatureTooltip({
|
|
||||||
label: "Custom",
|
|
||||||
}),
|
|
||||||
).toBe("Custom");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("maps feature highlight colors by feature id", () => {
|
it("maps feature highlight colors by feature id", () => {
|
||||||
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
|
||||||
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
|
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
|
||||||
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
|
||||||
expect(getFeatureHighlightColor("other")).toBe("default");
|
expect(getFeatureHighlightColor("other")).toBe("default");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats a select feature as active when a non-default option is selected", () => {
|
|
||||||
const speed = {
|
|
||||||
type: "select" as const,
|
|
||||||
id: "service_tier",
|
|
||||||
label: "Speed",
|
|
||||||
options: [
|
|
||||||
{ id: "default", label: "Normal", isDefault: true },
|
|
||||||
{ id: "fast", label: "Fast" },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
|
|
||||||
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
|
|
||||||
expect(
|
|
||||||
isFeatureActive({
|
|
||||||
type: "toggle",
|
|
||||||
id: "fast_mode",
|
|
||||||
label: "Fast",
|
|
||||||
value: true,
|
|
||||||
}),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeModelId", () => {
|
describe("normalizeModelId", () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||||
import { i18n } from "@/i18n/i18next";
|
import { i18n } from "@/i18n/i18next";
|
||||||
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
|
||||||
import {
|
import {
|
||||||
@@ -35,16 +35,6 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
|
|||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
|
|
||||||
return feature.tooltip ?? feature.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isFeatureActive(feature: AgentFeature): boolean {
|
|
||||||
if (feature.type === "toggle") return feature.value;
|
|
||||||
const selectedOption = feature.options.find((option) => option.id === feature.value);
|
|
||||||
return selectedOption !== undefined && !selectedOption.isDefault;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
|
||||||
switch (featureId) {
|
switch (featureId) {
|
||||||
case FAST_MODE_FEATURE_ID:
|
case FAST_MODE_FEATURE_ID:
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ export function resolveDraftKey(input: {
|
|||||||
|
|
||||||
export function buildDraftAgentControls(input: {
|
export function buildDraftAgentControls(input: {
|
||||||
formState: UseAgentFormStateResult;
|
formState: UseAgentFormStateResult;
|
||||||
features?: DraftAgentControlsProps["features"];
|
optionControls?: DraftAgentControlsProps["optionControls"];
|
||||||
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
|
|
||||||
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
|
||||||
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
|
||||||
}): DraftAgentControlsProps {
|
}): DraftAgentControlsProps {
|
||||||
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
|
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
|
||||||
return {
|
return {
|
||||||
providerDefinitions: formState.providerDefinitions,
|
providerDefinitions: formState.providerDefinitions,
|
||||||
selectedProvider: formState.selectedProvider,
|
selectedProvider: formState.selectedProvider,
|
||||||
@@ -43,8 +42,7 @@ export function buildDraftAgentControls(input: {
|
|||||||
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
selectedThinkingOptionId: formState.selectedThinkingOptionId,
|
||||||
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
onSelectThinkingOption: formState.setThinkingOptionFromUser,
|
||||||
onApplyAgentProfile,
|
onApplyAgentProfile,
|
||||||
features,
|
optionControls,
|
||||||
onSetFeature,
|
|
||||||
onDropdownClose,
|
onDropdownClose,
|
||||||
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
|
||||||
onRetryModelProvider: formState.refreshProviderModels,
|
onRetryModelProvider: formState.refreshProviderModels,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
type CreateAgentInitialValues,
|
type CreateAgentInitialValues,
|
||||||
type UseAgentFormStateResult,
|
type UseAgentFormStateResult,
|
||||||
} from "@/hooks/use-agent-form-state";
|
} from "@/hooks/use-agent-form-state";
|
||||||
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
|
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
|
||||||
import {
|
import {
|
||||||
buildDraftAgentControls,
|
buildDraftAgentControls,
|
||||||
hasDraftContent,
|
hasDraftContent,
|
||||||
@@ -35,7 +35,7 @@ type AttachmentUpdater =
|
|||||||
interface AgentInputDraftComposerOptions {
|
interface AgentInputDraftComposerOptions {
|
||||||
initialServerId: string | null;
|
initialServerId: string | null;
|
||||||
initialValues?: CreateAgentInitialValues;
|
initialValues?: CreateAgentInitialValues;
|
||||||
initialFeatureValues?: Record<string, unknown>;
|
initialProviderOptions?: Record<string, unknown>;
|
||||||
isVisible?: boolean;
|
isVisible?: boolean;
|
||||||
lockedWorkingDir?: string;
|
lockedWorkingDir?: string;
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
|
|||||||
effectiveModeId: string;
|
effectiveModeId: string;
|
||||||
effectiveModelId: string;
|
effectiveModelId: string;
|
||||||
effectiveThinkingOptionId: string;
|
effectiveThinkingOptionId: string;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
providerOptions: Record<string, unknown> | undefined;
|
||||||
agentControls: DraftAgentControlsProps;
|
agentControls: DraftAgentControlsProps;
|
||||||
commandDraft: DraftCommandTarget;
|
commandDraft: DraftCommandTarget;
|
||||||
};
|
};
|
||||||
@@ -274,26 +274,25 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
);
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
features: draftFeatures,
|
optionControls: draftOptionControls,
|
||||||
featureValues: draftFeatureValues,
|
options: draftOptions,
|
||||||
setFeatureValue: setDraftFeatureValue,
|
applyProfile: applyProfileOptions,
|
||||||
applyProfileFeatureValues,
|
} = useDraftProviderOptions({
|
||||||
} = useDraftAgentFeatures({
|
|
||||||
serverId: formState.selectedServerId,
|
serverId: formState.selectedServerId,
|
||||||
provider: formState.selectedProvider,
|
provider: formState.selectedProvider,
|
||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
modeId: formState.selectedMode,
|
modeId: formState.selectedMode,
|
||||||
modelId: effectiveModelId,
|
modelId: effectiveModelId,
|
||||||
thinkingOptionId: effectiveThinkingOptionId,
|
thinkingOptionId: effectiveThinkingOptionId,
|
||||||
initialFeatureValues: composerOptions?.initialFeatureValues,
|
initialOptions: composerOptions?.initialProviderOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const applyDraftAgentProfile = useCallback(
|
const applyDraftAgentProfile = useCallback(
|
||||||
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
|
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
|
||||||
formState.applyProfileFromUser(profile);
|
formState.applyProfileFromUser(profile);
|
||||||
applyProfileFeatureValues(profile.featureValues);
|
applyProfileOptions({ provider: profile.provider, options: profile.options });
|
||||||
},
|
},
|
||||||
[applyProfileFeatureValues, formState],
|
[applyProfileOptions, formState],
|
||||||
);
|
);
|
||||||
|
|
||||||
const commandDraft = useMemo(
|
const commandDraft = useMemo(
|
||||||
@@ -303,15 +302,9 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
cwd: workingDir,
|
cwd: workingDir,
|
||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
featureValues: draftFeatureValues,
|
providerOptions: draftOptions,
|
||||||
}),
|
}),
|
||||||
[
|
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir],
|
||||||
effectiveModelId,
|
|
||||||
effectiveThinkingOptionId,
|
|
||||||
draftFeatureValues,
|
|
||||||
providerSelection,
|
|
||||||
workingDir,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const composerState = useMemo<DraftComposerState | null>(() => {
|
const composerState = useMemo<DraftComposerState | null>(() => {
|
||||||
@@ -325,11 +318,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
effectiveModeId,
|
effectiveModeId,
|
||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
featureValues: draftFeatureValues,
|
providerOptions: draftOptions,
|
||||||
agentControls: buildDraftAgentControls({
|
agentControls: buildDraftAgentControls({
|
||||||
formState,
|
formState,
|
||||||
features: draftFeatures,
|
optionControls: draftOptionControls,
|
||||||
onSetFeature: setDraftFeatureValue,
|
|
||||||
onApplyAgentProfile: applyDraftAgentProfile,
|
onApplyAgentProfile: applyDraftAgentProfile,
|
||||||
}),
|
}),
|
||||||
commandDraft,
|
commandDraft,
|
||||||
@@ -340,11 +332,10 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
|
|||||||
effectiveModeId,
|
effectiveModeId,
|
||||||
effectiveModelId,
|
effectiveModelId,
|
||||||
effectiveThinkingOptionId,
|
effectiveThinkingOptionId,
|
||||||
draftFeatures,
|
draftOptionControls,
|
||||||
draftFeatureValues,
|
draftOptions,
|
||||||
applyDraftAgentProfile,
|
applyDraftAgentProfile,
|
||||||
formState,
|
formState,
|
||||||
setDraftFeatureValue,
|
|
||||||
workingDir,
|
workingDir,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@@ -65,7 +66,7 @@ interface AutoSubmitConfig {
|
|||||||
modeId: string | null;
|
modeId: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
thinkingOptionId: string | null;
|
thinkingOptionId: string | null;
|
||||||
featureValues: Record<string, unknown>;
|
providerOptions: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAutoSubmitConfig(
|
function resolveAutoSubmitConfig(
|
||||||
@@ -74,7 +75,7 @@ function resolveAutoSubmitConfig(
|
|||||||
modeId?: string | null;
|
modeId?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
thinkingOptionId?: string | null;
|
thinkingOptionId?: string | null;
|
||||||
featureValues?: Record<string, unknown>;
|
providerOptions?: Record<string, unknown>;
|
||||||
} | null,
|
} | null,
|
||||||
): AutoSubmitConfig | null {
|
): AutoSubmitConfig | null {
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
@@ -83,7 +84,7 @@ function resolveAutoSubmitConfig(
|
|||||||
modeId: pending.modeId ?? null,
|
modeId: pending.modeId ?? null,
|
||||||
model: pending.model ?? null,
|
model: pending.model ?? null,
|
||||||
thinkingOptionId: pending.thinkingOptionId ?? null,
|
thinkingOptionId: pending.thinkingOptionId ?? null,
|
||||||
featureValues: pending.featureValues ?? {},
|
providerOptions: pending.providerOptions ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +132,7 @@ async function submitDraftCreateRequest(input: {
|
|||||||
effectiveModeId: string;
|
effectiveModeId: string;
|
||||||
effectiveModelId: string | null;
|
effectiveModelId: string | null;
|
||||||
effectiveThinkingOptionId: string | null;
|
effectiveThinkingOptionId: string | null;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
providerOptions: Record<string, unknown> | undefined;
|
||||||
};
|
};
|
||||||
hostDisconnectedMessage: string;
|
hostDisconnectedMessage: string;
|
||||||
selectModelMessage: string;
|
selectModelMessage: string;
|
||||||
@@ -170,7 +171,7 @@ async function submitDraftCreateRequest(input: {
|
|||||||
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
|
||||||
thinkingOptionId:
|
thinkingOptionId:
|
||||||
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
|
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
|
||||||
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
|
providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions,
|
||||||
});
|
});
|
||||||
|
|
||||||
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
|
||||||
@@ -204,7 +205,7 @@ function buildDraftAgentSnapshot(input: {
|
|||||||
effectiveThinkingOptionId: string | null;
|
effectiveThinkingOptionId: string | null;
|
||||||
effectiveModeId: string;
|
effectiveModeId: string;
|
||||||
selectedProvider: string | null;
|
selectedProvider: string | null;
|
||||||
agentControls: { features?: Agent["features"] };
|
agentControls: { optionControls?: ComposerOptionControls };
|
||||||
};
|
};
|
||||||
selectModelMessage: string;
|
selectModelMessage: string;
|
||||||
}): Agent {
|
}): Agent {
|
||||||
@@ -241,7 +242,8 @@ function buildDraftAgentSnapshot(input: {
|
|||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: workspaceDirectory,
|
cwd: workspaceDirectory,
|
||||||
model,
|
model,
|
||||||
features: composerState.agentControls.features,
|
options: composerState.agentControls.optionControls?.options,
|
||||||
|
controls: composerState.agentControls.optionControls?.controls,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
parentAgentId: null,
|
parentAgentId: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
@@ -333,7 +335,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
composer: {
|
composer: {
|
||||||
initialServerId: serverId,
|
initialServerId: serverId,
|
||||||
initialValues: draftInitialValues,
|
initialValues: draftInitialValues,
|
||||||
initialFeatureValues: draftSetup?.featureValues,
|
initialProviderOptions: draftSetup?.providerOptions,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
lockedWorkingDir: draftWorkingDirectory ?? undefined,
|
lockedWorkingDir: draftWorkingDirectory ?? undefined,
|
||||||
},
|
},
|
||||||
@@ -351,8 +353,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
const draftModeOptions = composerState.modeOptions;
|
const draftModeOptions = composerState.modeOptions;
|
||||||
const draftSelectedMode = composerState.selectedMode;
|
const draftSelectedMode = composerState.selectedMode;
|
||||||
const draftSetMode = composerState.setModeFromUser;
|
const draftSetMode = composerState.setModeFromUser;
|
||||||
const draftFeatures = composerState.agentControls.features;
|
const draftOptionControls = composerState.agentControls.optionControls;
|
||||||
const draftOnSetFeature = composerState.agentControls.onSetFeature;
|
|
||||||
|
|
||||||
const clearDraftInput = draftInput.clear;
|
const clearDraftInput = draftInput.clear;
|
||||||
const replaceDraftText = draftInput.replaceText;
|
const replaceDraftText = draftInput.replaceText;
|
||||||
@@ -521,10 +522,7 @@ export function WorkspaceDraftAgentTab({
|
|||||||
selectedId: draftSelectedMode,
|
selectedId: draftSelectedMode,
|
||||||
select: draftSetMode,
|
select: draftSetMode,
|
||||||
},
|
},
|
||||||
features: {
|
optionControls: draftOptionControls ?? null,
|
||||||
list: draftFeatures,
|
|
||||||
set: draftOnSetFeature,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const isReadyForPendingAutoSubmit = Boolean(
|
const isReadyForPendingAutoSubmit = Boolean(
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ describe("create agent preferences", () => {
|
|||||||
modelId: "gpt-5.5",
|
modelId: "gpt-5.5",
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
featureValues: { fast_mode: true },
|
options: { fast_mode: true },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ describe("create agent preferences", () => {
|
|||||||
model: "gpt-5.5",
|
model: "gpt-5.5",
|
||||||
mode: "full-access",
|
mode: "full-access",
|
||||||
thinkingByModel: { "gpt-5.5": "high" },
|
thinkingByModel: { "gpt-5.5": "high" },
|
||||||
featureValues: { fast_mode: true },
|
options: { fast_mode: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -119,7 +119,7 @@ describe("create agent preferences", () => {
|
|||||||
model: "gpt-5.6",
|
model: "gpt-5.6",
|
||||||
mode: undefined,
|
mode: undefined,
|
||||||
thinkingByModel: undefined,
|
thinkingByModel: undefined,
|
||||||
featureValues: undefined,
|
options: undefined,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -167,13 +167,13 @@ describe("create agent preferences", () => {
|
|||||||
modelId: "one-minute-stream",
|
modelId: "one-minute-stream",
|
||||||
modeId: "approval-test",
|
modeId: "approval-test",
|
||||||
thinkingOptionId: "",
|
thinkingOptionId: "",
|
||||||
featureValues: {},
|
options: {},
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
provider: "mock",
|
provider: "mock",
|
||||||
providerPreferences: {
|
providerPreferences: {
|
||||||
pi: { model: "anthropic/sonnet" },
|
pi: { model: "anthropic/sonnet" },
|
||||||
mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} },
|
mock: { model: "one-minute-stream", mode: "approval-test", options: {} },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -208,6 +208,29 @@ describe("create agent preferences", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reads preferences saved as featureValues as options", () => {
|
||||||
|
expect(
|
||||||
|
parseFormPreferences({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: { codex: { featureValues: { service_tier: "priority" } } },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
provider: "codex",
|
||||||
|
providerPreferences: { codex: { options: { service_tier: "priority" } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps options that are not booleans or strings, such as a profile's sandbox settings", () => {
|
||||||
|
const options = {
|
||||||
|
sandbox_workspace_write: { network_access: true, writable_roots: ["/tmp"] },
|
||||||
|
max_turns: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
parseFormPreferences({ provider: "codex", providerPreferences: { codex: { options } } }),
|
||||||
|
).toEqual({ provider: "codex", providerPreferences: { codex: { options } } });
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects unknown persisted fields outside the explicit legacy shape", () => {
|
it("rejects unknown persisted fields outside the explicit legacy shape", () => {
|
||||||
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
|
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()]));
|
const optionsSchema = z.record(z.string(), z.json());
|
||||||
|
|
||||||
export interface ProviderPreferences {
|
export interface ProviderPreferences {
|
||||||
model?: string;
|
model?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
thinkingByModel?: Record<string, string>;
|
thinkingByModel?: Record<string, string>;
|
||||||
featureValues?: Record<string, unknown>;
|
/** Last-used values of the provider's option controls, by option key. */
|
||||||
|
options?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
|
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
|
||||||
@@ -20,12 +21,23 @@ export interface FormPreferences {
|
|||||||
launchTarget?: LaunchTarget;
|
launchTarget?: LaunchTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
|
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Preferences written
|
||||||
model: z.string().optional(),
|
// before the rename stored the same record as `featureValues`.
|
||||||
mode: z.string().optional(),
|
function renameLegacyFeatureValues(value: unknown): unknown {
|
||||||
thinkingByModel: z.record(z.string(), z.string()).optional(),
|
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value;
|
||||||
featureValues: featureValuesSchema.optional(),
|
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>;
|
||||||
});
|
return "options" in rest ? rest : { ...rest, options: featureValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.preprocess(
|
||||||
|
renameLegacyFeatureValues,
|
||||||
|
z.strictObject({
|
||||||
|
model: z.string().optional(),
|
||||||
|
mode: z.string().optional(),
|
||||||
|
thinkingByModel: z.record(z.string(), z.string()).optional(),
|
||||||
|
options: optionsSchema.optional(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
|
const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
|
||||||
z.strictObject({ kind: z.literal("chat") }),
|
z.strictObject({ kind: z.literal("chat") }),
|
||||||
@@ -116,7 +128,7 @@ function applyProviderPreferenceUpdates(
|
|||||||
): ProviderPreferences {
|
): ProviderPreferences {
|
||||||
const next: ProviderPreferences = { ...existing };
|
const next: ProviderPreferences = { ...existing };
|
||||||
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
|
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
|
||||||
const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
|
const nextOptions = mergeDefinedRecord(existing.options, updates.options);
|
||||||
|
|
||||||
if (updates.model !== undefined) {
|
if (updates.model !== undefined) {
|
||||||
next.model = updates.model;
|
next.model = updates.model;
|
||||||
@@ -129,8 +141,8 @@ function applyProviderPreferenceUpdates(
|
|||||||
if (nextThinkingByModel !== undefined) {
|
if (nextThinkingByModel !== undefined) {
|
||||||
next.thinkingByModel = nextThinkingByModel;
|
next.thinkingByModel = nextThinkingByModel;
|
||||||
}
|
}
|
||||||
if (nextFeatureValues !== undefined) {
|
if (nextOptions !== undefined) {
|
||||||
next.featureValues = nextFeatureValues;
|
next.options = nextOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
@@ -161,7 +173,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
|
|||||||
modelId?: string | null;
|
modelId?: string | null;
|
||||||
modeId?: string | null;
|
modeId?: string | null;
|
||||||
thinkingOptionId?: string | null;
|
thinkingOptionId?: string | null;
|
||||||
featureValues?: Record<string, unknown>;
|
options?: Record<string, unknown>;
|
||||||
}): FormPreferences {
|
}): FormPreferences {
|
||||||
if (!args.provider) {
|
if (!args.provider) {
|
||||||
return args.preferences;
|
return args.preferences;
|
||||||
@@ -170,7 +182,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
|
|||||||
const modelId = args.modelId?.trim() ?? "";
|
const modelId = args.modelId?.trim() ?? "";
|
||||||
const modeId = args.modeId?.trim() ?? "";
|
const modeId = args.modeId?.trim() ?? "";
|
||||||
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
|
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
|
||||||
const featureValues = featureValuesSchema.safeParse(args.featureValues);
|
const options = optionsSchema.safeParse(args.options);
|
||||||
|
|
||||||
return mergeProviderPreferences({
|
return mergeProviderPreferences({
|
||||||
preferences: args.preferences,
|
preferences: args.preferences,
|
||||||
@@ -179,7 +191,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
|
|||||||
model: modelId || undefined,
|
model: modelId || undefined,
|
||||||
mode: args.modeId === undefined ? undefined : modeId || null,
|
mode: args.modeId === undefined ? undefined : modeId || null,
|
||||||
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
|
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
|
||||||
...(featureValues.success ? { featureValues: featureValues.data } : {}),
|
...(options.success ? { options: options.data } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -192,7 +204,7 @@ export function applyAgentProfilePreferences(args: {
|
|||||||
modelId: string;
|
modelId: string;
|
||||||
modeId: string;
|
modeId: string;
|
||||||
thinkingOptionId: string;
|
thinkingOptionId: string;
|
||||||
featureValues: Record<string, unknown>;
|
options: Record<string, unknown>;
|
||||||
}): FormPreferences {
|
}): FormPreferences {
|
||||||
let next = args.preferences;
|
let next = args.preferences;
|
||||||
if (args.previousProvider) {
|
if (args.previousProvider) {
|
||||||
@@ -215,7 +227,7 @@ export function applyAgentProfilePreferences(args: {
|
|||||||
...(args.modelId && args.thinkingOptionId
|
...(args.modelId && args.thinkingOptionId
|
||||||
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
|
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
|
||||||
: {}),
|
: {}),
|
||||||
featureValues: args.featureValues,
|
options: args.options,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
|
|
||||||
import { resolveFeatureValues } from "./feature-preferences";
|
|
||||||
|
|
||||||
describe("feature-preferences", () => {
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
type: "toggle" as const,
|
|
||||||
id: "fast_mode",
|
|
||||||
label: "Fast",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "toggle" as const,
|
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
it("restores persisted values for available features", () => {
|
|
||||||
expect(
|
|
||||||
resolveFeatureValues({
|
|
||||||
features,
|
|
||||||
persistedFeatureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
unknown_feature: true,
|
|
||||||
},
|
|
||||||
localFeatureValues: {},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
fast_mode: true,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers local values over persisted values", () => {
|
|
||||||
expect(
|
|
||||||
resolveFeatureValues({
|
|
||||||
features,
|
|
||||||
persistedFeatureValues: {
|
|
||||||
fast_mode: true,
|
|
||||||
plan_mode: false,
|
|
||||||
},
|
|
||||||
localFeatureValues: {
|
|
||||||
fast_mode: false,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).toEqual({
|
|
||||||
fast_mode: false,
|
|
||||||
plan_mode: false,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
|
|
||||||
|
|
||||||
export function pruneFeatureValues(
|
|
||||||
featureValues: Record<string, unknown>,
|
|
||||||
features: AgentFeature[],
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
|
|
||||||
let changed = false;
|
|
||||||
const next: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const [featureId, value] of Object.entries(featureValues)) {
|
|
||||||
if (!allowedFeatureIds.has(featureId)) {
|
|
||||||
changed = true;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
next[featureId] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return changed ? next : featureValues;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyFeatureValues(
|
|
||||||
features: AgentFeature[],
|
|
||||||
featureValues: Record<string, unknown>,
|
|
||||||
): AgentFeature[] {
|
|
||||||
if (Object.keys(featureValues).length === 0) {
|
|
||||||
return features;
|
|
||||||
}
|
|
||||||
|
|
||||||
return features.map((feature) => {
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
|
|
||||||
return feature;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...feature,
|
|
||||||
value: featureValues[feature.id],
|
|
||||||
} as AgentFeature;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveFeatureValues(args: {
|
|
||||||
features: AgentFeature[];
|
|
||||||
persistedFeatureValues: Record<string, unknown>;
|
|
||||||
localFeatureValues: Record<string, unknown>;
|
|
||||||
}): Record<string, unknown> {
|
|
||||||
const next: Record<string, unknown> = {};
|
|
||||||
|
|
||||||
for (const feature of args.features) {
|
|
||||||
if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) {
|
|
||||||
next[feature.id] = args.localFeatureValues[feature.id];
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) {
|
|
||||||
next[feature.id] = args.persistedFeatureValues[feature.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
@@ -128,14 +128,14 @@ function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig
|
|||||||
const modeId = draftConfig.modeId?.trim() ?? "";
|
const modeId = draftConfig.modeId?.trim() ?? "";
|
||||||
const model = draftConfig.model?.trim() ?? "";
|
const model = draftConfig.model?.trim() ?? "";
|
||||||
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
|
||||||
const featureValues = draftConfig.featureValues;
|
const providerOptions = draftConfig.providerOptions;
|
||||||
return {
|
return {
|
||||||
provider: draftConfig.provider,
|
provider: draftConfig.provider,
|
||||||
cwd,
|
cwd,
|
||||||
...(modeId ? { modeId } : {}),
|
...(modeId ? { modeId } : {}),
|
||||||
...(model ? { model } : {}),
|
...(model ? { model } : {}),
|
||||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||||
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
|
...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -352,7 +352,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
|
|||||||
modelId: model,
|
modelId: model,
|
||||||
modeId,
|
modeId,
|
||||||
thinkingOptionId,
|
thinkingOptionId,
|
||||||
featureValues: profile.featureValues,
|
options: profile.options,
|
||||||
});
|
});
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
console.warn("[useAgentFormState] persist profile preference failed", error);
|
console.warn("[useAgentFormState] persist profile preference failed", error);
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
import type {
|
import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types";
|
||||||
AgentCapabilityFlags,
|
import type { Agent } from "@/stores/session-store";
|
||||||
AgentFeature,
|
|
||||||
AgentProvider,
|
|
||||||
} from "@getpaseo/protocol/agent-types";
|
|
||||||
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
|
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
|
||||||
|
|
||||||
export interface AgentScreenAgent {
|
export interface AgentScreenAgent {
|
||||||
@@ -22,7 +19,8 @@ export interface AgentScreenAgent {
|
|||||||
modeId?: string | null;
|
modeId?: string | null;
|
||||||
thinkingOptionId?: string | null;
|
thinkingOptionId?: string | null;
|
||||||
} | null;
|
} | null;
|
||||||
features?: readonly AgentFeature[];
|
options?: Agent["options"];
|
||||||
|
controls?: Agent["controls"];
|
||||||
lastError?: string | null;
|
lastError?: string | null;
|
||||||
projectPlacement?: {
|
projectPlacement?: {
|
||||||
projectKey?: string;
|
projectKey?: string;
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types";
|
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
||||||
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
|
|
||||||
import {
|
|
||||||
applyFeatureValues,
|
|
||||||
pruneFeatureValues,
|
|
||||||
resolveFeatureValues,
|
|
||||||
} from "./feature-preferences";
|
|
||||||
|
|
||||||
type DraftFeatureConfig = Pick<
|
|
||||||
AgentSessionConfig,
|
|
||||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
|
|
||||||
>;
|
|
||||||
|
|
||||||
export function useDraftAgentFeatures(input: {
|
|
||||||
serverId: string | null | undefined;
|
|
||||||
provider: AgentProvider | null;
|
|
||||||
cwd: string | null | undefined;
|
|
||||||
modeId: string | null | undefined;
|
|
||||||
modelId: string | null | undefined;
|
|
||||||
thinkingOptionId: string | null | undefined;
|
|
||||||
initialFeatureValues?: Record<string, unknown>;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
|
|
||||||
input;
|
|
||||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
|
|
||||||
() => initialFeatureValues ?? {},
|
|
||||||
);
|
|
||||||
const client = useHostRuntimeClient(serverId ?? "");
|
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
|
||||||
const { preferences, updatePreferences } = useFormPreferences();
|
|
||||||
const normalizedCwd = cwd?.trim() || "";
|
|
||||||
const normalizedProvider = provider ?? null;
|
|
||||||
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
|
|
||||||
const persistedFeatureValues = useMemo(
|
|
||||||
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
|
|
||||||
[preferences.providerPreferences, provider],
|
|
||||||
);
|
|
||||||
|
|
||||||
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
|
|
||||||
if (!normalizedProvider || !normalizedCwd) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: normalizedProvider,
|
|
||||||
cwd: normalizedCwd,
|
|
||||||
...(modeId ? { modeId } : {}),
|
|
||||||
...(modelId ? { model: modelId } : {}),
|
|
||||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
|
||||||
};
|
|
||||||
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
|
|
||||||
|
|
||||||
const featuresQuery = useQuery({
|
|
||||||
queryKey: [
|
|
||||||
"providerFeatures",
|
|
||||||
serverId ?? null,
|
|
||||||
normalizedProvider,
|
|
||||||
normalizedCwd || null,
|
|
||||||
modeId ?? null,
|
|
||||||
modelId ?? null,
|
|
||||||
thinkingOptionId ?? null,
|
|
||||||
],
|
|
||||||
enabled: Boolean(serverId && client && isConnected && draftConfig),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
queryFn: async () => {
|
|
||||||
if (!client || !draftConfig) {
|
|
||||||
throw new Error(t("workspace.terminal.hostDisconnected"));
|
|
||||||
}
|
|
||||||
const payload = await client.listProviderFeatures(draftConfig);
|
|
||||||
if (payload.error) {
|
|
||||||
throw new Error(payload.error);
|
|
||||||
}
|
|
||||||
return payload.features ?? [];
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const availableFeaturesRaw = featuresQuery.data;
|
|
||||||
const availableFeatures = useMemo(() => availableFeaturesRaw ?? [], [availableFeaturesRaw]);
|
|
||||||
const featureValues = useMemo(
|
|
||||||
() =>
|
|
||||||
resolveFeatureValues({
|
|
||||||
features: availableFeatures,
|
|
||||||
persistedFeatureValues,
|
|
||||||
localFeatureValues,
|
|
||||||
}),
|
|
||||||
[availableFeatures, localFeatureValues, persistedFeatureValues],
|
|
||||||
);
|
|
||||||
|
|
||||||
const features = useMemo(() => {
|
|
||||||
return applyFeatureValues(availableFeatures, featureValues);
|
|
||||||
}, [availableFeatures, featureValues]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const previousProvider = previousProviderRef.current;
|
|
||||||
previousProviderRef.current = normalizedProvider;
|
|
||||||
if (previousProvider === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (previousProvider !== normalizedProvider) {
|
|
||||||
setLocalFeatureValues({});
|
|
||||||
}
|
|
||||||
}, [normalizedProvider]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (availableFeaturesRaw === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
|
|
||||||
if (next !== localFeatureValues) {
|
|
||||||
setLocalFeatureValues(next);
|
|
||||||
}
|
|
||||||
}, [availableFeatures, availableFeaturesRaw, localFeatureValues]);
|
|
||||||
|
|
||||||
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
|
|
||||||
const setFeatureValue = useCallback(
|
|
||||||
(featureId: string, value: unknown) => {
|
|
||||||
setLocalFeatureValues((current) => {
|
|
||||||
if (Object.is(current[featureId], value)) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...current, [featureId]: value };
|
|
||||||
});
|
|
||||||
if (!provider) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void updatePreferences((current) =>
|
|
||||||
mergeProviderPreferences({
|
|
||||||
preferences: current,
|
|
||||||
provider,
|
|
||||||
updates: {
|
|
||||||
featureValues: {
|
|
||||||
[featureId]: value,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
).catch((error) => {
|
|
||||||
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
[provider, updatePreferences],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => {
|
|
||||||
setLocalFeatureValues(values);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
features,
|
|
||||||
featureValues: effectiveFeatureValues,
|
|
||||||
isLoading: featuresQuery.isLoading,
|
|
||||||
setFeatureValue,
|
|
||||||
applyProfileFeatureValues,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { AgentProvider, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
import {
|
||||||
|
draftControlsRequest,
|
||||||
|
openDraftOptions,
|
||||||
|
ownDraftOptions,
|
||||||
|
viewDraftOptions,
|
||||||
|
} from "@/agent-controls/draft-options-model";
|
||||||
|
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
|
||||||
|
import { useFetchQuery } from "@/data/query";
|
||||||
|
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||||
|
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adapter between a draft's options model and the screen: it sends the model's complete options
|
||||||
|
* to the daemon, hands the answer back, and persists the user's toggles as last-used values.
|
||||||
|
*/
|
||||||
|
export function useDraftProviderOptions(input: {
|
||||||
|
serverId: string | null | undefined;
|
||||||
|
provider: AgentProvider | null;
|
||||||
|
cwd: string | null | undefined;
|
||||||
|
modeId: string | null | undefined;
|
||||||
|
modelId: string | null | undefined;
|
||||||
|
thinkingOptionId: string | null | undefined;
|
||||||
|
initialOptions?: ProviderOptions;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
|
||||||
|
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
|
||||||
|
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
|
||||||
|
const client = useHostRuntimeClient(serverId ?? "");
|
||||||
|
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||||
|
const { preferences, updatePreferences } = useFormPreferences();
|
||||||
|
const normalizedCwd = cwd?.trim() || "";
|
||||||
|
const persisted = preferences.providerPreferences?.[provider ?? ""]?.options;
|
||||||
|
|
||||||
|
useEffect(() => model.observeProvider(provider), [model, provider]);
|
||||||
|
useEffect(() => model.setRemembered(persisted ?? {}), [model, persisted]);
|
||||||
|
|
||||||
|
const ownOptions = ownDraftOptions(state);
|
||||||
|
const draftConfig = useMemo(
|
||||||
|
() =>
|
||||||
|
draftControlsRequest(state, {
|
||||||
|
provider,
|
||||||
|
cwd: normalizedCwd,
|
||||||
|
modeId,
|
||||||
|
modelId,
|
||||||
|
thinkingOptionId,
|
||||||
|
}),
|
||||||
|
[modeId, modelId, normalizedCwd, provider, state, thinkingOptionId],
|
||||||
|
);
|
||||||
|
const queryKey = useMemo(
|
||||||
|
() => ["providerControls", serverId ?? null, draftConfig],
|
||||||
|
[draftConfig, serverId],
|
||||||
|
);
|
||||||
|
const requestKey = JSON.stringify(queryKey);
|
||||||
|
|
||||||
|
const { data } = useFetchQuery({
|
||||||
|
queryKey,
|
||||||
|
dataShape: "value",
|
||||||
|
staleTimeMs: 5 * 60 * 1000,
|
||||||
|
enabled: Boolean(serverId && client && isConnected && draftConfig),
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!client || !draftConfig) {
|
||||||
|
throw new Error(t("workspace.terminal.hostDisconnected"));
|
||||||
|
}
|
||||||
|
const payload = await client.listProviderControls(draftConfig);
|
||||||
|
if (payload.error) {
|
||||||
|
throw new Error(payload.error);
|
||||||
|
}
|
||||||
|
return { controls: payload.controls ?? [], options: payload.options ?? {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
|
||||||
|
}, [data, model, provider, requestKey]);
|
||||||
|
|
||||||
|
const setOption = useCallback(
|
||||||
|
(key: string, value: unknown) => {
|
||||||
|
model.setOption(key, value);
|
||||||
|
if (!provider) return;
|
||||||
|
void updatePreferences((current) =>
|
||||||
|
mergeProviderPreferences({
|
||||||
|
preferences: current,
|
||||||
|
provider,
|
||||||
|
updates: { options: { [key]: value } },
|
||||||
|
}),
|
||||||
|
).catch((error) => {
|
||||||
|
console.warn("[useDraftProviderOptions] persist option preference failed", error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[model, provider, updatePreferences],
|
||||||
|
);
|
||||||
|
|
||||||
|
const view = viewDraftOptions(state, { provider, requestKey });
|
||||||
|
const viewControls = view?.controls;
|
||||||
|
const viewOptions = view?.options;
|
||||||
|
const optionControls = useMemo<ComposerOptionControls | undefined>(
|
||||||
|
() =>
|
||||||
|
viewControls && viewOptions
|
||||||
|
? { controls: viewControls, options: viewOptions, onSetOption: setOption }
|
||||||
|
: undefined,
|
||||||
|
[setOption, viewControls, viewOptions],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { optionControls, options: ownOptions, applyProfile: model.applyProfile };
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ export type ForkAgentSource = Pick<
|
|||||||
| "model"
|
| "model"
|
||||||
| "thinkingOptionId"
|
| "thinkingOptionId"
|
||||||
| "runtimeInfo"
|
| "runtimeInfo"
|
||||||
| "features"
|
| "options"
|
||||||
| "projectPlacement"
|
| "projectPlacement"
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -95,18 +95,13 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const featureValues: Record<string, unknown> = {};
|
|
||||||
for (const feature of agent.features ?? []) {
|
|
||||||
featureValues[feature.id] = feature.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider: agent.provider,
|
provider: agent.provider,
|
||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
|
||||||
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
model: agent.model ?? agent.runtimeInfo?.model ?? null,
|
||||||
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
|
||||||
featureValues,
|
providerOptions: { ...agent.options },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => {
|
|||||||
providerPreferences: {
|
providerPreferences: {
|
||||||
codex: {
|
codex: {
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
featureValues: {
|
options: {
|
||||||
fast_mode: true,
|
fast_mode: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => {
|
|||||||
},
|
},
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
updates: {
|
updates: {
|
||||||
featureValues: {
|
options: {
|
||||||
plan_mode: true,
|
plan_mode: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => {
|
|||||||
providerPreferences: {
|
providerPreferences: {
|
||||||
codex: {
|
codex: {
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
featureValues: {
|
options: {
|
||||||
fast_mode: true,
|
fast_mode: true,
|
||||||
plan_mode: true,
|
plan_mode: true,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ interface ChatAgentStateShape {
|
|||||||
model?: Agent["model"];
|
model?: Agent["model"];
|
||||||
thinkingOptionId?: Agent["thinkingOptionId"];
|
thinkingOptionId?: Agent["thinkingOptionId"];
|
||||||
runtimeInfo?: Agent["runtimeInfo"];
|
runtimeInfo?: Agent["runtimeInfo"];
|
||||||
features?: Agent["features"];
|
options?: Agent["options"];
|
||||||
|
controls?: Agent["controls"];
|
||||||
lastError?: Agent["lastError"] | null;
|
lastError?: Agent["lastError"] | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +178,8 @@ function selectChatAgentState(
|
|||||||
model: agent.model,
|
model: agent.model,
|
||||||
thinkingOptionId: agent.thinkingOptionId,
|
thinkingOptionId: agent.thinkingOptionId,
|
||||||
runtimeInfo: agent.runtimeInfo,
|
runtimeInfo: agent.runtimeInfo,
|
||||||
features: agent.features,
|
options: agent.options,
|
||||||
|
controls: agent.controls,
|
||||||
lastError: agent.lastError ?? null,
|
lastError: agent.lastError ?? null,
|
||||||
archivedAt: agent.archivedAt ?? null,
|
archivedAt: agent.archivedAt ?? null,
|
||||||
requiresAttention: agent.requiresAttention ?? false,
|
requiresAttention: agent.requiresAttention ?? false,
|
||||||
@@ -204,7 +206,8 @@ function buildChatAgentFromState(
|
|||||||
model: state.model,
|
model: state.model,
|
||||||
thinkingOptionId: state.thinkingOptionId,
|
thinkingOptionId: state.thinkingOptionId,
|
||||||
runtimeInfo: state.runtimeInfo,
|
runtimeInfo: state.runtimeInfo,
|
||||||
features: state.features,
|
options: state.options,
|
||||||
|
controls: state.controls,
|
||||||
lastError: state.lastError ?? null,
|
lastError: state.lastError ?? null,
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
};
|
};
|
||||||
@@ -637,7 +640,8 @@ function AgentPanelBody({
|
|||||||
model: agentState.model,
|
model: agentState.model,
|
||||||
thinkingOptionId: agentState.thinkingOptionId,
|
thinkingOptionId: agentState.thinkingOptionId,
|
||||||
runtimeInfo: agentState.runtimeInfo,
|
runtimeInfo: agentState.runtimeInfo,
|
||||||
features: agentState.features,
|
options: agentState.options,
|
||||||
|
controls: agentState.controls,
|
||||||
lastError: agentState.lastError ?? null,
|
lastError: agentState.lastError ?? null,
|
||||||
projectPlacement,
|
projectPlacement,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ function findLucideIcon(name: string): LucideIcon | null {
|
|||||||
return isComponent(candidate) ? (candidate as LucideIcon) : null;
|
return isComponent(candidate) ? (candidate as LucideIcon) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */
|
||||||
|
export function isLucideIconName(name: string): boolean {
|
||||||
|
return findLucideIcon(name) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
function isComponent(value: unknown): boolean {
|
function isComponent(value: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof value === "function" ||
|
typeof value === "function" ||
|
||||||
|
|||||||
@@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
effectiveModelId: string;
|
effectiveModelId: string;
|
||||||
effectiveThinkingOptionId: string;
|
effectiveThinkingOptionId: string;
|
||||||
featureValues?: Record<string, unknown>;
|
providerOptions?: Record<string, unknown>;
|
||||||
}): DraftCommandTarget {
|
}): DraftCommandTarget {
|
||||||
const cwd = input.cwd.trim();
|
const cwd = input.cwd.trim();
|
||||||
if (!cwd) {
|
if (!cwd) {
|
||||||
@@ -332,7 +332,7 @@ export function buildDraftCommandTarget(input: {
|
|||||||
...(input.effectiveThinkingOptionId
|
...(input.effectiveThinkingOptionId
|
||||||
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
? { thinkingOptionId: input.effectiveThinkingOptionId }
|
||||||
: {}),
|
: {}),
|
||||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}),
|
||||||
};
|
};
|
||||||
return { status: "ready", config };
|
return { status: "ready", config };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -284,7 +284,7 @@ describe("mergeSelectedComposerPreferences", () => {
|
|||||||
codex: {
|
codex: {
|
||||||
mode: "full-access",
|
mode: "full-access",
|
||||||
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
featureValues: { fast_mode: true },
|
options: { fast_mode: true },
|
||||||
},
|
},
|
||||||
claude: { model: "claude-sonnet-4-6" },
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
},
|
},
|
||||||
@@ -299,7 +299,7 @@ describe("mergeSelectedComposerPreferences", () => {
|
|||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
mode: "full-access",
|
mode: "full-access",
|
||||||
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
thinkingByModel: { "gpt-5.4-mini": "medium" },
|
||||||
featureValues: { fast_mode: true },
|
options: { fast_mode: true },
|
||||||
},
|
},
|
||||||
claude: { model: "claude-sonnet-4-6" },
|
claude: { model: "claude-sonnet-4-6" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -271,7 +271,7 @@ interface WorkspaceDraftSubmissionConfig {
|
|||||||
modeId: string | null;
|
modeId: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
thinkingOptionId: string | null;
|
thinkingOptionId: string | null;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
providerOptions: Record<string, unknown> | undefined;
|
||||||
target: WorkspaceTabTarget;
|
target: WorkspaceTabTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ function buildWorkspaceDraftSetupFromComposer(input: {
|
|||||||
modeId: input.composerState.effectiveModeId || null,
|
modeId: input.composerState.effectiveModeId || null,
|
||||||
model: input.composerState.effectiveModelId || null,
|
model: input.composerState.effectiveModelId || null,
|
||||||
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
|
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
|
||||||
featureValues: input.composerState.featureValues ?? {},
|
providerOptions: input.composerState.providerOptions ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -454,7 +454,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
|
|||||||
modeId: composerState.effectiveModeId || undefined,
|
modeId: composerState.effectiveModeId || undefined,
|
||||||
model: composerState.effectiveModelId || undefined,
|
model: composerState.effectiveModelId || undefined,
|
||||||
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
|
||||||
featureValues: composerState.featureValues,
|
providerOptions: composerState.providerOptions,
|
||||||
},
|
},
|
||||||
initialPrompt: text,
|
initialPrompt: text,
|
||||||
clientMessageId: `${input.draftId}:initial-message`,
|
clientMessageId: `${input.draftId}:initial-message`,
|
||||||
@@ -534,7 +534,7 @@ function buildComposerConfig(input: {
|
|||||||
return {
|
return {
|
||||||
initialServerId: serverId || null,
|
initialServerId: serverId || null,
|
||||||
initialValues: buildComposerInitialValues({ initialSetup }),
|
initialValues: buildComposerInitialValues({ initialSetup }),
|
||||||
initialFeatureValues: initialSetup?.featureValues,
|
initialProviderOptions: initialSetup?.providerOptions,
|
||||||
isVisible: true,
|
isVisible: true,
|
||||||
lockedWorkingDir: workingDir,
|
lockedWorkingDir: workingDir,
|
||||||
};
|
};
|
||||||
@@ -567,7 +567,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
|||||||
modeId: initialSetup.modeId,
|
modeId: initialSetup.modeId,
|
||||||
model: initialSetup.model,
|
model: initialSetup.model,
|
||||||
thinkingOptionId: initialSetup.thinkingOptionId,
|
thinkingOptionId: initialSetup.thinkingOptionId,
|
||||||
featureValues: initialSetup.featureValues,
|
providerOptions: initialSetup.providerOptions,
|
||||||
target: { kind: "draft", draftId, setup: initialSetup },
|
target: { kind: "draft", draftId, setup: initialSetup },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -577,7 +577,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
|
|||||||
modeId: composerState.effectiveModeId || null,
|
modeId: composerState.effectiveModeId || null,
|
||||||
model: composerState.effectiveModelId || null,
|
model: composerState.effectiveModelId || null,
|
||||||
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
|
||||||
featureValues: composerState.featureValues,
|
providerOptions: composerState.providerOptions,
|
||||||
target: { kind: "draft", draftId },
|
target: { kind: "draft", draftId },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -635,7 +635,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
|
|||||||
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
...(submission.modeId ? { modeId: submission.modeId } : {}),
|
||||||
...(submission.model ? { model: submission.model } : {}),
|
...(submission.model ? { model: submission.model } : {}),
|
||||||
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
|
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
|
||||||
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
|
...(submission.providerOptions ? { providerOptions: submission.providerOptions } : {}),
|
||||||
allowEmptyText: true,
|
allowEmptyText: true,
|
||||||
agentCreation: input.agentCreation,
|
agentCreation: input.agentCreation,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ describe("createWorkspaceAgentInBackground", () => {
|
|||||||
modeId: null,
|
modeId: null,
|
||||||
model: null,
|
model: null,
|
||||||
thinkingOptionId: null,
|
thinkingOptionId: null,
|
||||||
featureValues: {},
|
providerOptions: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => {
|
|||||||
modeId: null,
|
modeId: null,
|
||||||
model: null,
|
model: null,
|
||||||
thinkingOptionId: null,
|
thinkingOptionId: null,
|
||||||
featureValues: {},
|
providerOptions: {},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
featureValues?: Record<string, unknown>;
|
providerOptions?: Record<string, unknown>;
|
||||||
}): AgentSessionConfig {
|
}): AgentSessionConfig {
|
||||||
return {
|
return {
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
@@ -14,6 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: {
|
|||||||
...(input.modeId ? { modeId: input.modeId } : {}),
|
...(input.modeId ? { modeId: input.modeId } : {}),
|
||||||
...(input.model ? { model: input.model } : {}),
|
...(input.model ? { model: input.model } : {}),
|
||||||
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
|
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
|
||||||
...(input.featureValues ? { featureValues: input.featureValues } : {}),
|
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import type { ComposerAttachment } from "@/attachments/types";
|
|||||||
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
|
||||||
import type {
|
import type {
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
|
ProviderOptions,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
@@ -91,7 +92,8 @@ export interface Agent {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
features?: AgentFeature[];
|
options?: ProviderOptions;
|
||||||
|
controls?: AgentOptionControl[];
|
||||||
thinkingOptionId?: string | null;
|
thinkingOptionId?: string | null;
|
||||||
requiresAttention?: boolean;
|
requiresAttention?: boolean;
|
||||||
attentionReason?: "finished" | "error" | "permission" | null;
|
attentionReason?: "finished" | "error" | "permission" | null;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
featureValues?: Record<string, unknown>;
|
providerOptions?: Record<string, unknown>;
|
||||||
allowEmptyText?: boolean;
|
allowEmptyText?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,6 +95,53 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps every saved layout when a draft tab's setup carries a nested provider option", async () => {
|
||||||
|
const values = new Map<string, string>();
|
||||||
|
const storage: StateStorage = {
|
||||||
|
getItem: (name) => values.get(name) ?? null,
|
||||||
|
setItem: (name, value) => {
|
||||||
|
values.set(name, value);
|
||||||
|
},
|
||||||
|
removeItem: (name) => {
|
||||||
|
values.delete(name);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const store = createWorkspaceLayoutStore(undefined, storage);
|
||||||
|
await store.persist.rehydrate();
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey: "server:kept",
|
||||||
|
target: { kind: "agent", agentId: "agent-1" },
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
store.getState().openTab({
|
||||||
|
workspaceKey: "server:forked",
|
||||||
|
target: {
|
||||||
|
kind: "draft",
|
||||||
|
draftId: "draft-1",
|
||||||
|
setup: {
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
modeId: null,
|
||||||
|
model: null,
|
||||||
|
thinkingOptionId: null,
|
||||||
|
providerOptions: { sandbox_workspace_write: { network_access: true }, max_turns: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
intent: "reveal",
|
||||||
|
});
|
||||||
|
|
||||||
|
const restored = createWorkspaceLayoutStore(undefined, storage);
|
||||||
|
await restored.persist.rehydrate();
|
||||||
|
|
||||||
|
expect(Object.keys(restored.getState().layoutByWorkspace).sort()).toEqual([
|
||||||
|
"server:forked",
|
||||||
|
"server:kept",
|
||||||
|
]);
|
||||||
|
expect(JSON.stringify(restored.getState().layoutByWorkspace["server:forked"])).toContain(
|
||||||
|
"network_access",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([1, 2])(
|
it.each([1, 2])(
|
||||||
"restores the agent pane after parked focus in storage version %i",
|
"restores the agent pane after parked focus in storage version %i",
|
||||||
async (version) => {
|
async (version) => {
|
||||||
|
|||||||
@@ -1,14 +1,25 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
|
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
|
||||||
|
|
||||||
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
|
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Layouts saved before
|
||||||
provider: z.string(),
|
// the rename stored the same record as `featureValues`.
|
||||||
cwd: z.string(),
|
function renameLegacyFeatureValues(value: unknown): unknown {
|
||||||
modeId: z.string().nullable(),
|
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value;
|
||||||
model: z.string().nullable(),
|
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>;
|
||||||
thinkingOptionId: z.string().nullable(),
|
return "providerOptions" in rest ? rest : { ...rest, providerOptions: featureValues };
|
||||||
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
|
}
|
||||||
});
|
|
||||||
|
const WorkspaceDraftTabSetupStorageSchema = z.preprocess(
|
||||||
|
renameLegacyFeatureValues,
|
||||||
|
z.strictObject({
|
||||||
|
provider: z.string(),
|
||||||
|
cwd: z.string(),
|
||||||
|
modeId: z.string().nullable(),
|
||||||
|
model: z.string().nullable(),
|
||||||
|
thinkingOptionId: z.string().nullable(),
|
||||||
|
providerOptions: z.record(z.string(), z.json()),
|
||||||
|
}),
|
||||||
|
);
|
||||||
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
|
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
|
||||||
z.strictObject({ kind: z.literal("new_tab") }),
|
z.strictObject({ kind: z.literal("new_tab") }),
|
||||||
z.strictObject({
|
z.strictObject({
|
||||||
|
|||||||
@@ -4551,7 +4551,6 @@ describe("compact workspace sheet", () => {
|
|||||||
title: agent.id,
|
title: agent.id,
|
||||||
cwd: "/repo",
|
cwd: "/repo",
|
||||||
model: null,
|
model: null,
|
||||||
features: undefined,
|
|
||||||
thinkingOptionId: undefined,
|
thinkingOptionId: undefined,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ const AGENT_DEFAULTS: Agent = {
|
|||||||
cwd: WORKSPACE_DIRECTORY,
|
cwd: WORKSPACE_DIRECTORY,
|
||||||
workspaceId: WORKSPACE_ID,
|
workspaceId: WORKSPACE_ID,
|
||||||
model: null,
|
model: null,
|
||||||
features: undefined,
|
|
||||||
thinkingOptionId: undefined,
|
thinkingOptionId: undefined,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ const AGENT_DEFAULTS: Agent = {
|
|||||||
title: "Agent",
|
title: "Agent",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
model: null,
|
model: null,
|
||||||
features: undefined,
|
|
||||||
thinkingOptionId: undefined,
|
thinkingOptionId: undefined,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
|
|||||||
@@ -60,7 +60,6 @@ const AGENT_DEFAULTS: Agent = {
|
|||||||
cwd: "/repo/worktree",
|
cwd: "/repo/worktree",
|
||||||
workspaceId: WORKSPACE_ID,
|
workspaceId: WORKSPACE_ID,
|
||||||
model: null,
|
model: null,
|
||||||
features: undefined,
|
|
||||||
thinkingOptionId: undefined,
|
thinkingOptionId: undefined,
|
||||||
requiresAttention: false,
|
requiresAttention: false,
|
||||||
attentionReason: null,
|
attentionReason: null,
|
||||||
|
|||||||
@@ -717,13 +717,11 @@ describe("terminal emulator runtime in a real browser", () => {
|
|||||||
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
{ name: "DA1-zero", bytes: "\x1b[0c" },
|
||||||
{ name: "DA2", bytes: "\x1b[>c" },
|
{ name: "DA2", bytes: "\x1b[>c" },
|
||||||
{ name: "DA3", bytes: "\x1b[=c" },
|
{ name: "DA3", bytes: "\x1b[=c" },
|
||||||
{ name: "XTVERSION", bytes: "\x1b[>q" },
|
|
||||||
{ name: "DSR-5", bytes: "\x1b[5n" },
|
{ name: "DSR-5", bytes: "\x1b[5n" },
|
||||||
{ name: "DSR-6", bytes: "\x1b[6n" },
|
{ name: "DSR-6", bytes: "\x1b[6n" },
|
||||||
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
{ name: "DSR-?6", bytes: "\x1b[?6n" },
|
||||||
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
{ name: "DECRQM", bytes: "\x1b[1$p" },
|
||||||
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
{ name: "DECRQM-?", bytes: "\x1b[?1$p" },
|
||||||
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
|
|
||||||
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
|
||||||
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
|
||||||
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
|
||||||
|
|||||||
@@ -647,7 +647,6 @@ export class TerminalEmulatorRuntime {
|
|||||||
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "c" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
|
|
||||||
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
|
||||||
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
|
||||||
@@ -656,7 +655,6 @@ export class TerminalEmulatorRuntime {
|
|||||||
{ prefix: "?", intermediates: "$", final: "p" },
|
{ prefix: "?", intermediates: "$", final: "p" },
|
||||||
() => true,
|
() => true,
|
||||||
);
|
);
|
||||||
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
|
|
||||||
for (const code of [10, 11, 12]) {
|
for (const code of [10, 11, 12]) {
|
||||||
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
|
import { isControlActive, readSelectOption } from "@/agent-controls/option-controls";
|
||||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots";
|
import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots";
|
||||||
|
|
||||||
function createSnapshot(
|
function createSnapshot(
|
||||||
@@ -32,10 +33,39 @@ function createSnapshot(
|
|||||||
persistence: input.persistence ?? null,
|
persistence: input.persistence ?? null,
|
||||||
title: input.title ?? null,
|
title: input.title ?? null,
|
||||||
labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"],
|
labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"],
|
||||||
|
...(input.controls ? { controls: input.controls } : {}),
|
||||||
|
...(input.options ? { options: input.options } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("normalizeAgentSnapshot", () => {
|
describe("normalizeAgentSnapshot", () => {
|
||||||
|
it("keeps a closed agent's stored options and controls, so its Fast control draws", () => {
|
||||||
|
const speed = {
|
||||||
|
type: "select" as const,
|
||||||
|
key: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
defaultValue: "default",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Normal" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const agent = normalizeAgentSnapshot(
|
||||||
|
createSnapshot({
|
||||||
|
status: "closed",
|
||||||
|
controls: [speed],
|
||||||
|
options: { service_tier: "priority", sandbox_mode: "read-only" },
|
||||||
|
}),
|
||||||
|
"server-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(agent.status).toBe("closed");
|
||||||
|
expect(agent.controls).toEqual([speed]);
|
||||||
|
expect(agent.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" });
|
||||||
|
expect(readSelectOption(speed, agent.options ?? {})).toBe("priority");
|
||||||
|
expect(isControlActive(speed, agent.options ?? {})).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("round-trips identified active turns through the canonical snapshot boundary", () => {
|
it("round-trips identified active turns through the canonical snapshot boundary", () => {
|
||||||
const snapshot = createSnapshot({
|
const snapshot = createSnapshot({
|
||||||
status: "running",
|
status: "running",
|
||||||
|
|||||||
@@ -64,7 +64,8 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
|||||||
cwd: agent.cwd,
|
cwd: agent.cwd,
|
||||||
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
|
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
|
||||||
model: agent.model,
|
model: agent.model,
|
||||||
...(agent.features ? { features: agent.features } : {}),
|
...(agent.options ? { options: agent.options } : {}),
|
||||||
|
...(agent.controls ? { controls: agent.controls } : {}),
|
||||||
thinkingOptionId: agent.thinkingOptionId ?? null,
|
thinkingOptionId: agent.thinkingOptionId ?? null,
|
||||||
createdAt: agent.createdAt.toISOString(),
|
createdAt: agent.createdAt.toISOString(),
|
||||||
updatedAt: agent.updatedAt.toISOString(),
|
updatedAt: agent.updatedAt.toISOString(),
|
||||||
@@ -127,7 +128,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
|||||||
cwd: snapshot.cwd,
|
cwd: snapshot.cwd,
|
||||||
workspaceId: snapshot.workspaceId,
|
workspaceId: snapshot.workspaceId,
|
||||||
model: snapshot.model ?? null,
|
model: snapshot.model ?? null,
|
||||||
features: snapshot.features,
|
options: snapshot.options,
|
||||||
|
controls: snapshot.controls,
|
||||||
thinkingOptionId: snapshot.thinkingOptionId ?? null,
|
thinkingOptionId: snapshot.thinkingOptionId ?? null,
|
||||||
requiresAttention: snapshot.requiresAttention ?? false,
|
requiresAttention: snapshot.requiresAttention ?? false,
|
||||||
attentionReason: snapshot.attentionReason ?? null,
|
attentionReason: snapshot.attentionReason ?? null,
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function normalizeWorkspaceDraftTabSetup(
|
|||||||
thinkingOptionId: trimOptionalString(
|
thinkingOptionId: trimOptionalString(
|
||||||
typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null,
|
typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null,
|
||||||
),
|
),
|
||||||
featureValues: isPlainRecord(record.featureValues) ? { ...record.featureValues } : {},
|
providerOptions: isPlainRecord(record.providerOptions) ? { ...record.providerOptions } : {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ function workspaceDraftTabSetupsEqual(
|
|||||||
left.modeId === right.modeId &&
|
left.modeId === right.modeId &&
|
||||||
left.model === right.model &&
|
left.model === right.model &&
|
||||||
left.thinkingOptionId === right.thinkingOptionId &&
|
left.thinkingOptionId === right.thinkingOptionId &&
|
||||||
recordsShallowEqual(left.featureValues, right.featureValues)
|
recordsShallowEqual(left.providerOptions, right.providerOptions)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface WorkspaceDraftTabSetup {
|
|||||||
modeId: string | null;
|
modeId: string | null;
|
||||||
model: string | null;
|
model: string | null;
|
||||||
thinkingOptionId: string | null;
|
thinkingOptionId: string | null;
|
||||||
featureValues: Record<string, unknown>;
|
providerOptions: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceWorkingDiffTabTarget {
|
export interface WorkspaceWorkingDiffTabTarget {
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls";
|
||||||
|
import type {
|
||||||
|
AgentProfile,
|
||||||
|
AgentSnapshotPayload,
|
||||||
|
MutableDaemonConfig,
|
||||||
|
ListProviderFeaturesResponseMessage,
|
||||||
|
ProviderControlsListResponseMessage,
|
||||||
|
ServerInfoStatusPayload,
|
||||||
|
SessionInboundMessage,
|
||||||
|
SessionOutboundMessage,
|
||||||
|
} from "@getpaseo/protocol/messages";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01 once daemon floor >=
|
||||||
|
* v0.15.0.
|
||||||
|
*
|
||||||
|
* A daemon without `server_info.features.providerOptionControls` sends `features` (a control with
|
||||||
|
* its value attached) and takes composer values as `featureValues`. This is the one place that
|
||||||
|
* turns that into the shape everyone else uses: `controls` over `options`, written through
|
||||||
|
* `providerOptions`.
|
||||||
|
*/
|
||||||
|
export function usesLegacyProviderOptions(info: ServerInfoStatusPayload): boolean {
|
||||||
|
return info.features?.providerOptionControls !== true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LegacyProviderOptions {
|
||||||
|
/** Gives agent snapshots `controls` and `options`, built from their `features`. */
|
||||||
|
normalize(message: SessionOutboundMessage): SessionOutboundMessage {
|
||||||
|
switch (message.type) {
|
||||||
|
case "agent_update":
|
||||||
|
if (message.payload.kind !== "upsert") return message;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: { ...message.payload, agent: this.snapshot(message.payload.agent) },
|
||||||
|
};
|
||||||
|
case "agent_status":
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: { ...message.payload, info: this.snapshot(message.payload.info) },
|
||||||
|
};
|
||||||
|
case "fetch_agents_response":
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: {
|
||||||
|
...message.payload,
|
||||||
|
entries: message.payload.entries.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
agent: this.snapshot(entry.agent),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "fetch_agent_response":
|
||||||
|
case "agent.create.response":
|
||||||
|
case "cancel_agent_response":
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: {
|
||||||
|
...message.payload,
|
||||||
|
agent: message.payload.agent && this.snapshot(message.payload.agent),
|
||||||
|
},
|
||||||
|
} as SessionOutboundMessage;
|
||||||
|
case "workspace.create.response":
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: {
|
||||||
|
...message.payload,
|
||||||
|
...(message.payload.agent ? { agent: this.snapshot(message.payload.agent) } : {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "wait_for_finish_response":
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: {
|
||||||
|
...message.payload,
|
||||||
|
final: message.payload.final && this.snapshot(message.payload.final),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "get_daemon_config_response":
|
||||||
|
case "set_daemon_config_response":
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: { ...message.payload, config: this.daemonConfig(message.payload.config) },
|
||||||
|
};
|
||||||
|
case "fetch_agent_timeline_response":
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
payload: {
|
||||||
|
...message.payload,
|
||||||
|
agent: message.payload.agent && this.snapshot(message.payload.agent),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
case "status":
|
||||||
|
return this.status(message);
|
||||||
|
default:
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private status(
|
||||||
|
message: Extract<SessionOutboundMessage, { type: "status" }>,
|
||||||
|
): SessionOutboundMessage {
|
||||||
|
const { payload } = message;
|
||||||
|
if (payload.status === "agent_created" || payload.status === "agent_resumed") {
|
||||||
|
const { agent } = payload as unknown as { agent: AgentSnapshotPayload };
|
||||||
|
return { ...message, payload: { ...payload, agent: this.snapshot(agent) } };
|
||||||
|
}
|
||||||
|
if (payload.status === "daemon_config_changed") {
|
||||||
|
const config = payload.config as MutableDaemonConfig;
|
||||||
|
return { ...message, payload: { ...payload, config: this.daemonConfig(config) } };
|
||||||
|
}
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Splits a create's options for a legacy daemon: the ids it lists as features for that very
|
||||||
|
* config go out as `featureValues`, which is where it reads them, and the rest stay
|
||||||
|
* `providerOptions`. Which options are features depends on the model, mode and thinking level
|
||||||
|
* as well as the provider, so the ids come from a listing for this config, never from memory.
|
||||||
|
*/
|
||||||
|
routeCreateConfig<
|
||||||
|
T extends {
|
||||||
|
providerOptions?: Record<string, unknown>;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
>(config: T, featureIds: ReadonlySet<string>): T {
|
||||||
|
if (!config.providerOptions) return config;
|
||||||
|
const featureValues: Record<string, unknown> = {};
|
||||||
|
const providerOptions: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(config.providerOptions)) {
|
||||||
|
(featureIds.has(key) ? featureValues : providerOptions)[key] = value;
|
||||||
|
}
|
||||||
|
const { providerOptions: _options, ...rest } = config;
|
||||||
|
return {
|
||||||
|
...(rest as T),
|
||||||
|
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
|
||||||
|
...(Object.keys(featureValues).length > 0
|
||||||
|
? { featureValues: { ...config.featureValues, ...featureValues } }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends options as `featureValues`, which is where a legacy daemon reads them. */
|
||||||
|
prepare(message: SessionInboundMessage): SessionInboundMessage {
|
||||||
|
switch (message.type) {
|
||||||
|
case "agent.config.apply.request": {
|
||||||
|
const { options, ...config } = message.config;
|
||||||
|
if (!options) return message;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
config: { ...config, featureValues: { ...config.featureValues, ...options } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "list_commands_request": {
|
||||||
|
if (!message.draftConfig?.providerOptions) return message;
|
||||||
|
const { providerOptions, ...draftConfig } = message.draftConfig;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
draftConfig: {
|
||||||
|
...draftConfig,
|
||||||
|
featureValues: { ...draftConfig.featureValues, ...providerOptions },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "set_daemon_config_request":
|
||||||
|
if (!message.config.agentProfiles) return message;
|
||||||
|
return {
|
||||||
|
...message,
|
||||||
|
config: {
|
||||||
|
...message.config,
|
||||||
|
agentProfiles: message.config.agentProfiles.map(profileWithFeatureValues),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The legacy answer to a controls listing, in the shape of the current response. */
|
||||||
|
controlsResponse(
|
||||||
|
legacy: ListProviderFeaturesResponseMessage["payload"],
|
||||||
|
): ProviderControlsListResponseMessage["payload"] {
|
||||||
|
const { features, ...rest } = legacy;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
...(features
|
||||||
|
? { controls: controlsFromFeatures(features), options: optionsFromFeatures(features) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private daemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
|
||||||
|
if (!config.agentProfiles) return config;
|
||||||
|
return { ...config, agentProfiles: config.agentProfiles.map(profileWithOptions) };
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly snapshot = (agent: AgentSnapshotPayload): AgentSnapshotPayload => {
|
||||||
|
if (agent.controls || !agent.features) return agent;
|
||||||
|
return {
|
||||||
|
...agent,
|
||||||
|
controls: controlsFromFeatures(agent.features),
|
||||||
|
options: optionsFromFeatures(agent.features),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A legacy daemon stores a profile's options under `featureValues`. */
|
||||||
|
function profileWithOptions(profile: AgentProfile): AgentProfile {
|
||||||
|
if (profile.options || !profile.featureValues) return profile;
|
||||||
|
const { featureValues, ...rest } = profile;
|
||||||
|
return { ...rest, options: featureValues };
|
||||||
|
}
|
||||||
|
|
||||||
|
function profileWithFeatureValues(profile: AgentProfile): AgentProfile {
|
||||||
|
if (!profile.options) return profile;
|
||||||
|
const { options, ...rest } = profile;
|
||||||
|
return { ...rest, featureValues: options };
|
||||||
|
}
|
||||||
@@ -3727,6 +3727,35 @@ test("rejects an internal create before sending when the host predates internal
|
|||||||
expect(mock.sent).toHaveLength(0);
|
expect(mock.sent).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("metadata needs a host that advertises entityMetadata and sends nothing to one that does not", async () => {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "clsk_unit_test",
|
||||||
|
logger: createMockLogger(),
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
const connectPromise = client.connect();
|
||||||
|
mock.triggerOpen({ features: {} });
|
||||||
|
await connectPromise;
|
||||||
|
|
||||||
|
const update = "Update the host to use metadata.";
|
||||||
|
await expect(
|
||||||
|
client.createAgent({ provider: "codex", cwd: "/tmp/project", metadata: { key: "value" } }),
|
||||||
|
).rejects.toThrow(update);
|
||||||
|
await expect(
|
||||||
|
client.createWorkspace({
|
||||||
|
source: { kind: "directory", path: "/tmp/project" },
|
||||||
|
metadata: { key: "value" },
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(update);
|
||||||
|
await expect(client.setAgentMetadata("agent-1", { key: null })).rejects.toThrow(update);
|
||||||
|
await expect(client.setWorkspaceMetadata("workspace-1", { key: null })).rejects.toThrow(update);
|
||||||
|
expect(mock.sent).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
|
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
|
||||||
const logger = createMockLogger();
|
const logger = createMockLogger();
|
||||||
const mock = createMockTransport();
|
const mock = createMockTransport();
|
||||||
@@ -8924,3 +8953,394 @@ test("routed channels inherit an origin without drain support", async () => {
|
|||||||
expect(route.supportsDrain).toBe(false);
|
expect(route.supportsDrain).toBe(false);
|
||||||
route.close();
|
route.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("provider options across daemon versions", () => {
|
||||||
|
const agentSnapshot = {
|
||||||
|
id: "agent-1",
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
model: null,
|
||||||
|
thinkingOptionId: null,
|
||||||
|
effectiveThinkingOptionId: null,
|
||||||
|
createdAt: "2026-04-30T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-04-30T00:00:00.000Z",
|
||||||
|
lastUserMessageAt: null,
|
||||||
|
status: "idle",
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: false,
|
||||||
|
supportsSessionPersistence: false,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: false,
|
||||||
|
},
|
||||||
|
currentModeId: null,
|
||||||
|
availableModes: [],
|
||||||
|
pendingPermissions: [],
|
||||||
|
persistence: null,
|
||||||
|
title: null,
|
||||||
|
labels: {},
|
||||||
|
requiresAttention: false,
|
||||||
|
attentionReason: null,
|
||||||
|
};
|
||||||
|
const speedFeature = {
|
||||||
|
type: "select",
|
||||||
|
id: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
value: "priority",
|
||||||
|
options: [
|
||||||
|
{ id: "default", label: "Normal", isDefault: true },
|
||||||
|
{ id: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function connect(features: Record<string, boolean>) {
|
||||||
|
const mock = createMockTransport();
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: "ws://test",
|
||||||
|
clientId: "provider_options",
|
||||||
|
transportFactory: () => mock.transport,
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
});
|
||||||
|
clients.push(client);
|
||||||
|
const connecting = client.connect();
|
||||||
|
mock.triggerOpen({ features });
|
||||||
|
await connecting;
|
||||||
|
const sentMessages = () =>
|
||||||
|
mock.sent.map(
|
||||||
|
(frame) => (JSON.parse(frame as string) as { message: Record<string, unknown> }).message,
|
||||||
|
);
|
||||||
|
return { mock, client, sentMessages };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a legacy create and a timeline read return the agent with controls over options", async () => {
|
||||||
|
const { mock, client, sentMessages } = await connect({});
|
||||||
|
const legacyAgent = { ...agentSnapshot, features: [speedFeature] };
|
||||||
|
const answerLast = (reply: (requestId: string) => Record<string, unknown>) => {
|
||||||
|
const sent = sentMessages();
|
||||||
|
const requestId = String(sent[sent.length - 1].requestId);
|
||||||
|
mock.triggerMessage(wrapSessionMessage(reply(requestId) as never));
|
||||||
|
};
|
||||||
|
|
||||||
|
const creating = client.createAgent({ provider: "codex", cwd: "/repo" });
|
||||||
|
answerLast((requestId) => ({
|
||||||
|
type: "status",
|
||||||
|
payload: { status: "agent_created", requestId, agentId: "agent-1", agent: legacyAgent },
|
||||||
|
}));
|
||||||
|
const created = await creating;
|
||||||
|
|
||||||
|
const reading = client.fetchAgentTimeline("agent-1");
|
||||||
|
answerLast((requestId) => ({
|
||||||
|
type: "fetch_agent_timeline_response",
|
||||||
|
payload: {
|
||||||
|
requestId,
|
||||||
|
agentId: "agent-1",
|
||||||
|
agent: legacyAgent,
|
||||||
|
direction: "tail",
|
||||||
|
projection: "projected",
|
||||||
|
epoch: "epoch-1",
|
||||||
|
reset: false,
|
||||||
|
staleCursor: false,
|
||||||
|
gap: false,
|
||||||
|
window: { minSeq: 0, maxSeq: 0, nextSeq: 1 },
|
||||||
|
startCursor: null,
|
||||||
|
endCursor: null,
|
||||||
|
hasOlder: false,
|
||||||
|
hasNewer: false,
|
||||||
|
entries: [],
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
const timeline = await reading;
|
||||||
|
|
||||||
|
expect(created.options).toEqual({ service_tier: "priority" });
|
||||||
|
expect(created.controls).toEqual([expect.objectContaining({ key: "service_tier" })]);
|
||||||
|
expect(timeline.agent?.options).toEqual({ service_tier: "priority" });
|
||||||
|
expect(timeline.agent?.controls).toEqual([expect.objectContaining({ key: "service_tier" })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy daemon's features arrive as controls over options", async () => {
|
||||||
|
const { mock, client } = await connect({});
|
||||||
|
const received: Array<{ controls?: unknown; options?: unknown }> = [];
|
||||||
|
client.on("agent_update", (message) => {
|
||||||
|
if (message.payload.kind === "upsert") received.push(message.payload.agent);
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "agent_update",
|
||||||
|
payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(received).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
options: { service_tier: "priority" },
|
||||||
|
controls: [
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "select",
|
||||||
|
key: "service_tier",
|
||||||
|
defaultValue: "default",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Normal" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy daemon takes option writes as feature writes", async () => {
|
||||||
|
const { client, sentMessages, mock } = await connect({});
|
||||||
|
const write = client.setAgentOptions("agent-1", { service_tier: "default" });
|
||||||
|
const [request] = sentMessages();
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: "set_agent_feature_request",
|
||||||
|
agentId: "agent-1",
|
||||||
|
featureId: "service_tier",
|
||||||
|
value: "default",
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "set_agent_feature_response",
|
||||||
|
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await write;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a current daemon takes option writes as one options patch", async () => {
|
||||||
|
const { client, sentMessages, mock } = await connect({ providerOptionControls: true });
|
||||||
|
const write = client.setAgentOptions("agent-1", { service_tier: null, sandbox_mode: "x" });
|
||||||
|
const [request] = sentMessages();
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: "agent.config.apply.request",
|
||||||
|
agentId: "agent-1",
|
||||||
|
config: { options: { service_tier: null, sandbox_mode: "x" } },
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "agent.config.apply.response",
|
||||||
|
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await write;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy draft listing answers with controls and options", async () => {
|
||||||
|
const { client, sentMessages, mock } = await connect({});
|
||||||
|
const listing = client.listProviderControls({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
providerOptions: { service_tier: "default" },
|
||||||
|
});
|
||||||
|
const [request] = sentMessages();
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: "list_provider_features_request",
|
||||||
|
draftConfig: { provider: "codex", featureValues: { service_tier: "default" } },
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "list_provider_features_response",
|
||||||
|
payload: {
|
||||||
|
provider: "codex",
|
||||||
|
features: [{ ...speedFeature, value: "default" }],
|
||||||
|
error: null,
|
||||||
|
fetchedAt: "2026-04-30T00:00:00.000Z",
|
||||||
|
requestId: request.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await expect(listing).resolves.toMatchObject({
|
||||||
|
provider: "codex",
|
||||||
|
options: { service_tier: "default" },
|
||||||
|
controls: [expect.objectContaining({ key: "service_tier" })],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy daemon's profiles are read and saved by their options", async () => {
|
||||||
|
const { client, sentMessages, mock } = await connect({});
|
||||||
|
const reading = client.getDaemonConfig();
|
||||||
|
const [read] = sentMessages();
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "get_daemon_config_response",
|
||||||
|
payload: {
|
||||||
|
requestId: read.requestId,
|
||||||
|
config: {
|
||||||
|
mcp: { injectIntoAgents: true },
|
||||||
|
agentProfiles: [
|
||||||
|
{
|
||||||
|
id: "p1",
|
||||||
|
name: "Fast",
|
||||||
|
provider: "codex",
|
||||||
|
featureValues: { service_tier: "fast" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { config } = await reading;
|
||||||
|
expect(config.agentProfiles?.[0]).toMatchObject({ options: { service_tier: "fast" } });
|
||||||
|
expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues");
|
||||||
|
|
||||||
|
void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {});
|
||||||
|
const written = sentMessages().find((message) => message.type === "set_daemon_config_request");
|
||||||
|
expect(written).toMatchObject({
|
||||||
|
config: { agentProfiles: [{ featureValues: { service_tier: "fast" } }] },
|
||||||
|
});
|
||||||
|
expect(written).not.toMatchObject({ config: { agentProfiles: [{ options: {} }] } });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy daemon applies a profile's options as feature values", async () => {
|
||||||
|
const { client, sentMessages } = await connect({});
|
||||||
|
void client.applyAgentConfig("agent-1", { options: { service_tier: "fast" } }).catch(() => {});
|
||||||
|
const [request] = sentMessages();
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: "agent.config.apply.request",
|
||||||
|
config: { featureValues: { service_tier: "fast" } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a cold legacy create lists the provider's features before routing its options", async () => {
|
||||||
|
const { client, sentMessages, mock } = await connect({});
|
||||||
|
void client
|
||||||
|
.createAgent({
|
||||||
|
config: {
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/repo",
|
||||||
|
providerOptions: { fast_mode: true, sandbox_mode: "read-only" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
|
||||||
|
const [listing] = sentMessages();
|
||||||
|
expect(listing).toMatchObject({
|
||||||
|
type: "list_provider_features_request",
|
||||||
|
draftConfig: { provider: "claude", cwd: "/repo" },
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "list_provider_features_response",
|
||||||
|
payload: {
|
||||||
|
provider: "claude",
|
||||||
|
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }],
|
||||||
|
error: null,
|
||||||
|
fetchedAt: "2026-04-30T00:00:00.000Z",
|
||||||
|
requestId: listing.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
|
||||||
|
expect(sentMessages()[1].config).toMatchObject({
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
providerOptions: { sandbox_mode: "read-only" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy workspace create routes its agent's options from its own model's controls", async () => {
|
||||||
|
const { client, sentMessages, mock } = await connect({ creationLifecycle: true });
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "agent_update",
|
||||||
|
payload: {
|
||||||
|
kind: "upsert",
|
||||||
|
agent: { ...agentSnapshot, provider: "codex", model: "mini", features: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
void client
|
||||||
|
.createWorkspace({
|
||||||
|
source: { kind: "directory", path: "/repo" },
|
||||||
|
agent: {
|
||||||
|
config: {
|
||||||
|
provider: "codex",
|
||||||
|
cwd: "/repo",
|
||||||
|
model: "gpt-5.5",
|
||||||
|
providerOptions: { service_tier: "fast" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as never)
|
||||||
|
.catch(() => {});
|
||||||
|
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
|
||||||
|
const [listing] = sentMessages();
|
||||||
|
expect(listing).toMatchObject({
|
||||||
|
type: "list_provider_features_request",
|
||||||
|
draftConfig: { provider: "codex", cwd: "/repo", model: "gpt-5.5" },
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "list_provider_features_response",
|
||||||
|
payload: {
|
||||||
|
provider: "codex",
|
||||||
|
features: [speedFeature],
|
||||||
|
error: null,
|
||||||
|
fetchedAt: "2026-04-30T00:00:00.000Z",
|
||||||
|
requestId: listing.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const sentTypes = () => sentMessages().map((message) => message.type);
|
||||||
|
await vi.waitFor(() => expect(sentTypes()).toContain("workspace.create.request"));
|
||||||
|
const created = sentMessages()[sentTypes().indexOf("workspace.create.request")];
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
agent: { config: { featureValues: { service_tier: "fast" } } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a legacy create lists the controls of its own model, whatever a snapshot showed", async () => {
|
||||||
|
const { client, sentMessages, mock } = await connect({});
|
||||||
|
// A Haiku agent has no Fast control; that says nothing about Opus.
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "agent_update",
|
||||||
|
payload: {
|
||||||
|
kind: "upsert",
|
||||||
|
agent: { ...agentSnapshot, provider: "claude", model: "haiku", features: [] },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
void client
|
||||||
|
.createAgent({
|
||||||
|
config: {
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/repo",
|
||||||
|
model: "opus",
|
||||||
|
modeId: "plan",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
providerOptions: { fast_mode: true, sandbox_mode: "read-only" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
|
||||||
|
const [listing] = sentMessages();
|
||||||
|
expect(listing).toMatchObject({
|
||||||
|
type: "list_provider_features_request",
|
||||||
|
draftConfig: {
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/repo",
|
||||||
|
model: "opus",
|
||||||
|
modeId: "plan",
|
||||||
|
thinkingOptionId: "high",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
mock.triggerMessage(
|
||||||
|
wrapSessionMessage({
|
||||||
|
type: "list_provider_features_response",
|
||||||
|
payload: {
|
||||||
|
provider: "claude",
|
||||||
|
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }],
|
||||||
|
error: null,
|
||||||
|
fetchedAt: "2026-04-30T00:00:00.000Z",
|
||||||
|
requestId: listing.requestId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
|
||||||
|
expect(sentMessages()[1].config).toMatchObject({
|
||||||
|
model: "opus",
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
providerOptions: { sandbox_mode: "read-only" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from "./connection/index.js";
|
} from "./connection/index.js";
|
||||||
import { CreationClient } from "./creation/index.js";
|
import { CreationClient } from "./creation/index.js";
|
||||||
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
|
||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
|
||||||
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
|
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
|
||||||
@@ -102,6 +103,7 @@ import type {
|
|||||||
WorkspaceSetupStatusResponseMessage,
|
WorkspaceSetupStatusResponseMessage,
|
||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
ListProviderFeaturesResponseMessage,
|
ListProviderFeaturesResponseMessage,
|
||||||
|
ProviderControlsListResponseMessage,
|
||||||
ListProviderModelsResponseMessage,
|
ListProviderModelsResponseMessage,
|
||||||
ListProviderModesResponseMessage,
|
ListProviderModesResponseMessage,
|
||||||
ListAvailableProvidersResponse,
|
ListAvailableProvidersResponse,
|
||||||
@@ -184,6 +186,10 @@ import {
|
|||||||
normalizeProviderSnapshotUpdateMessage,
|
normalizeProviderSnapshotUpdateMessage,
|
||||||
normalizeProvidersSnapshotPayload,
|
normalizeProvidersSnapshotPayload,
|
||||||
} from "./compat/normalize-provider-models.js";
|
} from "./compat/normalize-provider-models.js";
|
||||||
|
import {
|
||||||
|
LegacyProviderOptions,
|
||||||
|
usesLegacyProviderOptions,
|
||||||
|
} from "./compat/legacy-provider-options.js";
|
||||||
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
|
||||||
import type {
|
import type {
|
||||||
BrowserAutomationExecuteRequest,
|
BrowserAutomationExecuteRequest,
|
||||||
@@ -504,11 +510,15 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
|||||||
worktreeName?: string;
|
worktreeName?: string;
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
/** Plugin-owned data stored on the agent. Needs a host with `entityMetadata`. */
|
||||||
|
metadata?: CreateAgentRequestMessage["metadata"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateWorkspaceRequestOptions {
|
export interface CreateWorkspaceRequestOptions {
|
||||||
source: WorkspaceCreateRequest["source"];
|
source: WorkspaceCreateRequest["source"];
|
||||||
title?: string;
|
title?: string;
|
||||||
|
/** Plugin-owned data stored on the workspace. Needs a host with `entityMetadata`. */
|
||||||
|
metadata?: WorkspaceCreateRequest["metadata"];
|
||||||
/** Workspace discovery visibility; contents retain normal durability. */
|
/** Workspace discovery visibility; contents retain normal durability. */
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
callerAgentId?: string;
|
callerAgentId?: string;
|
||||||
@@ -598,6 +608,7 @@ export interface FileUploadInput {
|
|||||||
export type FileUploadResult = FileUploadResponse["payload"];
|
export type FileUploadResult = FileUploadResponse["payload"];
|
||||||
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
|
||||||
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
|
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
|
||||||
|
export type ListProviderControlsPayload = ProviderControlsListResponseMessage["payload"];
|
||||||
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
|
||||||
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
|
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
|
||||||
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
|
||||||
@@ -624,7 +635,7 @@ type WriteProjectConfigPayload = Extract<
|
|||||||
type ListCommandsPayload = ListCommandsResponse["payload"];
|
type ListCommandsPayload = ListCommandsResponse["payload"];
|
||||||
type ListCommandsDraftConfig = Pick<
|
type ListCommandsDraftConfig = Pick<
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
|
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" | "providerOptions"
|
||||||
>;
|
>;
|
||||||
export interface WriteProjectConfigInput {
|
export interface WriteProjectConfigInput {
|
||||||
repoRoot: string;
|
repoRoot: string;
|
||||||
@@ -2089,17 +2100,13 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
|
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
|
||||||
return this.sendJsonMessage(
|
const wire = this.legacyProviderOptions()?.prepare(payload) ?? payload;
|
||||||
"session",
|
return this.sendJsonMessage("session", payload.type, { type: "session", message: wire }, () => {
|
||||||
payload.type,
|
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
||||||
{ type: "session", message: payload },
|
for (const waiter of this.waiters) {
|
||||||
() => {
|
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
||||||
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
|
}
|
||||||
for (const waiter of this.waiters) {
|
});
|
||||||
if (waiter.requestId === payload.requestId) waiter.submitted = true;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sendTransportFrame(
|
private sendTransportFrame(
|
||||||
@@ -3249,6 +3256,13 @@ export class DaemonClient {
|
|||||||
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
|
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Entity metadata is gated once at the transport boundary.
|
||||||
|
private requireEntityMetadata(): void {
|
||||||
|
if (this.lastServerInfoMessage?.features?.entityMetadata !== true) {
|
||||||
|
throw new Error("Update the host to use metadata.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Background workspaces are gated once at the transport boundary.
|
// Background workspaces are gated once at the transport boundary.
|
||||||
private requireBackgroundWorkspaces(): void {
|
private requireBackgroundWorkspaces(): void {
|
||||||
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
|
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
|
||||||
@@ -3267,14 +3281,45 @@ export class DaemonClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||||
|
if (options.metadata !== undefined) this.requireEntityMetadata();
|
||||||
|
const config = resolveAgentConfig(options);
|
||||||
const result = await this.creations.createAgent({
|
const result = await this.creations.createAgent({
|
||||||
...options,
|
...options,
|
||||||
config: resolveAgentConfig(options),
|
config: this.carriesLegacyOptions(config)
|
||||||
|
? await this.routeLegacyCreateConfig(config)
|
||||||
|
: config,
|
||||||
});
|
});
|
||||||
if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed");
|
if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed");
|
||||||
return result.agent;
|
return result.agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A legacy daemon reads control options as `featureValues`, and which options are controls
|
||||||
|
* depends on the config (Claude's differ by model), so a create that carries options first lists
|
||||||
|
* the controls for its own config. Callers never prime anything.
|
||||||
|
*/
|
||||||
|
private carriesLegacyOptions(config: AgentSessionConfig): boolean {
|
||||||
|
return (
|
||||||
|
this.legacyProviderOptions() !== null && Object.keys(config.providerOptions ?? {}).length > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async routeLegacyCreateConfig(config: AgentSessionConfig): Promise<AgentSessionConfig> {
|
||||||
|
const payload = await this.listProviderFeatures({
|
||||||
|
provider: config.provider,
|
||||||
|
cwd: config.cwd || "~",
|
||||||
|
...(config.modeId ? { modeId: config.modeId } : {}),
|
||||||
|
...(config.model ? { model: config.model } : {}),
|
||||||
|
...(config.thinkingOptionId ? { thinkingOptionId: config.thinkingOptionId } : {}),
|
||||||
|
featureValues: config.providerOptions,
|
||||||
|
});
|
||||||
|
if (payload.error) throw new Error(payload.error);
|
||||||
|
return this.legacyOptions.routeCreateConfig(
|
||||||
|
config,
|
||||||
|
new Set((payload.features ?? []).map((feature) => feature.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private async createLegacyAgent(
|
private async createLegacyAgent(
|
||||||
options: CreateAgentRequestOptions,
|
options: CreateAgentRequestOptions,
|
||||||
): Promise<AgentSnapshotPayload> {
|
): Promise<AgentSnapshotPayload> {
|
||||||
@@ -3473,6 +3518,42 @@ export class DaemonClient {
|
|||||||
return { removedWorkspaceIds: payload.removedWorkspaceIds };
|
return { removedWorkspaceIds: payload.removedWorkspaceIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Top-level keys replace; `null` removes the key. Resolves to the agent's metadata afterwards. */
|
||||||
|
async setAgentMetadata(
|
||||||
|
agentId: string,
|
||||||
|
patch: Record<string, JsonValue>,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<Record<string, JsonValue>> {
|
||||||
|
this.requireEntityMetadata();
|
||||||
|
const payload =
|
||||||
|
await this.sendNamespacedCorrelatedSessionRequest<"agent.metadata.update.response">({
|
||||||
|
requestId,
|
||||||
|
message: { type: "agent.metadata.update.request", agentId, patch },
|
||||||
|
});
|
||||||
|
if (!payload.accepted || !payload.metadata) {
|
||||||
|
throw new Error(payload.error ?? "setAgentMetadata rejected");
|
||||||
|
}
|
||||||
|
return payload.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-level keys replace; `null` removes the key. Resolves to the workspace's metadata afterwards. */
|
||||||
|
async setWorkspaceMetadata(
|
||||||
|
workspaceId: string,
|
||||||
|
patch: Record<string, JsonValue>,
|
||||||
|
requestId?: string,
|
||||||
|
): Promise<Record<string, JsonValue>> {
|
||||||
|
this.requireEntityMetadata();
|
||||||
|
const payload =
|
||||||
|
await this.sendNamespacedCorrelatedSessionRequest<"workspace.metadata.update.response">({
|
||||||
|
requestId,
|
||||||
|
message: { type: "workspace.metadata.update.request", workspaceId, patch },
|
||||||
|
});
|
||||||
|
if (!payload.accepted || !payload.metadata) {
|
||||||
|
throw new Error(payload.error ?? "setWorkspaceMetadata rejected");
|
||||||
|
}
|
||||||
|
return payload.metadata;
|
||||||
|
}
|
||||||
|
|
||||||
async setWorkspaceTitle(
|
async setWorkspaceTitle(
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
title: string | null,
|
title: string | null,
|
||||||
@@ -4065,6 +4146,24 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Changes the agent's own provider options: top-level keys replace, `null` removes the key.
|
||||||
|
* A daemon without `providerOptionControls` takes each key as a feature write.
|
||||||
|
*/
|
||||||
|
async setAgentOptions(
|
||||||
|
agentId: string,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
): Promise<AgentProviderNotice | null> {
|
||||||
|
if (this.legacyProviderOptions()) {
|
||||||
|
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
for (const [featureId, value] of Object.entries(patch)) {
|
||||||
|
await this.setAgentFeature(agentId, featureId, value);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return this.applyAgentConfig(agentId, { options: patch });
|
||||||
|
}
|
||||||
|
|
||||||
async setAgentThinkingOption(
|
async setAgentThinkingOption(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
thinkingOptionId: string | null,
|
thinkingOptionId: string | null,
|
||||||
@@ -5008,12 +5107,16 @@ export class DaemonClient {
|
|||||||
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
|
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
|
||||||
if (input.agent?.background !== undefined)
|
if (input.agent?.background !== undefined)
|
||||||
throw new Error("Configure background on the workspace, not its initial agent.");
|
throw new Error("Configure background on the workspace, not its initial agent.");
|
||||||
|
if (input.metadata !== undefined || input.agent?.metadata !== undefined)
|
||||||
|
this.requireEntityMetadata();
|
||||||
|
let agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined;
|
||||||
|
if (agentConfig && this.carriesLegacyOptions(agentConfig)) {
|
||||||
|
agentConfig = await this.routeLegacyCreateConfig(agentConfig);
|
||||||
|
}
|
||||||
const result = await this.creations.createWorkspace({
|
const result = await this.creations.createWorkspace({
|
||||||
...input,
|
...input,
|
||||||
requestId: resolvedRequestId,
|
requestId: resolvedRequestId,
|
||||||
...(input.agent
|
...(input.agent && agentConfig ? { agent: { ...input.agent, config: agentConfig } } : {}),
|
||||||
? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -5494,6 +5597,36 @@ export class DaemonClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The controls a draft agent would show and the options it would run with: `config.json`
|
||||||
|
* defaults merged with the draft's own `providerOptions`, resolved by the daemon.
|
||||||
|
*/
|
||||||
|
async listProviderControls(
|
||||||
|
draftConfig: ListCommandsDraftConfig,
|
||||||
|
options?: { requestId?: string },
|
||||||
|
): Promise<ListProviderControlsPayload> {
|
||||||
|
const legacy = this.legacyProviderOptions();
|
||||||
|
if (legacy) {
|
||||||
|
const { providerOptions, ...rest } = draftConfig;
|
||||||
|
const payload = await this.listProviderFeatures(
|
||||||
|
{
|
||||||
|
...rest,
|
||||||
|
// A legacy daemon reads the options it exposes as features, whatever the others are.
|
||||||
|
...(providerOptions ? { featureValues: providerOptions } : {}),
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
return legacy.controlsResponse(payload);
|
||||||
|
}
|
||||||
|
return this.sendCorrelatedSessionRequest({
|
||||||
|
requestId: options?.requestId,
|
||||||
|
message: { type: "provider.controls.list.request", draftConfig },
|
||||||
|
responseType: "provider.controls.list.response",
|
||||||
|
timeout: 90000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use `listProviderControls`. */
|
||||||
async listProviderFeatures(
|
async listProviderFeatures(
|
||||||
draftConfig: ListCommandsDraftConfig,
|
draftConfig: ListCommandsDraftConfig,
|
||||||
options?: { requestId?: string },
|
options?: { requestId?: string },
|
||||||
@@ -6747,6 +6880,14 @@ export class DaemonClient {
|
|||||||
return requestId ?? crypto.randomUUID();
|
return requestId ?? crypto.randomUUID();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private readonly legacyOptions = new LegacyProviderOptions();
|
||||||
|
|
||||||
|
/** The translation for a daemon without `providerOptionControls`; null for a current one. */
|
||||||
|
private legacyProviderOptions(): LegacyProviderOptions | null {
|
||||||
|
const info = this.lastServerInfoMessage;
|
||||||
|
return info && usesLegacyProviderOptions(info) ? this.legacyOptions : null;
|
||||||
|
}
|
||||||
|
|
||||||
getLastServerInfoMessage(): ServerInfoStatusPayload | null {
|
getLastServerInfoMessage(): ServerInfoStatusPayload | null {
|
||||||
return this.lastServerInfoMessage;
|
return this.lastServerInfoMessage;
|
||||||
}
|
}
|
||||||
@@ -7308,6 +7449,7 @@ export class DaemonClient {
|
|||||||
|
|
||||||
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
private handleSessionMessage(msg: SessionOutboundMessage): void {
|
||||||
msg = this.owned.normalize(msg);
|
msg = this.owned.normalize(msg);
|
||||||
|
msg = this.legacyProviderOptions()?.normalize(msg) ?? msg;
|
||||||
if (
|
if (
|
||||||
msg.type === "providers_snapshot_update" &&
|
msg.type === "providers_snapshot_update" &&
|
||||||
this.config.providerSnapshots !== "wire" &&
|
this.config.providerSnapshots !== "wire" &&
|
||||||
@@ -7596,6 +7738,7 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
|
|||||||
worktreeName: _worktreeName,
|
worktreeName: _worktreeName,
|
||||||
requestId: _requestId,
|
requestId: _requestId,
|
||||||
labels: _labels,
|
labels: _labels,
|
||||||
|
metadata: _metadata,
|
||||||
...overrides
|
...overrides
|
||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
|
|||||||
@@ -1601,7 +1601,12 @@ test("config actions delegate to existing daemon config RPCs", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("agent config maps provider/model and provider-native options to the daemon", async () => {
|
test("agent config maps provider/model and provider-native options to the daemon", async () => {
|
||||||
const { client, ws } = await connectClient();
|
const { client, ws } = await connectClient({
|
||||||
|
providerUsageList: true,
|
||||||
|
providersSnapshotCwd: true,
|
||||||
|
ownedSubscriptions: true,
|
||||||
|
providerOptionControls: true,
|
||||||
|
});
|
||||||
const createdAgent = createAgent({
|
const createdAgent = createAgent({
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
currentModeId: "full-access",
|
currentModeId: "full-access",
|
||||||
@@ -1633,8 +1638,8 @@ test("agent config maps provider/model and provider-native options to the daemon
|
|||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
featureValues: { webSearch: true },
|
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
|
webSearch: true,
|
||||||
approval_policy: "never",
|
approval_policy: "never",
|
||||||
sandbox_mode: "workspace-write",
|
sandbox_mode: "workspace-write",
|
||||||
sandbox_workspace_write: {
|
sandbox_workspace_write: {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/
|
|||||||
import type { OwnedSubscription } from "./connection/index.js";
|
import type { OwnedSubscription } from "./connection/index.js";
|
||||||
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
|
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
|
||||||
import type { DaemonClientConfig } from "./daemon-client.js";
|
import type { DaemonClientConfig } from "./daemon-client.js";
|
||||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionResponse, JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
import type {
|
import type {
|
||||||
AgentSnapshotPayload,
|
AgentSnapshotPayload,
|
||||||
CreationSnapshot,
|
CreationSnapshot,
|
||||||
@@ -20,6 +20,8 @@ import type {
|
|||||||
ListCommandsResponse,
|
ListCommandsResponse,
|
||||||
ListProviderFeaturesRequestMessage,
|
ListProviderFeaturesRequestMessage,
|
||||||
ListProviderFeaturesResponseMessage,
|
ListProviderFeaturesResponseMessage,
|
||||||
|
ProviderControlsListRequestMessage,
|
||||||
|
ProviderControlsListResponseMessage,
|
||||||
ListProviderModelsResponseMessage,
|
ListProviderModelsResponseMessage,
|
||||||
ProjectListRequestMessage,
|
ProjectListRequestMessage,
|
||||||
ProjectListResponseMessage,
|
ProjectListResponseMessage,
|
||||||
@@ -182,6 +184,8 @@ export interface PaseoWorkspaceHandle {
|
|||||||
readonly directory: string | null;
|
readonly directory: string | null;
|
||||||
readonly name: string | null;
|
readonly name: string | null;
|
||||||
readonly status: PaseoWorkspace["status"] | null;
|
readonly status: PaseoWorkspace["status"] | null;
|
||||||
|
/** Data plugins stored on the workspace. Paseo never reads a key. */
|
||||||
|
readonly metadata: NonNullable<PaseoWorkspace["metadata"]> | null;
|
||||||
readonly agents: {
|
readonly agents: {
|
||||||
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
|
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
|
||||||
};
|
};
|
||||||
@@ -189,6 +193,8 @@ export interface PaseoWorkspaceHandle {
|
|||||||
current(): PaseoWorkspace | null;
|
current(): PaseoWorkspace | null;
|
||||||
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
|
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
|
||||||
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
|
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
|
||||||
|
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
|
||||||
|
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||||
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
|
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
|
||||||
/**
|
/**
|
||||||
* Subscribes to already-emitted daemon workspace_update events for this id.
|
* Subscribes to already-emitted daemon workspace_update events for this id.
|
||||||
@@ -239,8 +245,12 @@ export interface PaseoAgentConfig {
|
|||||||
provider: string;
|
provider: string;
|
||||||
modeId?: PaseoAgentSessionConfig["modeId"];
|
modeId?: PaseoAgentSessionConfig["modeId"];
|
||||||
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
|
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
|
||||||
|
/** @deprecated Put these in `options`; they are folded into it on send. */
|
||||||
featureValues?: PaseoProviderFeatureValues;
|
featureValues?: PaseoProviderFeatureValues;
|
||||||
/** JSON-safe provider-native settings, validated by the selected provider. */
|
/**
|
||||||
|
* JSON-safe provider settings, validated by the selected provider. They include the settings the
|
||||||
|
* provider offers a composer control for, such as `service_tier` or `fast_mode`.
|
||||||
|
*/
|
||||||
options?: PaseoAgentSessionConfig["providerOptions"];
|
options?: PaseoAgentSessionConfig["providerOptions"];
|
||||||
systemPrompt?: PaseoAgentSessionConfig["systemPrompt"];
|
systemPrompt?: PaseoAgentSessionConfig["systemPrompt"];
|
||||||
toolPolicy?: PaseoAgentSessionConfig["toolPolicy"];
|
toolPolicy?: PaseoAgentSessionConfig["toolPolicy"];
|
||||||
@@ -268,6 +278,8 @@ export interface PaseoAgentCreateOptions {
|
|||||||
background?: CreateAgentRequestMessage["background"];
|
background?: CreateAgentRequestMessage["background"];
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
/** Data plugins store on the agent: JSON values under keys such as `"linear.issue"`. */
|
||||||
|
metadata?: CreateAgentRequestMessage["metadata"];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
|
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
|
||||||
@@ -364,7 +376,14 @@ export interface PaseoAgentHandle {
|
|||||||
readonly activeTurn: NonNullable<PaseoAgent["activeTurn"]> | null;
|
readonly activeTurn: NonNullable<PaseoAgent["activeTurn"]> | null;
|
||||||
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
|
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
|
||||||
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
|
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
|
||||||
|
/** @deprecated Use `controls` and `options`. */
|
||||||
readonly features: NonNullable<PaseoAgent["features"]> | null;
|
readonly features: NonNullable<PaseoAgent["features"]> | null;
|
||||||
|
/** The provider options the agent runs with: `config.json` defaults merged with its own. */
|
||||||
|
readonly options: NonNullable<PaseoAgent["options"]> | null;
|
||||||
|
/** How the provider wants its options drawn. Controls hold no value; read `options`. */
|
||||||
|
readonly controls: NonNullable<PaseoAgent["controls"]> | null;
|
||||||
|
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||||
|
readonly metadata: NonNullable<PaseoAgent["metadata"]> | null;
|
||||||
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
|
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
|
||||||
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
|
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
|
||||||
readonly timeline: PaseoAgentTimelineHandle;
|
readonly timeline: PaseoAgentTimelineHandle;
|
||||||
@@ -377,6 +396,14 @@ export interface PaseoAgentHandle {
|
|||||||
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
|
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
|
||||||
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
||||||
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
|
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Changes the 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. Resolves once the
|
||||||
|
* provider has applied the patch; a provider that cannot apply a key live rejects it.
|
||||||
|
*/
|
||||||
|
setOptions(patch: Record<string, unknown>): Promise<void>;
|
||||||
|
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
|
||||||
|
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
|
||||||
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
/** Sends a prompt and resolves when that turn finishes or needs attention. */
|
||||||
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
|
||||||
/** Waits for the current turn, including one started with `prompt`. */
|
/** Waits for the current turn, including one started with `prompt`. */
|
||||||
@@ -413,14 +440,21 @@ export interface PaseoAgentActions {
|
|||||||
|
|
||||||
export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"];
|
export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"];
|
||||||
export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"];
|
export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"];
|
||||||
type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"];
|
type PaseoProviderControlsDraft = ProviderControlsListRequestMessage["draftConfig"];
|
||||||
export interface PaseoProviderFeaturesInput extends Omit<
|
export interface PaseoProviderControlsInput extends Omit<
|
||||||
PaseoProviderFeaturesDraft,
|
PaseoProviderControlsDraft,
|
||||||
"provider" | "model"
|
"provider" | "model"
|
||||||
> {
|
> {
|
||||||
/** Provider and model in `provider/model` format. */
|
/** Provider and model in `provider/model` format. */
|
||||||
provider: string;
|
provider: string;
|
||||||
}
|
}
|
||||||
|
export type PaseoProviderControlsResult = ProviderControlsListResponseMessage["payload"];
|
||||||
|
/** @deprecated Use `PaseoProviderControlsInput`. */
|
||||||
|
export type PaseoProviderFeaturesInput = Omit<
|
||||||
|
ListProviderFeaturesRequestMessage["draftConfig"],
|
||||||
|
"provider" | "model"
|
||||||
|
> & { provider: string };
|
||||||
|
/** @deprecated Use `PaseoProviderControlsResult`. */
|
||||||
export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
|
export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
|
||||||
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
|
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
|
||||||
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
|
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
|
||||||
@@ -459,6 +493,15 @@ export interface PaseoProviderActions {
|
|||||||
provider: PaseoAgentProvider,
|
provider: PaseoAgentProvider,
|
||||||
options?: PaseoProviderListOptions,
|
options?: PaseoProviderListOptions,
|
||||||
): Promise<PaseoProviderModesResult>;
|
): Promise<PaseoProviderModesResult>;
|
||||||
|
/**
|
||||||
|
* The controls a draft agent would show and the options it would run with, resolved by the
|
||||||
|
* daemon from `config.json` defaults and the draft's own `providerOptions`.
|
||||||
|
*/
|
||||||
|
listControls(
|
||||||
|
draftConfig: PaseoProviderControlsInput,
|
||||||
|
options?: { requestId?: string },
|
||||||
|
): Promise<PaseoProviderControlsResult>;
|
||||||
|
/** @deprecated Use `listControls`. */
|
||||||
listFeatures(
|
listFeatures(
|
||||||
draftConfig: PaseoProviderFeaturesInput,
|
draftConfig: PaseoProviderFeaturesInput,
|
||||||
options?: { requestId?: string },
|
options?: { requestId?: string },
|
||||||
@@ -543,7 +586,14 @@ function toDaemonAgentCreateOptions(
|
|||||||
serverId?: string,
|
serverId?: string,
|
||||||
): CreateAgentRequestOptions {
|
): CreateAgentRequestOptions {
|
||||||
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
|
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
|
||||||
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
|
const {
|
||||||
|
provider: providerModel,
|
||||||
|
options: ownOptions,
|
||||||
|
featureValues,
|
||||||
|
...runtimeConfig
|
||||||
|
} = agentConfig;
|
||||||
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. An explicit option wins.
|
||||||
|
const providerOptions = featureValues ? { ...featureValues, ...ownOptions } : ownOptions;
|
||||||
const { provider, model } = parseProviderModel(providerModel);
|
const { provider, model } = parseProviderModel(providerModel);
|
||||||
const relationship = parent ? encodeParent(parent, serverId) : {};
|
const relationship = parent ? encodeParent(parent, serverId) : {};
|
||||||
return {
|
return {
|
||||||
@@ -776,6 +826,10 @@ export function createPaseoApi(
|
|||||||
const { provider, model } = parseProviderModel(providerModel);
|
const { provider, model } = parseProviderModel(providerModel);
|
||||||
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options);
|
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options);
|
||||||
},
|
},
|
||||||
|
listControls: ({ provider: providerModel, ...draftConfig }, options) => {
|
||||||
|
const { provider, model } = parseProviderModel(providerModel);
|
||||||
|
return daemonClient.listProviderControls({ ...draftConfig, provider, model }, options);
|
||||||
|
},
|
||||||
listAvailable: (options) => daemonClient.listAvailableProviders(options),
|
listAvailable: (options) => daemonClient.listAvailableProviders(options),
|
||||||
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
|
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
|
||||||
waitForReady: (options) =>
|
waitForReady: (options) =>
|
||||||
@@ -855,6 +909,9 @@ function createWorkspaceHandleFactory(
|
|||||||
get status() {
|
get status() {
|
||||||
return current?.status ?? null;
|
return current?.status ?? null;
|
||||||
},
|
},
|
||||||
|
get metadata() {
|
||||||
|
return current?.metadata ?? null;
|
||||||
|
},
|
||||||
agents: {
|
agents: {
|
||||||
create: async (options) => {
|
create: async (options) => {
|
||||||
const snapshot = current ?? (await refresh());
|
const snapshot = current ?? (await refresh());
|
||||||
@@ -874,6 +931,10 @@ function createWorkspaceHandleFactory(
|
|||||||
current: () => current,
|
current: () => current,
|
||||||
refresh,
|
refresh,
|
||||||
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
|
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
|
||||||
|
setMetadata: async (patch) => {
|
||||||
|
const metadata = await daemonClient.setWorkspaceMetadata(id, patch);
|
||||||
|
if (current) current = { ...current, metadata };
|
||||||
|
},
|
||||||
archive: async (requestId) => {
|
archive: async (requestId) => {
|
||||||
const result = await daemonClient.archiveWorkspace(id, requestId);
|
const result = await daemonClient.archiveWorkspace(id, requestId);
|
||||||
if (current) {
|
if (current) {
|
||||||
@@ -975,6 +1036,15 @@ function createAgentHandleFactory(
|
|||||||
get features() {
|
get features() {
|
||||||
return current?.features ?? null;
|
return current?.features ?? null;
|
||||||
},
|
},
|
||||||
|
get options() {
|
||||||
|
return current?.options ?? null;
|
||||||
|
},
|
||||||
|
get controls() {
|
||||||
|
return current?.controls ?? null;
|
||||||
|
},
|
||||||
|
get metadata() {
|
||||||
|
return current?.metadata ?? null;
|
||||||
|
},
|
||||||
get runtimeInfo() {
|
get runtimeInfo() {
|
||||||
return current?.runtimeInfo ?? null;
|
return current?.runtimeInfo ?? null;
|
||||||
},
|
},
|
||||||
@@ -996,6 +1066,13 @@ function createAgentHandleFactory(
|
|||||||
send: async (text, options) => {
|
send: async (text, options) => {
|
||||||
await daemonClient.sendAgentMessage(id, text, options);
|
await daemonClient.sendAgentMessage(id, text, options);
|
||||||
},
|
},
|
||||||
|
setOptions: async (patch) => {
|
||||||
|
await daemonClient.setAgentOptions(id, patch);
|
||||||
|
},
|
||||||
|
setMetadata: async (patch) => {
|
||||||
|
const metadata = await daemonClient.setAgentMetadata(id, patch);
|
||||||
|
if (current) current = { ...current, metadata };
|
||||||
|
},
|
||||||
respondToPermission: async ({ requestId, response }) => {
|
respondToPermission: async ({ requestId, response }) => {
|
||||||
await daemonClient.respondToPermission(id, requestId, response);
|
await daemonClient.respondToPermission(id, requestId, response);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -204,16 +204,15 @@ export function createHostTransportFactory(
|
|||||||
}
|
}
|
||||||
export function createHostClient(
|
export function createHostClient(
|
||||||
connection: string,
|
connection: string,
|
||||||
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
|
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
|
||||||
budget?: HostTransportBudget,
|
budget?: HostTransportBudget,
|
||||||
): DaemonClient {
|
): DaemonClient {
|
||||||
const target = parseHostTarget(connection);
|
const target = parseHostTarget(connection);
|
||||||
const { webSocketAgent, transportFactory, ...clientConfig } = config;
|
const { webSocketAgent, ...clientConfig } = config;
|
||||||
return new DaemonClient({
|
return new DaemonClient({
|
||||||
...clientConfig,
|
...clientConfig,
|
||||||
url: "ws://host-connector/ws",
|
url: "ws://host-connector/ws",
|
||||||
...(target.password ? { password: target.password } : {}),
|
...(target.password ? { password: target.password } : {}),
|
||||||
transportFactory:
|
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
|
||||||
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
"@types/node": "24.6.0",
|
"@types/node": "24.6.0",
|
||||||
"@types/ws": "^8.5.14",
|
"@types/ws": "^8.5.14",
|
||||||
"electron": "44.2.0",
|
"electron": "44.2.0",
|
||||||
"electron-builder": "26.18.0",
|
"electron-builder": "26.8.1",
|
||||||
"typescript": "5.9.3",
|
"typescript": "5.9.3",
|
||||||
"unzip-crx-3": "^0.2.0",
|
"unzip-crx-3": "^0.2.0",
|
||||||
"vitest": "^4.1.6",
|
"vitest": "^4.1.6",
|
||||||
|
|||||||
@@ -283,6 +283,10 @@ describe("lifecycle hook SDK types", () => {
|
|||||||
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
|
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
|
||||||
server.addHook("workspace.create.before", async () => {});
|
server.addHook("workspace.create.before", async () => {});
|
||||||
server.addHook("agent.open.before", async () => {});
|
server.addHook("agent.open.before", async () => {});
|
||||||
|
server.addHook("agent.open.before", async ({ request }) => {
|
||||||
|
const { metadata, ...withoutMetadata } = request;
|
||||||
|
return withoutMetadata;
|
||||||
|
});
|
||||||
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
|
||||||
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
const kind: "completed" | "failed" | "canceled" = outcome.kind;
|
||||||
// @ts-expect-error Notification payloads have no creation request.
|
// @ts-expect-error Notification payloads have no creation request.
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentTimelineItem,
|
AgentTimelineItem,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
|
JsonValue,
|
||||||
} from "@getpaseo/protocol/agent-types";
|
} from "@getpaseo/protocol/agent-types";
|
||||||
import type { PaseoApi } from "@getpaseo/client";
|
import type { PaseoApi } from "@getpaseo/client";
|
||||||
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
|
||||||
@@ -19,6 +20,8 @@ export interface PluginHookWorkspace {
|
|||||||
cwd: string;
|
cwd: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
archivedAt: string | null;
|
archivedAt: string | null;
|
||||||
|
/** Data plugins stored on the workspace. Paseo never reads a key. */
|
||||||
|
metadata: Record<string, JsonValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginHookWorktree {
|
export interface PluginHookWorktree {
|
||||||
@@ -68,6 +71,8 @@ export interface PluginHookAgent {
|
|||||||
provider: string;
|
provider: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
|
/** Data plugins stored on the agent. Paseo never reads a key. */
|
||||||
|
metadata: Record<string, JsonValue>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginSessionOpenRequest {
|
export interface PluginSessionOpenRequest {
|
||||||
@@ -79,6 +84,7 @@ export interface PluginSessionOpenRequest {
|
|||||||
purpose: "interactive" | "history";
|
purpose: "interactive" | "history";
|
||||||
readonly title: string | null;
|
readonly title: string | null;
|
||||||
readonly labels: Readonly<Record<string, string>>;
|
readonly labels: Readonly<Record<string, string>>;
|
||||||
|
readonly metadata: Readonly<Record<string, JsonValue>>;
|
||||||
tools: AgentToolDefinition[];
|
tools: AgentToolDefinition[];
|
||||||
env: Record<string, string>;
|
env: Record<string, string>;
|
||||||
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
|
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
|
||||||
@@ -116,15 +122,27 @@ export interface PluginHookEvents {
|
|||||||
|
|
||||||
export interface PluginHookRequests {
|
export interface PluginHookRequests {
|
||||||
"worktree.create.before": PluginWorktreeCreateRequest;
|
"worktree.create.before": PluginWorktreeCreateRequest;
|
||||||
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
|
"agent.create.before": {
|
||||||
|
config: AgentSessionConfig;
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** Stored on the new agent. Always present in the payload; return it changed to change it. */
|
||||||
|
metadata?: Record<string, JsonValue>;
|
||||||
|
};
|
||||||
"agent.open.before": PluginSessionOpenRequest;
|
"agent.open.before": PluginSessionOpenRequest;
|
||||||
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
|
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** What a hook may return. An omitted `metadata` stays as it was. */
|
||||||
|
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
|
||||||
|
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
|
||||||
|
metadata?: Readonly<Record<string, JsonValue>>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export type PluginHooks = {
|
export type PluginHooks = {
|
||||||
[Name in keyof PluginHookRequests]: {
|
[Name in keyof PluginHookRequests]: {
|
||||||
request: PluginHookRequests[Name];
|
request: PluginHookRequests[Name];
|
||||||
result: PluginHookRequests[Name];
|
result: PluginHookRequestResults[Name];
|
||||||
};
|
};
|
||||||
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
|
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
|
||||||
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
|
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
|
||||||
|
|||||||
@@ -116,7 +116,9 @@ export interface ProviderSessionConfig {
|
|||||||
model?: string;
|
model?: string;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
thinkingOption?: string;
|
thinkingOption?: string;
|
||||||
|
/** @deprecated The same values as `providerOptions`. */
|
||||||
settings: Readonly<Record<string, JsonValue>>;
|
settings: Readonly<Record<string, JsonValue>>;
|
||||||
|
/** The agent's options, the provider defaults from `config.json` included. */
|
||||||
providerOptions?: Readonly<Record<string, unknown>>;
|
providerOptions?: Readonly<Record<string, unknown>>;
|
||||||
title?: string;
|
title?: string;
|
||||||
persist: boolean;
|
persist: boolean;
|
||||||
@@ -126,6 +128,13 @@ export interface ProviderConfigChanges {
|
|||||||
model?: string | null;
|
model?: string | null;
|
||||||
mode?: string | null;
|
mode?: string | null;
|
||||||
thinkingOption?: string | null;
|
thinkingOption?: string | null;
|
||||||
|
/**
|
||||||
|
* The option keys to change on the running session, with or without a control. Apply every key
|
||||||
|
* or fail the whole request, for a key you only read when a session opens. `null` returns the
|
||||||
|
* key to the provider's own default. The committed values come back in `session.config`.
|
||||||
|
*/
|
||||||
|
providerOptions?: Readonly<Record<string, JsonValue>>;
|
||||||
|
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
|
||||||
settings?: Readonly<Record<string, JsonValue>>;
|
settings?: Readonly<Record<string, JsonValue>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,6 +320,34 @@ export interface ProviderMode {
|
|||||||
isUnattended?: boolean;
|
isUnattended?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws one top-level option key in the composer. The control holds no value: Paseo reads it
|
||||||
|
* from the agent's options.
|
||||||
|
*/
|
||||||
|
export type ProviderOptionControl =
|
||||||
|
| {
|
||||||
|
type: "toggle";
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
/** A Lucide icon name. */
|
||||||
|
icon?: string;
|
||||||
|
/** Shown while the key has no value. */
|
||||||
|
defaultValue?: boolean;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "select";
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
/** A Lucide icon name. */
|
||||||
|
icon?: string;
|
||||||
|
/** The choice shown while the key has no value. */
|
||||||
|
defaultValue?: string;
|
||||||
|
choices: ReadonlyArray<{ value: string; label: string; description?: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated Report `controls` and `providerOptions` instead. */
|
||||||
export type ProviderSetting =
|
export type ProviderSetting =
|
||||||
| {
|
| {
|
||||||
type: "toggle";
|
type: "toggle";
|
||||||
@@ -335,7 +372,12 @@ export interface ProviderConfigState {
|
|||||||
models: readonly ProviderModel[];
|
models: readonly ProviderModel[];
|
||||||
modes: readonly ProviderMode[];
|
modes: readonly ProviderMode[];
|
||||||
thinkingOptions: readonly ProviderThinkingOption[];
|
thinkingOptions: readonly ProviderThinkingOption[];
|
||||||
settings: readonly ProviderSetting[];
|
/** The option keys that get a composer control. May change at runtime, for example per model. */
|
||||||
|
controls?: readonly ProviderOptionControl[];
|
||||||
|
/** What the session runs with now, for the keys it reports. Publish the committed values. */
|
||||||
|
providerOptions?: Readonly<Record<string, JsonValue>>;
|
||||||
|
/** @deprecated Report `controls` and `providerOptions`. Each setting id is an option key. */
|
||||||
|
settings?: readonly ProviderSetting[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderCatalog {
|
export interface ProviderCatalog {
|
||||||
@@ -888,6 +930,7 @@ const configChangesSchema = z
|
|||||||
model: z.string().nullable().optional(),
|
model: z.string().nullable().optional(),
|
||||||
mode: z.string().nullable().optional(),
|
mode: z.string().nullable().optional(),
|
||||||
thinkingOption: z.string().nullable().optional(),
|
thinkingOption: z.string().nullable().optional(),
|
||||||
|
providerOptions: jsonObjectSchema.optional(),
|
||||||
settings: jsonObjectSchema.optional(),
|
settings: jsonObjectSchema.optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -1024,6 +1067,33 @@ const settingSchema = z.discriminatedUnion("type", [
|
|||||||
})
|
})
|
||||||
.strip(),
|
.strip(),
|
||||||
]);
|
]);
|
||||||
|
const controlSchema = z.discriminatedUnion("type", [
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("toggle"),
|
||||||
|
key: idSchema,
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
defaultValue: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strip(),
|
||||||
|
z
|
||||||
|
.object({
|
||||||
|
type: z.literal("select"),
|
||||||
|
key: idSchema,
|
||||||
|
label: z.string(),
|
||||||
|
description: z.string().optional(),
|
||||||
|
icon: z.string().optional(),
|
||||||
|
defaultValue: z.string().optional(),
|
||||||
|
choices: z.array(
|
||||||
|
z
|
||||||
|
.object({ value: z.string(), label: z.string(), description: z.string().optional() })
|
||||||
|
.strip(),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
.strip(),
|
||||||
|
]);
|
||||||
const configStateSchema = z
|
const configStateSchema = z
|
||||||
.object({
|
.object({
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
@@ -1032,7 +1102,9 @@ const configStateSchema = z
|
|||||||
models: z.array(modelSchema),
|
models: z.array(modelSchema),
|
||||||
modes: z.array(modeSchema),
|
modes: z.array(modeSchema),
|
||||||
thinkingOptions: z.array(selectOptionSchema),
|
thinkingOptions: z.array(selectOptionSchema),
|
||||||
settings: z.array(settingSchema),
|
controls: z.array(controlSchema).optional(),
|
||||||
|
providerOptions: jsonObjectSchema.optional(),
|
||||||
|
settings: z.array(settingSchema).optional(),
|
||||||
})
|
})
|
||||||
.strip();
|
.strip();
|
||||||
const catalogSchema = z
|
const catalogSchema = z
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ export const AgentProfileSchema = z
|
|||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
thinkingOptionId: 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(),
|
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. */
|
/** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
|
||||||
notes: z.string().optional(),
|
notes: z.string().optional(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import type { AgentMessage } from "./agent-message.js";
|
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;
|
export type AgentProvider = string;
|
||||||
|
|
||||||
@@ -154,6 +161,20 @@ export interface AgentFeatureSelect {
|
|||||||
|
|
||||||
export type AgentFeature = AgentFeatureToggle | 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 {
|
export interface AgentCapabilityFlags {
|
||||||
[capability: string]: boolean | undefined;
|
[capability: string]: boolean | undefined;
|
||||||
supportsStreaming: boolean;
|
supportsStreaming: boolean;
|
||||||
@@ -523,6 +544,10 @@ export interface AgentSessionConfig {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: 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>;
|
featureValues?: Record<string, unknown>;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
providerOptions?: ProviderOptions;
|
providerOptions?: ProviderOptions;
|
||||||
|
|||||||
@@ -303,6 +303,9 @@ import type {
|
|||||||
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
|
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
|
||||||
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
|
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
|
||||||
|
|
||||||
|
/** Plugin-owned data on an agent or workspace. Paseo stores it and never reads a key. */
|
||||||
|
const EntityMetadataSchema = z.record(z.string(), JsonWireValueSchema);
|
||||||
|
|
||||||
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
|
||||||
|
|
||||||
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
|
||||||
@@ -361,6 +364,39 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
|
|||||||
AgentFeatureSelectSchema,
|
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({
|
const AgentModelDefinitionSchema = z.object({
|
||||||
provider: AgentProviderSchema,
|
provider: AgentProviderSchema,
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
@@ -490,6 +526,7 @@ const AgentSessionConfigSchema = z.object({
|
|||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
thinkingOptionId: 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(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||||
providerOptions: ProviderOptionsSchema.optional(),
|
providerOptions: ProviderOptionsSchema.optional(),
|
||||||
@@ -866,7 +903,11 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
workspaceId: z.string().optional(),
|
workspaceId: z.string().optional(),
|
||||||
model: z.string().nullable(),
|
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(),
|
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(),
|
thinkingOptionId: z.string().nullable().optional(),
|
||||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@@ -884,6 +925,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
|||||||
lastError: z.string().optional(),
|
lastError: z.string().optional(),
|
||||||
title: z.string().nullable(),
|
title: z.string().nullable(),
|
||||||
labels: z.record(z.string(), z.string()).default({}),
|
labels: z.record(z.string(), z.string()).default({}),
|
||||||
|
metadata: EntityMetadataSchema.optional(),
|
||||||
requiresAttention: z.boolean().optional(),
|
requiresAttention: z.boolean().optional(),
|
||||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||||
attentionTimestamp: z.string().nullable().optional(),
|
attentionTimestamp: z.string().nullable().optional(),
|
||||||
@@ -1030,6 +1072,22 @@ export const WorkspaceTitleSetRequestSchema = z.object({
|
|||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AgentMetadataUpdateRequestSchema = z.object({
|
||||||
|
type: z.literal("agent.metadata.update.request"),
|
||||||
|
agentId: z.string(),
|
||||||
|
// Top-level keys replace; null removes the key.
|
||||||
|
patch: EntityMetadataSchema,
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WorkspaceMetadataUpdateRequestSchema = z.object({
|
||||||
|
type: z.literal("workspace.metadata.update.request"),
|
||||||
|
workspaceId: z.string(),
|
||||||
|
// Top-level keys replace; null removes the key.
|
||||||
|
patch: EntityMetadataSchema,
|
||||||
|
requestId: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
export const WorkspacePinSetRequestSchema = z.object({
|
export const WorkspacePinSetRequestSchema = z.object({
|
||||||
type: z.literal("workspace.pin.set.request"),
|
type: z.literal("workspace.pin.set.request"),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
@@ -1788,6 +1846,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
|||||||
// Workspace creation intent, rejected when selecting an existing workspace.
|
// Workspace creation intent, rejected when selecting an existing workspace.
|
||||||
background: z.boolean().optional(),
|
background: z.boolean().optional(),
|
||||||
labels: z.record(z.string(), z.string()).default({}),
|
labels: z.record(z.string(), z.string()).default({}),
|
||||||
|
metadata: EntityMetadataSchema.optional(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2055,7 +2114,10 @@ export const AgentConfigApplySchema = z.object({
|
|||||||
modelId: z.string().nullable().optional(),
|
modelId: z.string().nullable().optional(),
|
||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
thinkingOptionId: z.string().nullable().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(),
|
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>;
|
export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
|
||||||
@@ -2163,6 +2225,28 @@ export const WorkspaceTitleSetResponseSchema = z.object({
|
|||||||
payload: WorkspaceTitleSetResponsePayloadSchema,
|
payload: WorkspaceTitleSetResponsePayloadSchema,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const AgentMetadataUpdateResponseSchema = z.object({
|
||||||
|
type: z.literal("agent.metadata.update.response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
agentId: z.string(),
|
||||||
|
accepted: z.boolean(),
|
||||||
|
metadata: EntityMetadataSchema.nullable(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const WorkspaceMetadataUpdateResponseSchema = z.object({
|
||||||
|
type: z.literal("workspace.metadata.update.response"),
|
||||||
|
payload: z.object({
|
||||||
|
requestId: z.string(),
|
||||||
|
workspaceId: z.string(),
|
||||||
|
accepted: z.boolean(),
|
||||||
|
metadata: EntityMetadataSchema.nullable(),
|
||||||
|
error: z.string().nullable(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const WorkspacePinSetResponsePayloadSchema = z.object({
|
export const WorkspacePinSetResponsePayloadSchema = z.object({
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
workspaceId: z.string(),
|
workspaceId: z.string(),
|
||||||
@@ -2718,6 +2802,7 @@ export const WorkspaceCreateRequestSchema = z.object({
|
|||||||
idempotencyKey: z.string().min(1).max(512).optional(),
|
idempotencyKey: z.string().min(1).max(512).optional(),
|
||||||
// Optional user-set title applied to the created workspace.
|
// Optional user-set title applied to the created workspace.
|
||||||
title: z.string().optional(),
|
title: z.string().optional(),
|
||||||
|
metadata: EntityMetadataSchema.optional(),
|
||||||
// Optional prompt context for workspace-level name/branch generation.
|
// Optional prompt context for workspace-level name/branch generation.
|
||||||
firstAgentContext: FirstAgentContextSchema.optional(),
|
firstAgentContext: FirstAgentContextSchema.optional(),
|
||||||
// Retained for wire parsing; public internal creation is no longer supported.
|
// Retained for wire parsing; public internal creation is no longer supported.
|
||||||
@@ -2971,7 +3056,9 @@ const ListCommandsDraftConfigSchema = z.object({
|
|||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
thinkingOptionId: 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(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
providerOptions: ProviderOptionsSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const ListProviderFeaturesRequestMessageSchema = z.object({
|
export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||||
@@ -2980,6 +3067,12 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
|
|||||||
requestId: z.string(),
|
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({
|
export const ListCommandsRequestSchema = z.object({
|
||||||
type: z.literal("list_commands_request"),
|
type: z.literal("list_commands_request"),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
@@ -3157,6 +3250,7 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
|||||||
model: z.string().optional(),
|
model: z.string().optional(),
|
||||||
modeId: z.string().optional(),
|
modeId: z.string().optional(),
|
||||||
thinkingOptionId: 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(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
providerOptions: ProviderOptionsSchema.optional(),
|
providerOptions: ProviderOptionsSchema.optional(),
|
||||||
toolPolicy: ToolPolicySchema.optional(),
|
toolPolicy: ToolPolicySchema.optional(),
|
||||||
@@ -3305,6 +3399,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ProjectIconSetRequestSchema,
|
ProjectIconSetRequestSchema,
|
||||||
ProjectRemoveRequestSchema,
|
ProjectRemoveRequestSchema,
|
||||||
WorkspaceTitleSetRequestSchema,
|
WorkspaceTitleSetRequestSchema,
|
||||||
|
AgentMetadataUpdateRequestSchema,
|
||||||
|
WorkspaceMetadataUpdateRequestSchema,
|
||||||
WorkspacePinSetRequestSchema,
|
WorkspacePinSetRequestSchema,
|
||||||
WorkspaceLabelListRequestSchema,
|
WorkspaceLabelListRequestSchema,
|
||||||
WorkspaceLabelAssignmentSetRequestSchema,
|
WorkspaceLabelAssignmentSetRequestSchema,
|
||||||
@@ -3357,6 +3453,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModelsRequestMessageSchema,
|
ListProviderModelsRequestMessageSchema,
|
||||||
ListProviderModesRequestMessageSchema,
|
ListProviderModesRequestMessageSchema,
|
||||||
ListProviderFeaturesRequestMessageSchema,
|
ListProviderFeaturesRequestMessageSchema,
|
||||||
|
ProviderControlsListRequestMessageSchema,
|
||||||
ListAvailableProvidersRequestMessageSchema,
|
ListAvailableProvidersRequestMessageSchema,
|
||||||
GetProvidersSnapshotRequestMessageSchema,
|
GetProvidersSnapshotRequestMessageSchema,
|
||||||
RefreshProvidersSnapshotRequestMessageSchema,
|
RefreshProvidersSnapshotRequestMessageSchema,
|
||||||
@@ -3836,6 +3933,8 @@ export const ServerInfoStatusPayloadSchema = z
|
|||||||
selectiveAgentTimeline: z.boolean().optional(),
|
selectiveAgentTimeline: z.boolean().optional(),
|
||||||
explicitEventSubscriptions: z.boolean().optional(),
|
explicitEventSubscriptions: z.boolean().optional(),
|
||||||
ownedSubscriptions: z.boolean().optional(),
|
ownedSubscriptions: z.boolean().optional(),
|
||||||
|
providerOptionControls: z.boolean().optional(),
|
||||||
|
entityMetadata: z.boolean().optional(),
|
||||||
binaryChunkingV1: z.boolean().optional(),
|
binaryChunkingV1: z.boolean().optional(),
|
||||||
workspaceSetupObservation: z.boolean().optional(),
|
workspaceSetupObservation: z.boolean().optional(),
|
||||||
workspaceSortTimes: z.boolean().optional(),
|
workspaceSortTimes: z.boolean().optional(),
|
||||||
@@ -4170,6 +4269,7 @@ export const WorkspaceDescriptorPayloadSchema = z
|
|||||||
pinnedAt: z.string().nullable().optional(),
|
pinnedAt: z.string().nullable().optional(),
|
||||||
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
|
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
|
||||||
labels: z.array(z.string()).optional(),
|
labels: z.array(z.string()).optional(),
|
||||||
|
metadata: EntityMetadataSchema.optional(),
|
||||||
// Retained for wire parsing of older daemon descriptors.
|
// Retained for wire parsing of older daemon descriptors.
|
||||||
internal: z.boolean().optional(),
|
internal: z.boolean().optional(),
|
||||||
background: z.boolean().optional(),
|
background: z.boolean().optional(),
|
||||||
@@ -6303,6 +6403,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({
|
export const ListProviderFeaturesResponseMessageSchema = z.object({
|
||||||
type: z.literal("list_provider_features_response"),
|
type: z.literal("list_provider_features_response"),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
@@ -7135,6 +7248,8 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ProjectIconSetResponseSchema,
|
ProjectIconSetResponseSchema,
|
||||||
ProjectRemoveResponseSchema,
|
ProjectRemoveResponseSchema,
|
||||||
WorkspaceTitleSetResponseSchema,
|
WorkspaceTitleSetResponseSchema,
|
||||||
|
AgentMetadataUpdateResponseSchema,
|
||||||
|
WorkspaceMetadataUpdateResponseSchema,
|
||||||
WorkspacePinSetResponseSchema,
|
WorkspacePinSetResponseSchema,
|
||||||
WorkspaceRecoveryInspectResponseSchema,
|
WorkspaceRecoveryInspectResponseSchema,
|
||||||
WorkspaceRecoveryRestoreResponseSchema,
|
WorkspaceRecoveryRestoreResponseSchema,
|
||||||
@@ -7195,6 +7310,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
ListProviderModesResponseMessageSchema,
|
ListProviderModesResponseMessageSchema,
|
||||||
ListProviderFeaturesResponseMessageSchema,
|
ListProviderFeaturesResponseMessageSchema,
|
||||||
|
ProviderControlsListResponseMessageSchema,
|
||||||
ListAvailableProvidersResponseSchema,
|
ListAvailableProvidersResponseSchema,
|
||||||
GetProvidersSnapshotResponseMessageSchema,
|
GetProvidersSnapshotResponseMessageSchema,
|
||||||
ProvidersSnapshotUpdateMessageSchema,
|
ProvidersSnapshotUpdateMessageSchema,
|
||||||
@@ -7449,6 +7565,12 @@ export type ListProviderModelsRequestMessage = z.infer<
|
|||||||
typeof ListProviderModelsRequestMessageSchema
|
typeof ListProviderModelsRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
|
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<
|
export type ListProviderFeaturesRequestMessage = z.infer<
|
||||||
typeof ListProviderFeaturesRequestMessageSchema
|
typeof ListProviderFeaturesRequestMessageSchema
|
||||||
>;
|
>;
|
||||||
@@ -7494,6 +7616,10 @@ export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
|
|||||||
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
|
||||||
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
|
||||||
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
|
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
|
||||||
|
export type AgentMetadataUpdateRequest = z.infer<typeof AgentMetadataUpdateRequestSchema>;
|
||||||
|
export type WorkspaceMetadataUpdateRequest = z.infer<typeof WorkspaceMetadataUpdateRequestSchema>;
|
||||||
|
export type AgentMetadataUpdateResponse = z.infer<typeof AgentMetadataUpdateResponseSchema>;
|
||||||
|
export type WorkspaceMetadataUpdateResponse = z.infer<typeof WorkspaceMetadataUpdateResponseSchema>;
|
||||||
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
|
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
|
||||||
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
|
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
|
||||||
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type {
|
||||||
|
AgentFeature,
|
||||||
|
AgentOptionControl,
|
||||||
|
AgentOptionSelectControl,
|
||||||
|
ProviderOptions,
|
||||||
|
} from "./agent-types.js";
|
||||||
|
|
||||||
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
// `features` are controls with their value attached. The daemon builds them for apps that
|
||||||
|
// predate controls, and the client package rebuilds controls and options from them when it
|
||||||
|
// talks to a daemon that predates controls. Both directions live here so they stay inverse.
|
||||||
|
|
||||||
|
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 } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Agent records and daemons that predate controls carry `features`; their descriptors become controls. */
|
||||||
|
export function controlsFromFeatures(
|
||||||
|
features: readonly AgentFeature[] | undefined,
|
||||||
|
): AgentOptionControl[] {
|
||||||
|
return (features ?? []).map(controlFromFeature);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The options a list of `features` stands for: each feature's value under its id. */
|
||||||
|
export function optionsFromFeatures(
|
||||||
|
features: readonly AgentFeature[] | undefined,
|
||||||
|
): ProviderOptions {
|
||||||
|
return Object.fromEntries((features ?? []).map((feature) => [feature.id, feature.value]));
|
||||||
|
}
|
||||||
@@ -34,6 +34,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
|
|||||||
isolation: z.enum(["local", "worktree"]).optional(),
|
isolation: z.enum(["local", "worktree"]).optional(),
|
||||||
title: z.string().trim().min(1).nullable().optional(),
|
title: z.string().trim().min(1).nullable().optional(),
|
||||||
providerOptions: z.record(z.string(), z.unknown()).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(),
|
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||||
systemPrompt: z.string().optional(),
|
systemPrompt: z.string().optional(),
|
||||||
mcpServers: z.record(z.string(), z.unknown()).optional(),
|
mcpServers: z.record(z.string(), z.unknown()).optional(),
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export async function ensureAgentLoaded(
|
|||||||
}
|
}
|
||||||
snapshot = await deps.agentManager.createAgent(config, agentId, {
|
snapshot = await deps.agentManager.createAgent(config, agentId, {
|
||||||
labels: record.labels,
|
labels: record.labels,
|
||||||
|
metadata: record.metadata,
|
||||||
workspaceId: record.workspaceId,
|
workspaceId: record.workspaceId,
|
||||||
owner: record.owner,
|
owner: record.owner,
|
||||||
env: record.launchEnv,
|
env: record.launchEnv,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { AgentStorage } from "./agent-storage.js";
|
|||||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||||
import { toAgentPayload } from "./agent-projections.js";
|
import { toAgentPayload } from "./agent-projections.js";
|
||||||
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
import { projectTimelineRows } from "./timeline-projection.js";
|
import { projectTimelineRows } from "./timeline-projection.js";
|
||||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||||
import { startAgentRun } from "./agent-prompt.js";
|
import { startAgentRun } from "./agent-prompt.js";
|
||||||
@@ -38,7 +39,7 @@ import type {
|
|||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentCreateSessionOptions,
|
AgentCreateSessionOptions,
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentPromptInput,
|
AgentPromptInput,
|
||||||
AgentProvider,
|
AgentProvider,
|
||||||
@@ -57,6 +58,7 @@ import type {
|
|||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||||
import type { ProviderDefinition } from "./provider-registry.js";
|
import type { ProviderDefinition } from "./provider-registry.js";
|
||||||
|
import { mergeProviderOptions } from "./provider-options.js";
|
||||||
|
|
||||||
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
||||||
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-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 {
|
function createControl(args: { key: string; label: string }): AgentOptionControl {
|
||||||
return {
|
return { type: "toggle", key: args.key, label: args.label };
|
||||||
type: "toggle",
|
|
||||||
id: args.id,
|
|
||||||
label: args.label,
|
|
||||||
value: args.value,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function expectArchivedAgentRecord(
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
@@ -2365,18 +2362,21 @@ test("listDraftFeatures does not start a fallback session without a model", asyn
|
|||||||
logger,
|
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.fetchCatalogCalls).toBe(0);
|
||||||
expect(client.createSessionCalls).toBe(0);
|
expect(client.createSessionCalls).toBe(0);
|
||||||
expect(client.availabilityCalls).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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
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 {
|
class DraftFeatureClient extends TestAgentClient {
|
||||||
fetchCatalogCalls = 0;
|
fetchCatalogCalls = 0;
|
||||||
createSessionCalls = 0;
|
createSessionCalls = 0;
|
||||||
@@ -2398,9 +2398,9 @@ test("listDraftFeatures uses client feature listing without a model", async () =
|
|||||||
return await super.createSession(config);
|
return await super.createSession(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
this.featureConfigs.push(config);
|
this.featureConfigs.push(config);
|
||||||
return [draftFeature];
|
return [draftControl];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const client = new DraftFeatureClient();
|
const client = new DraftFeatureClient();
|
||||||
@@ -2412,9 +2412,13 @@ test("listDraftFeatures uses client feature listing without a model", async () =
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
|
await expect(
|
||||||
draftFeature,
|
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.fetchCatalogCalls).toBe(0);
|
||||||
expect(client.createSessionCalls).toBe(0);
|
expect(client.createSessionCalls).toBe(0);
|
||||||
@@ -2423,15 +2427,28 @@ test("listDraftFeatures uses client feature listing without a model", async () =
|
|||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
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 {
|
class DraftFeatureClient extends TestAgentClient {
|
||||||
fetchCatalogCalls = 0;
|
fetchCatalogCalls = 0;
|
||||||
createSessionCalls = 0;
|
createSessionCalls = 0;
|
||||||
@@ -2447,9 +2464,9 @@ test("listDraftFeatures uses explicit model config without default model fetchin
|
|||||||
return await super.createSession(config);
|
return await super.createSession(config);
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
this.featureConfigs.push(config);
|
this.featureConfigs.push(config);
|
||||||
return [draftFeature];
|
return [draftControl];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const client = new DraftFeatureClient();
|
const client = new DraftFeatureClient();
|
||||||
@@ -2461,13 +2478,13 @@ test("listDraftFeatures uses explicit model config without default model fetchin
|
|||||||
logger,
|
logger,
|
||||||
});
|
});
|
||||||
|
|
||||||
const features = await manager.listDraftFeatures({
|
const draft = await manager.listDraftControls({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(features).toEqual([draftFeature]);
|
expect(draft).toEqual({ controls: [draftControl], options: {} });
|
||||||
expect(client.fetchCatalogCalls).toBe(0);
|
expect(client.fetchCatalogCalls).toBe(0);
|
||||||
expect(client.createSessionCalls).toBe(0);
|
expect(client.createSessionCalls).toBe(0);
|
||||||
expect(client.featureConfigs).toEqual([
|
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 () => {
|
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
|
||||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
|
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
@@ -9984,16 +10041,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
|||||||
expect(client.resumedModeId).toBe("acceptEdits");
|
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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||||
const storagePath = join(workdir, "agents");
|
const storagePath = join(workdir, "agents");
|
||||||
const storage = new AgentStorage(storagePath, logger);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
class RefreshingPermissionSession extends TestAgentSession {
|
class RefreshingPermissionSession extends TestAgentSession {
|
||||||
private featureState: AgentFeature[] = [
|
private planMode = true;
|
||||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
||||||
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
|
||||||
];
|
|
||||||
private modeId = "auto";
|
private modeId = "auto";
|
||||||
private pending = [
|
private pending = [
|
||||||
{
|
{
|
||||||
@@ -10005,8 +10059,11 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
return this.featureState;
|
return [
|
||||||
|
createControl({ key: "web_search", label: "Fast" }),
|
||||||
|
createControl({ key: "plan_mode", label: "Plan" }),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
override async getRuntimeInfo() {
|
override async getRuntimeInfo() {
|
||||||
@@ -10015,7 +10072,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
|||||||
sessionId: this.id,
|
sessionId: this.id,
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
modeId: this.modeId,
|
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> {
|
override async respondToPermission(): Promise<void> {
|
||||||
this.modeId = "auto";
|
this.modeId = "auto";
|
||||||
this.pending = [];
|
this.pending = [];
|
||||||
this.featureState = [
|
this.planMode = false;
|
||||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
this.pushEvent({
|
||||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
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",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
|
providerOptions: { web_search: true, plan_mode: true },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: undefined },
|
{ workspaceId: undefined },
|
||||||
@@ -10080,20 +10140,14 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
|||||||
|
|
||||||
const updated = manager.getAgent(snapshot.id);
|
const updated = manager.getAgent(snapshot.id);
|
||||||
expect(updated?.pendingPermissions.size).toBe(0);
|
expect(updated?.pendingPermissions.size).toBe(0);
|
||||||
expect(updated?.features).toEqual([
|
expect(updated?.effectiveOptions).toEqual({ web_search: true, plan_mode: false });
|
||||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
||||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
|
||||||
]);
|
|
||||||
expect(updated?.runtimeInfo).toMatchObject({
|
expect(updated?.runtimeInfo).toMatchObject({
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
extra: { collaborationMode: "Code" },
|
extra: { collaborationMode: "Code" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const persisted = await storage.get(snapshot.id);
|
const persisted = await storage.get(snapshot.id);
|
||||||
expect(persisted?.features).toEqual([
|
expect(persisted?.config?.providerOptions).toEqual({ web_search: true, plan_mode: false });
|
||||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
||||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
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);
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
|
||||||
class OrderedPermissionSession extends TestAgentSession {
|
class OrderedPermissionSession extends TestAgentSession {
|
||||||
private featureState: AgentFeature[] = [
|
|
||||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
|
||||||
];
|
|
||||||
private modeId = "plan";
|
private modeId = "plan";
|
||||||
private pending = [
|
private pending = [
|
||||||
{
|
{
|
||||||
@@ -10116,8 +10167,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
return this.featureState;
|
return [createControl({ key: "web_search", label: "Fast" })];
|
||||||
}
|
}
|
||||||
|
|
||||||
override async getRuntimeInfo() {
|
override async getRuntimeInfo() {
|
||||||
@@ -10138,6 +10189,11 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
}
|
}
|
||||||
|
|
||||||
override async respondToPermission(): Promise<void> {
|
override async respondToPermission(): Promise<void> {
|
||||||
|
this.pushEvent({
|
||||||
|
type: "options_changed",
|
||||||
|
provider: this.provider,
|
||||||
|
patch: { web_search: false },
|
||||||
|
});
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
type: "permission_resolved",
|
type: "permission_resolved",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@@ -10145,7 +10201,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
resolution: { behavior: "allow" },
|
resolution: { behavior: "allow" },
|
||||||
});
|
});
|
||||||
this.modeId = "acceptEdits";
|
this.modeId = "acceptEdits";
|
||||||
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
|
||||||
this.pending = [];
|
this.pending = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10169,6 +10224,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
|
providerOptions: { web_search: true },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: undefined },
|
{ workspaceId: undefined },
|
||||||
@@ -10180,9 +10236,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||||
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
|
||||||
seen.push(
|
seen.push(
|
||||||
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
|
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.web_search)}`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -12420,6 +12475,7 @@ test.each([
|
|||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
title: null,
|
title: null,
|
||||||
|
metadata: {},
|
||||||
},
|
},
|
||||||
turnId: "hook-turn",
|
turnId: "hook-turn",
|
||||||
outcome,
|
outcome,
|
||||||
@@ -12695,3 +12751,478 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
|
|||||||
expect(h.commands).toEqual(["/maintenance"]);
|
expect(h.commands).toEqual(["/maintenance"]);
|
||||||
expect(h.prompts).toEqual([]);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
|
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
|
||||||
|
import { ProviderOptionsValidationError } from "./provider-options.js";
|
||||||
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
||||||
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
||||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||||
import { projectTimelineRows } from "./timeline-projection.js";
|
import { projectTimelineRows } from "./timeline-projection.js";
|
||||||
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
|
||||||
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
|
||||||
|
import { applyRecordPatch } from "../record-patch.js";
|
||||||
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { basename, resolve } from "node:path";
|
import { basename, resolve } from "node:path";
|
||||||
@@ -33,7 +35,7 @@ import {
|
|||||||
type AgentCreateSessionOptions,
|
type AgentCreateSessionOptions,
|
||||||
type AgentResumePurpose,
|
type AgentResumePurpose,
|
||||||
type AgentResumeSessionOptions,
|
type AgentResumeSessionOptions,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentSlashCommand,
|
type AgentSlashCommand,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -59,7 +61,16 @@ import {
|
|||||||
type ListImportableSessionsOptions,
|
type ListImportableSessionsOptions,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||||
|
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
import { 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 { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||||
import type { AgentOwner } from "./agent-owner.js";
|
import type { AgentOwner } from "./agent-owner.js";
|
||||||
import {
|
import {
|
||||||
@@ -210,9 +221,6 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
|||||||
if (record.config.thinkingOptionId != null) {
|
if (record.config.thinkingOptionId != null) {
|
||||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||||
}
|
}
|
||||||
if (record.config.featureValues != null) {
|
|
||||||
config.featureValues = record.config.featureValues;
|
|
||||||
}
|
|
||||||
if (record.config.providerOptions != null) {
|
if (record.config.providerOptions != null) {
|
||||||
config.providerOptions = record.config.providerOptions;
|
config.providerOptions = record.config.providerOptions;
|
||||||
}
|
}
|
||||||
@@ -317,6 +325,7 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
|
|||||||
|
|
||||||
export interface CreateAgentOptions {
|
export interface CreateAgentOptions {
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
metadata?: AgentMetadata;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
persistSession?: boolean;
|
persistSession?: boolean;
|
||||||
@@ -428,7 +437,9 @@ interface ManagedAgentBase {
|
|||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
availableModes: AgentMode[];
|
availableModes: AgentMode[];
|
||||||
features?: AgentFeature[];
|
controls?: AgentOptionControl[];
|
||||||
|
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
|
||||||
|
effectiveOptions?: ProviderOptions;
|
||||||
currentModeId: string | null;
|
currentModeId: string | null;
|
||||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||||
bufferedPermissionResolutions: Map<
|
bufferedPermissionResolutions: Map<
|
||||||
@@ -456,6 +467,8 @@ interface ManagedAgentBase {
|
|||||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||||
*/
|
*/
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
|
/** Plugin-owned data. Paseo persists and forwards it and never reads a key. */
|
||||||
|
metadata: AgentMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ManagedAgentWithSession = ManagedAgentBase & {
|
type ManagedAgentWithSession = ManagedAgentBase & {
|
||||||
@@ -515,6 +528,7 @@ type ActiveManagedAgent =
|
|||||||
|
|
||||||
type LiveManagedAgent = ActiveManagedAgent;
|
type LiveManagedAgent = ActiveManagedAgent;
|
||||||
type AgentLabelPatch = Record<string, string | null>;
|
type AgentLabelPatch = Record<string, string | null>;
|
||||||
|
type AgentMetadata = Record<string, JsonValue>;
|
||||||
|
|
||||||
function attachManagedTurnIdentity(
|
function attachManagedTurnIdentity(
|
||||||
agent: ActiveManagedAgent,
|
agent: ActiveManagedAgent,
|
||||||
@@ -562,6 +576,7 @@ interface WriteLabelsResult {
|
|||||||
interface AgentMetadataPatch {
|
interface AgentMetadataPatch {
|
||||||
title?: string;
|
title?: string;
|
||||||
labels?: AgentLabelPatch;
|
labels?: AgentLabelPatch;
|
||||||
|
metadata?: Record<string, JsonValue | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
const SYSTEM_ERROR_PREFIX = "[System Error]";
|
||||||
@@ -620,6 +635,11 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
|
|||||||
return Object.assign(new Error(message), { name: "AbortError" });
|
return Object.assign(new Error(message), { name: "AbortError" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The user labels and plugin metadata an agent carries; both default to empty. */
|
||||||
|
function agentEntityData(source?: { labels?: Record<string, string>; metadata?: AgentMetadata }) {
|
||||||
|
return { labels: source?.labels ?? {}, metadata: source?.metadata ?? {} };
|
||||||
|
}
|
||||||
|
|
||||||
function validateAgentId(agentId: string, source: string): string {
|
function validateAgentId(agentId: string, source: string): string {
|
||||||
const result = AgentIdSchema.safeParse(agentId);
|
const result = AgentIdSchema.safeParse(agentId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
@@ -628,21 +648,6 @@ function validateAgentId(agentId: string, source: string): string {
|
|||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyLabelPatch(
|
|
||||||
labels: Record<string, string>,
|
|
||||||
patch: AgentLabelPatch,
|
|
||||||
): Record<string, string> {
|
|
||||||
const nextLabels = { ...labels };
|
|
||||||
for (const [key, value] of Object.entries(patch)) {
|
|
||||||
if (value === null) {
|
|
||||||
delete nextLabels[key];
|
|
||||||
} else {
|
|
||||||
nextLabels[key] = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nextLabels;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildExplicitTimelineSeedForRegister(
|
function buildExplicitTimelineSeedForRegister(
|
||||||
now: Date,
|
now: Date,
|
||||||
options:
|
options:
|
||||||
@@ -1243,11 +1248,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 normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
||||||
const client = this.requireClient(normalizedConfig.provider);
|
const client = this.requireClient(normalizedConfig.provider);
|
||||||
if (!normalizedConfig.model && !client.listFeatures) {
|
const options = this.effectiveOptionsFor(
|
||||||
return [];
|
normalizedConfig.provider,
|
||||||
|
normalizedConfig.providerOptions,
|
||||||
|
);
|
||||||
|
if (!normalizedConfig.model && !client.listControls) {
|
||||||
|
return { controls: [], options };
|
||||||
}
|
}
|
||||||
const available = await client.isAvailable();
|
const available = await client.isAvailable();
|
||||||
if (!available) {
|
if (!available) {
|
||||||
@@ -1256,13 +1267,18 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (client.listFeatures) {
|
if (client.listControls) {
|
||||||
return await client.listFeatures(normalizedConfig);
|
return { controls: await client.listControls(normalizedConfig), options };
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await client.createSession(normalizedConfig);
|
const session = await client.createSession(normalizedConfig);
|
||||||
try {
|
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 {
|
} finally {
|
||||||
try {
|
try {
|
||||||
await session.close();
|
await session.close();
|
||||||
@@ -1360,13 +1376,25 @@ export class AgentManager {
|
|||||||
): Promise<ManagedAgent> {
|
): Promise<ManagedAgent> {
|
||||||
this.assertAcceptingAgentRegistrations();
|
this.assertAcceptingAgentRegistrations();
|
||||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||||
|
config = this.withConvertedOptions(config);
|
||||||
if (this.pluginLifecycle && !config.internal) {
|
if (this.pluginLifecycle && !config.internal) {
|
||||||
|
const sent = exposeFeatureValuesToHook(config);
|
||||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||||
config,
|
config: sent,
|
||||||
env: options.env,
|
env: options.env,
|
||||||
|
metadata: options.metadata ?? {},
|
||||||
});
|
});
|
||||||
config = { ...request.config, internal: config.internal };
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||||
options = { ...options, env: request.env };
|
// against the previous SDK edits `config.featureValues`.
|
||||||
|
config = {
|
||||||
|
...foldHookFeatureValues({
|
||||||
|
returned: request.config,
|
||||||
|
sent,
|
||||||
|
convert: (values) => this.convertLegacyOptions(request.config.provider, values),
|
||||||
|
}),
|
||||||
|
internal: config.internal,
|
||||||
|
};
|
||||||
|
options = { ...options, env: request.env, metadata: request.metadata ?? options.metadata };
|
||||||
}
|
}
|
||||||
await this.deleteAgentState(resolvedAgentId);
|
await this.deleteAgentState(resolvedAgentId);
|
||||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||||
@@ -1391,14 +1419,18 @@ export class AgentManager {
|
|||||||
workspaceId: options.workspaceId ?? null,
|
workspaceId: options.workspaceId ?? null,
|
||||||
title: options.initialTitle ?? storedConfig.title ?? null,
|
title: options.initialTitle ?? storedConfig.title ?? null,
|
||||||
labels: options.labels ?? {},
|
labels: options.labels ?? {},
|
||||||
|
metadata: options.metadata ?? {},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
const createOptions = this.buildCreateSessionOptions(options);
|
const createOptions = this.buildCreateSessionOptions(options);
|
||||||
|
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||||
|
launchedOptions,
|
||||||
labels: options.labels,
|
labels: options.labels,
|
||||||
|
metadata: options.metadata,
|
||||||
initialTitle: options.initialTitle,
|
initialTitle: options.initialTitle,
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
owner: options.owner,
|
owner: options.owner,
|
||||||
@@ -1432,6 +1464,7 @@ export class AgentManager {
|
|||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
lastUserMessageAt?: Date | null;
|
lastUserMessageAt?: Date | null;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
metadata?: AgentMetadata;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
owner?: AgentOwner;
|
owner?: AgentOwner;
|
||||||
attention?: AttentionState;
|
attention?: AttentionState;
|
||||||
@@ -1464,6 +1497,7 @@ export class AgentManager {
|
|||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
lastUserMessageAt?: Date | null;
|
lastUserMessageAt?: Date | null;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
metadata?: AgentMetadata;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
owner?: AgentOwner;
|
owner?: AgentOwner;
|
||||||
attention?: AttentionState;
|
attention?: AttentionState;
|
||||||
@@ -1516,10 +1550,11 @@ export class AgentManager {
|
|||||||
purpose,
|
purpose,
|
||||||
workspaceId: options?.workspaceId ?? null,
|
workspaceId: options?.workspaceId ?? null,
|
||||||
title: record?.title ?? mergedConfig.title ?? null,
|
title: record?.title ?? mergedConfig.title ?? null,
|
||||||
labels: record?.labels ?? options?.labels ?? {},
|
...agentEntityData(record ?? options),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
|
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||||
const session = await client.resumeSession(
|
const session = await client.resumeSession(
|
||||||
handle,
|
handle,
|
||||||
providerLaunchConfig,
|
providerLaunchConfig,
|
||||||
@@ -1528,6 +1563,7 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||||
|
launchedOptions,
|
||||||
...options,
|
...options,
|
||||||
launchEnv,
|
launchEnv,
|
||||||
persistence: handle,
|
persistence: handle,
|
||||||
@@ -1583,6 +1619,7 @@ export class AgentManager {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
|
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||||
const imported = await client.importSession(
|
const imported = await client.importSession(
|
||||||
{
|
{
|
||||||
providerHandleId: input.providerHandleId,
|
providerHandleId: input.providerHandleId,
|
||||||
@@ -1601,6 +1638,7 @@ export class AgentManager {
|
|||||||
|
|
||||||
handedToRegistration = true;
|
handedToRegistration = true;
|
||||||
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||||
|
launchedOptions,
|
||||||
labels: input.labels,
|
labels: input.labels,
|
||||||
workspaceId: input.workspaceId,
|
workspaceId: input.workspaceId,
|
||||||
timelineRows,
|
timelineRows,
|
||||||
@@ -1683,6 +1721,7 @@ export class AgentManager {
|
|||||||
workspaceId: existing.workspaceId,
|
workspaceId: existing.workspaceId,
|
||||||
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
|
||||||
labels: existing.labels,
|
labels: existing.labels,
|
||||||
|
metadata: existing.metadata,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||||
@@ -1706,6 +1745,7 @@ export class AgentManager {
|
|||||||
this.assertAcceptingAgentRegistrations();
|
this.assertAcceptingAgentRegistrations();
|
||||||
|
|
||||||
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
|
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
|
||||||
|
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||||
session = handle
|
session = handle
|
||||||
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
||||||
: await client.createSession(providerLaunchConfig, launchContext);
|
: await client.createSession(providerLaunchConfig, launchContext);
|
||||||
@@ -1724,7 +1764,9 @@ export class AgentManager {
|
|||||||
// Preserve existing labels and timeline during reload.
|
// Preserve existing labels and timeline during reload.
|
||||||
handedToRegistration = true;
|
handedToRegistration = true;
|
||||||
return this.registerSession(session, storedConfig, agentId, {
|
return this.registerSession(session, storedConfig, agentId, {
|
||||||
|
launchedOptions,
|
||||||
labels: existing.labels,
|
labels: existing.labels,
|
||||||
|
metadata: existing.metadata,
|
||||||
workspaceId: existing.workspaceId,
|
workspaceId: existing.workspaceId,
|
||||||
owner: existing.owner,
|
owner: existing.owner,
|
||||||
launchEnv: existing.launchEnv,
|
launchEnv: existing.launchEnv,
|
||||||
@@ -2161,7 +2203,11 @@ export class AgentManager {
|
|||||||
createdAt: new Date(record.createdAt),
|
createdAt: new Date(record.createdAt),
|
||||||
updatedAt,
|
updatedAt,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
features: record.features,
|
controls: record.controls,
|
||||||
|
effectiveOptions: this.effectiveOptionsFor(
|
||||||
|
record.provider,
|
||||||
|
record.config?.providerOptions ?? undefined,
|
||||||
|
),
|
||||||
currentModeId: record.lastModeId ?? null,
|
currentModeId: record.lastModeId ?? null,
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
bufferedPermissionResolutions: new Map(),
|
bufferedPermissionResolutions: new Map(),
|
||||||
@@ -2181,6 +2227,7 @@ export class AgentManager {
|
|||||||
attention,
|
attention,
|
||||||
internal: record.internal,
|
internal: record.internal,
|
||||||
labels: record.labels,
|
labels: record.labels,
|
||||||
|
metadata: record.metadata,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2254,20 +2301,109 @@ export class AgentManager {
|
|||||||
return notice;
|
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);
|
const agent = this.requireAgent(agentId);
|
||||||
|
const patch = this.convertLegacyOptions(agent.provider, requested);
|
||||||
if (!agent.session.setFeature) {
|
let ended: ProviderOptionsPatch = {};
|
||||||
throw new Error("Agent session does not support setting features");
|
let sent: ProviderOptionsPatch = {};
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
// A provider that cannot apply options to a running agent must not store them either.
|
||||||
|
if (!agent.session.applyOptions) {
|
||||||
|
throw new ProviderOptionsValidationError(
|
||||||
|
agent.provider,
|
||||||
|
Object.keys(patch).map((key) => ({
|
||||||
|
path: [key],
|
||||||
|
message: "cannot change on a running session; it applies when the agent is opened",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
|
||||||
|
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
||||||
|
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
|
||||||
|
ended = (await agent.session.applyOptions(sent)) ?? {};
|
||||||
|
// Changes the provider made on its own while applying are older than its final answer.
|
||||||
|
await this.drainSessionEvents(agentId);
|
||||||
}
|
}
|
||||||
|
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
|
||||||
await agent.session.setFeature(featureId, value);
|
this.updateEffectiveOptions({ agent, sent, reported: ended });
|
||||||
await this.drainSessionEvents(agentId);
|
|
||||||
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
|
|
||||||
this.touchUpdatedAt(agent);
|
this.touchUpdatedAt(agent);
|
||||||
this.emitState(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> {
|
async setTitle(agentId: string, title: string): Promise<void> {
|
||||||
const agent = this.requireAgent(agentId);
|
const agent = this.requireAgent(agentId);
|
||||||
const normalizedTitle = title.trim();
|
const normalizedTitle = title.trim();
|
||||||
@@ -2293,10 +2429,30 @@ export class AgentManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Applies a plugin-metadata patch (null removes a key) and returns the stored result. */
|
||||||
|
async setMetadata(
|
||||||
|
agentId: string,
|
||||||
|
patch: Record<string, JsonValue | null>,
|
||||||
|
): Promise<AgentMetadata> {
|
||||||
|
return this.runLifecycleMutation(agentId, async () => {
|
||||||
|
const liveAgent = this.agents.get(agentId);
|
||||||
|
if (liveAgent) {
|
||||||
|
liveAgent.metadata = applyRecordPatch<JsonValue>(liveAgent.metadata, patch);
|
||||||
|
this.touchUpdatedAt(liveAgent);
|
||||||
|
await this.persistSnapshot(liveAgent);
|
||||||
|
this.emitState(liveAgent, { persist: false });
|
||||||
|
return liveAgent.metadata;
|
||||||
|
}
|
||||||
|
const record = await this.writeStoredMetadata(agentId, { metadata: patch });
|
||||||
|
if (!record.internal) this.dispatchStoredAgentState(record);
|
||||||
|
return record.metadata;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||||
const liveAgent = this.agents.get(agentId);
|
const liveAgent = this.agents.get(agentId);
|
||||||
if (liveAgent) {
|
if (liveAgent) {
|
||||||
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
|
liveAgent.labels = applyRecordPatch(liveAgent.labels, patch);
|
||||||
this.touchUpdatedAt(liveAgent);
|
this.touchUpdatedAt(liveAgent);
|
||||||
await this.persistSnapshot(liveAgent);
|
await this.persistSnapshot(liveAgent);
|
||||||
this.emitState(liveAgent, { persist: false });
|
this.emitState(liveAgent, { persist: false });
|
||||||
@@ -2321,7 +2477,8 @@ export class AgentManager {
|
|||||||
const nextRecord = {
|
const nextRecord = {
|
||||||
...record,
|
...record,
|
||||||
...(patch.title ? { title: patch.title } : {}),
|
...(patch.title ? { title: patch.title } : {}),
|
||||||
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
|
...(patch.labels ? { labels: applyRecordPatch(record.labels, patch.labels) } : {}),
|
||||||
|
...(patch.metadata ? { metadata: applyRecordPatch(record.metadata, patch.metadata) } : {}),
|
||||||
updatedAt: this.nextStoredUpdatedAt(record),
|
updatedAt: this.nextStoredUpdatedAt(record),
|
||||||
};
|
};
|
||||||
await registry.upsert(nextRecord);
|
await registry.upsert(nextRecord);
|
||||||
@@ -2500,7 +2657,7 @@ export class AgentManager {
|
|||||||
await registry.upsert({
|
await registry.upsert({
|
||||||
...record,
|
...record,
|
||||||
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
|
||||||
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
|
...(updates?.labels ? { labels: applyRecordPatch(record.labels, updates.labels) } : {}),
|
||||||
archivedAt: null,
|
archivedAt: null,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
@@ -3780,13 +3937,14 @@ export class AgentManager {
|
|||||||
|
|
||||||
private async registerSession(
|
private async registerSession(
|
||||||
session: AgentSession,
|
session: AgentSession,
|
||||||
config: AgentSessionConfig,
|
openedConfig: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
options?: {
|
options: {
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
lastUserMessageAt?: Date | null;
|
lastUserMessageAt?: Date | null;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
metadata?: AgentMetadata;
|
||||||
timeline?: AgentTimelineItem[];
|
timeline?: AgentTimelineItem[];
|
||||||
timelineRows?: AgentTimelineRow[];
|
timelineRows?: AgentTimelineRow[];
|
||||||
timelineNextSeq?: number;
|
timelineNextSeq?: number;
|
||||||
@@ -3800,6 +3958,12 @@ export class AgentManager {
|
|||||||
* attention come from what was already recorded, and installing the session is not
|
* attention come from what was already recorded, and installing the session is not
|
||||||
* activity in it.
|
* 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;
|
restoring?: boolean;
|
||||||
initialTitle?: string | null;
|
initialTitle?: string | null;
|
||||||
publishWhenReady?: boolean;
|
publishWhenReady?: boolean;
|
||||||
@@ -3811,6 +3975,7 @@ export class AgentManager {
|
|||||||
let registered = false;
|
let registered = false;
|
||||||
try {
|
try {
|
||||||
this.assertAcceptingAgentRegistrations();
|
this.assertAcceptingAgentRegistrations();
|
||||||
|
const config = this.withOpenedOptions({ config: openedConfig, session });
|
||||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||||
if (this.agents.has(resolvedAgentId)) {
|
if (this.agents.has(resolvedAgentId)) {
|
||||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||||
@@ -3832,6 +3997,10 @@ export class AgentManager {
|
|||||||
resolvedAgentId,
|
resolvedAgentId,
|
||||||
session,
|
session,
|
||||||
config,
|
config,
|
||||||
|
effectiveOptions: this.openedEffectiveOptions({
|
||||||
|
session,
|
||||||
|
launchedOptions: options.launchedOptions,
|
||||||
|
}),
|
||||||
now,
|
now,
|
||||||
durableTimelineHasRows,
|
durableTimelineHasRows,
|
||||||
options,
|
options,
|
||||||
@@ -3976,6 +4145,7 @@ export class AgentManager {
|
|||||||
resolvedAgentId: string;
|
resolvedAgentId: string;
|
||||||
session: AgentSession;
|
session: AgentSession;
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
|
effectiveOptions: ProviderOptions;
|
||||||
now: Date;
|
now: Date;
|
||||||
durableTimelineHasRows: boolean;
|
durableTimelineHasRows: boolean;
|
||||||
options:
|
options:
|
||||||
@@ -3984,6 +4154,7 @@ export class AgentManager {
|
|||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
lastUserMessageAt?: Date | null;
|
lastUserMessageAt?: Date | null;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
metadata?: AgentMetadata;
|
||||||
historyPrimed?: boolean;
|
historyPrimed?: boolean;
|
||||||
lastUsage?: AgentUsage;
|
lastUsage?: AgentUsage;
|
||||||
lastError?: string;
|
lastError?: string;
|
||||||
@@ -3995,7 +4166,15 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
}): ActiveManagedAgent {
|
}): ActiveManagedAgent {
|
||||||
const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
|
const {
|
||||||
|
resolvedAgentId,
|
||||||
|
session,
|
||||||
|
config,
|
||||||
|
effectiveOptions,
|
||||||
|
now,
|
||||||
|
durableTimelineHasRows,
|
||||||
|
options,
|
||||||
|
} = params;
|
||||||
return {
|
return {
|
||||||
id: resolvedAgentId,
|
id: resolvedAgentId,
|
||||||
provider: config.provider,
|
provider: config.provider,
|
||||||
@@ -4006,6 +4185,8 @@ export class AgentManager {
|
|||||||
session,
|
session,
|
||||||
capabilities: session.capabilities,
|
capabilities: session.capabilities,
|
||||||
config,
|
config,
|
||||||
|
// What the session opened with. Provider defaults edited later reach it when it next opens.
|
||||||
|
effectiveOptions,
|
||||||
runtimeInfo: undefined,
|
runtimeInfo: undefined,
|
||||||
lifecycle: "initializing",
|
lifecycle: "initializing",
|
||||||
createdAt: options?.createdAt ?? now,
|
createdAt: options?.createdAt ?? now,
|
||||||
@@ -4032,7 +4213,7 @@ export class AgentManager {
|
|||||||
lastError: options?.lastError,
|
lastError: options?.lastError,
|
||||||
attention: resolveInitialAttention(options?.attention),
|
attention: resolveInitialAttention(options?.attention),
|
||||||
internal: config.internal ?? false,
|
internal: config.internal ?? false,
|
||||||
labels: options?.labels ?? {},
|
...agentEntityData(options),
|
||||||
} as ActiveManagedAgent;
|
} as ActiveManagedAgent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4311,7 +4492,7 @@ export class AgentManager {
|
|||||||
agent.pendingPermissions.clear();
|
agent.pendingPermissions.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncFeaturesFromSession(agent);
|
this.syncControls(agent);
|
||||||
await this.refreshRuntimeInfo(agent, options);
|
await this.refreshRuntimeInfo(agent, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4657,6 +4838,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: {
|
private dispatchStreamEventByType(params: {
|
||||||
agent: ActiveManagedAgent;
|
agent: ActiveManagedAgent;
|
||||||
event: AgentStreamEvent;
|
event: AgentStreamEvent;
|
||||||
@@ -4708,6 +4901,9 @@ export class AgentManager {
|
|||||||
flags.shouldDispatchEvent = false;
|
flags.shouldDispatchEvent = false;
|
||||||
this.emitState(agent);
|
this.emitState(agent);
|
||||||
return undefined;
|
return undefined;
|
||||||
|
case "options_changed":
|
||||||
|
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
|
||||||
|
return undefined;
|
||||||
case "timeline":
|
case "timeline":
|
||||||
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
||||||
case "turn_completed":
|
case "turn_completed":
|
||||||
@@ -5183,7 +5379,7 @@ export class AgentManager {
|
|||||||
this.enqueueBackgroundPersist(agent);
|
this.enqueueBackgroundPersist(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncFeaturesFromSession(agent);
|
this.syncControls(agent);
|
||||||
|
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
@@ -5205,9 +5401,9 @@ export class AgentManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private syncFeaturesFromSession(agent: ManagedAgent): void {
|
private syncControls(agent: ManagedAgent): void {
|
||||||
if ("session" in agent && agent.session?.features) {
|
if ("session" in agent && agent.session?.controls) {
|
||||||
agent.features = agent.session.features;
|
agent.controls = agent.session.controls;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5454,7 +5650,7 @@ export class AgentManager {
|
|||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
options: NormalizeConfigOptions = {},
|
options: NormalizeConfigOptions = {},
|
||||||
): Promise<AgentSessionConfig> {
|
): Promise<AgentSessionConfig> {
|
||||||
const normalized: AgentSessionConfig = { ...config };
|
const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) };
|
||||||
|
|
||||||
// Always resolve cwd to absolute path for consistent history file lookup
|
// Always resolve cwd to absolute path for consistent history file lookup
|
||||||
if (normalized.cwd) {
|
if (normalized.cwd) {
|
||||||
@@ -5526,6 +5722,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(
|
private async prepareSessionConfig(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
@@ -5577,6 +5797,7 @@ export class AgentManager {
|
|||||||
workspaceId?: string | null;
|
workspaceId?: string | null;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
|
metadata?: AgentMetadata;
|
||||||
},
|
},
|
||||||
): Promise<AgentLaunchContext> {
|
): Promise<AgentLaunchContext> {
|
||||||
// A plugin changing during this open must still make the resulting session stale.
|
// A plugin changing during this open must still make the resulting session stale.
|
||||||
@@ -5594,7 +5815,7 @@ export class AgentManager {
|
|||||||
purpose: opening?.purpose ?? "interactive",
|
purpose: opening?.purpose ?? "interactive",
|
||||||
env: { ...env },
|
env: { ...env },
|
||||||
title: opening?.title ?? null,
|
title: opening?.title ?? null,
|
||||||
labels: opening?.labels ?? {},
|
...agentEntityData(opening),
|
||||||
tools: [],
|
tools: [],
|
||||||
mcpServers: {},
|
mcpServers: {},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
} from "./agent-projections.js";
|
} from "./agent-projections.js";
|
||||||
import type { AgentSession } from "./agent-sdk-types.js";
|
import type { AgentSession } from "./agent-sdk-types.js";
|
||||||
import type {
|
import type {
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
ImportableProviderSession,
|
ImportableProviderSession,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
@@ -128,16 +128,6 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
|
|||||||
return { ...base, ...overrides };
|
return { ...base, ...overrides };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
|
||||||
return {
|
|
||||||
type: "toggle",
|
|
||||||
id: "fast_mode",
|
|
||||||
label: "Fast mode",
|
|
||||||
value: true,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("toStoredAgentRecord", () => {
|
describe("toStoredAgentRecord", () => {
|
||||||
it("captures lifecycle metadata, config, and persistence", () => {
|
it("captures lifecycle metadata, config, and persistence", () => {
|
||||||
const agent = createManagedAgent({
|
const agent = createManagedAgent({
|
||||||
@@ -407,6 +397,50 @@ describe("toAgentPayload", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("projects a closed agent's stored controls and options", () => {
|
||||||
|
const controls: AgentOptionControl[] = [
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
key: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
defaultValue: "default",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Normal" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const record = toStoredAgentRecord(
|
||||||
|
createManagedAgent({
|
||||||
|
provider: "codex",
|
||||||
|
lifecycle: "closed",
|
||||||
|
controls,
|
||||||
|
config: {
|
||||||
|
provider: "codex",
|
||||||
|
providerOptions: { service_tier: "priority", sandbox_mode: "read-only" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const payload = buildStoredAgentPayload(record, ["codex"]);
|
||||||
|
|
||||||
|
expect(payload.status).toBe("closed");
|
||||||
|
expect(payload.controls).toEqual(controls);
|
||||||
|
expect(payload.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" });
|
||||||
|
expect(payload.features).toEqual([
|
||||||
|
{
|
||||||
|
type: "select",
|
||||||
|
id: "service_tier",
|
||||||
|
label: "Speed",
|
||||||
|
value: "priority",
|
||||||
|
options: [
|
||||||
|
{ id: "default", label: "Normal", isDefault: true },
|
||||||
|
{ id: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("carries a stored lastError onto the payload", () => {
|
it("carries a stored lastError onto the payload", () => {
|
||||||
const record = toStoredAgentRecord(
|
const record = toStoredAgentRecord(
|
||||||
createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }),
|
createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }),
|
||||||
@@ -474,13 +508,44 @@ describe("toAgentPayload", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes features in the snapshot payload", () => {
|
it("projects controls, effective options, and legacy features from the same source", () => {
|
||||||
const features = [createFeature()];
|
const controls: AgentOptionControl[] = [
|
||||||
const agent = createManagedAgent({ features });
|
{ 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);
|
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 { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
AgentMetadata,
|
AgentMetadata,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
@@ -18,7 +18,9 @@ import type {
|
|||||||
AgentUsage,
|
AgentUsage,
|
||||||
ImportableProviderSession,
|
ImportableProviderSession,
|
||||||
} from "./agent-sdk-types.js";
|
} from "./agent-sdk-types.js";
|
||||||
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
|
import { featuresFromControls } from "@getpaseo/protocol/option-controls";
|
||||||
import type { JsonValue } from "../json-utils.js";
|
import type { JsonValue } from "../json-utils.js";
|
||||||
import {
|
import {
|
||||||
isStoredAgentProviderAvailable,
|
isStoredAgentProviderAvailable,
|
||||||
@@ -92,12 +94,13 @@ export function toStoredAgentRecord(
|
|||||||
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
labels: agent.labels,
|
labels: agent.labels,
|
||||||
|
metadata: agent.metadata,
|
||||||
lastStatus: agent.lifecycle,
|
lastStatus: agent.lifecycle,
|
||||||
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
|
||||||
config: config ?? null,
|
config: config ?? null,
|
||||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
features: normalizeFeatures(agent.features),
|
controls: normalizeControls(agent.controls),
|
||||||
persistence,
|
persistence,
|
||||||
lastError: agent.lastError ?? undefined,
|
lastError: agent.lastError ?? undefined,
|
||||||
requiresAttention: agent.attention.requiresAttention,
|
requiresAttention: agent.attention.requiresAttention,
|
||||||
@@ -143,11 +146,17 @@ export function toAgentPayload(
|
|||||||
capabilities: cloneCapabilities(agent.capabilities),
|
capabilities: cloneCapabilities(agent.capabilities),
|
||||||
currentModeId: agent.currentModeId,
|
currentModeId: agent.currentModeId,
|
||||||
availableModes: cloneAvailableModes(agent.availableModes),
|
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),
|
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||||
title: options?.title ?? null,
|
title: options?.title ?? null,
|
||||||
labels: agent.labels,
|
labels: agent.labels,
|
||||||
|
metadata: agent.metadata,
|
||||||
archivedAt: options?.archivedAt ?? null,
|
archivedAt: options?.archivedAt ?? null,
|
||||||
...(agent.internal ? { internal: true } : {}),
|
...(agent.internal ? { internal: true } : {}),
|
||||||
};
|
};
|
||||||
@@ -206,6 +215,21 @@ function buildStoredPersistenceHandle(
|
|||||||
return toAgentPersistenceHandle(validProviders, record.persistence);
|
return toAgentPersistenceHandle(validProviders, record.persistence);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildStoredOptionControls(
|
||||||
|
record: StoredAgentRecord,
|
||||||
|
): Pick<AgentSnapshotPayload, "features" | "options" | "controls"> {
|
||||||
|
const controls = normalizeControls(record.controls);
|
||||||
|
const options = record.config?.providerOptions
|
||||||
|
? sanitizeEffectiveOptions(record.config.providerOptions)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
features: featuresFromControls({ controls, options }),
|
||||||
|
...(options ? { options } : {}),
|
||||||
|
controls,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function buildStoredAgentPayload(
|
export function buildStoredAgentPayload(
|
||||||
record: StoredAgentRecord,
|
record: StoredAgentRecord,
|
||||||
validProviders: Iterable<AgentProvider>,
|
validProviders: Iterable<AgentProvider>,
|
||||||
@@ -227,6 +251,7 @@ export function buildStoredAgentPayload(
|
|||||||
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
|
||||||
|
|
||||||
const runtimeInfo = buildStoredRuntimeInfo(record);
|
const runtimeInfo = buildStoredRuntimeInfo(record);
|
||||||
|
const optionControls = buildStoredOptionControls(record);
|
||||||
const providerAvailable = isStoredAgentProviderAvailable(record, validProviders);
|
const providerAvailable = isStoredAgentProviderAvailable(record, validProviders);
|
||||||
const persistence = projectPersistenceHandleForWire(
|
const persistence = projectPersistenceHandleForWire(
|
||||||
buildStoredPersistenceHandle(record, validProviders),
|
buildStoredPersistenceHandle(record, validProviders),
|
||||||
@@ -251,6 +276,7 @@ export function buildStoredAgentPayload(
|
|||||||
capabilities: defaultCapabilities,
|
capabilities: defaultCapabilities,
|
||||||
currentModeId: record.lastModeId ?? null,
|
currentModeId: record.lastModeId ?? null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
|
...optionControls,
|
||||||
pendingPermissions: [],
|
pendingPermissions: [],
|
||||||
persistence,
|
persistence,
|
||||||
title: record.title ?? null,
|
title: record.title ?? null,
|
||||||
@@ -260,6 +286,7 @@ export function buildStoredAgentPayload(
|
|||||||
archivedAt: record.archivedAt ?? null,
|
archivedAt: record.archivedAt ?? null,
|
||||||
...(record.internal ? { internal: true } : {}),
|
...(record.internal ? { internal: true } : {}),
|
||||||
labels: normalizeLabels(record.labels),
|
labels: normalizeLabels(record.labels),
|
||||||
|
metadata: record.metadata,
|
||||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||||
};
|
};
|
||||||
@@ -375,12 +402,6 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
|||||||
if (config.thinkingOptionId) {
|
if (config.thinkingOptionId) {
|
||||||
serializable.thinkingOptionId = 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) {
|
if (config.providerOptions !== undefined) {
|
||||||
const providerOptions = sanitizeOptionalJson(config.providerOptions);
|
const providerOptions = sanitizeOptionalJson(config.providerOptions);
|
||||||
if (providerOptions && isJsonObject(providerOptions)) {
|
if (providerOptions && isJsonObject(providerOptions)) {
|
||||||
@@ -456,8 +477,15 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
|
|||||||
return modes.map((mode) => ({ ...mode }));
|
return modes.map((mode) => ({ ...mode }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
|
function normalizeControls(
|
||||||
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
|
controls: AgentOptionControl[] | null | undefined,
|
||||||
|
): AgentOptionControl[] {
|
||||||
|
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEffectiveOptions(options: Record<string, unknown>): ProviderOptions {
|
||||||
|
const sanitized = sanitizeOptionalJson(options);
|
||||||
|
return sanitized && isJsonObject(sanitized) ? sanitized : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
AgentFeatureSelect,
|
AgentOptionSelectControl,
|
||||||
AgentFeatureToggle,
|
AgentOptionToggleControl,
|
||||||
AgentProviderNotice,
|
AgentProviderNotice,
|
||||||
AgentTaskItem,
|
AgentTaskItem,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
|||||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
AgentFeatureSelect,
|
AgentOptionSelectControl,
|
||||||
AgentFeatureToggle,
|
AgentOptionToggleControl,
|
||||||
AgentProviderNotice,
|
AgentProviderNotice,
|
||||||
AgentTaskItem,
|
AgentTaskItem,
|
||||||
};
|
};
|
||||||
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
|
|||||||
export interface ResolveAgentCreateConfigInput {
|
export interface ResolveAgentCreateConfigInput {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
providerOptions: ProviderOptions | undefined;
|
||||||
parent: AgentCreateConfigParent | null;
|
parent: AgentCreateConfigParent | null;
|
||||||
unattended: boolean;
|
unattended: boolean;
|
||||||
availableModes: AgentMode[] | undefined;
|
availableModes: AgentMode[] | undefined;
|
||||||
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
|
|||||||
|
|
||||||
export interface ResolveAgentCreateConfigResult {
|
export interface ResolveAgentCreateConfigResult {
|
||||||
modeId: string | undefined;
|
modeId: string | undefined;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
providerOptions: ProviderOptions | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentCreateConfigUnattendedInput {
|
export interface AgentCreateConfigUnattendedInput {
|
||||||
modeId: string | null;
|
modeId: string | null;
|
||||||
config: AgentSessionConfig;
|
config: AgentSessionConfig;
|
||||||
features?: AgentFeature[];
|
controls?: AgentOptionControl[];
|
||||||
availableModes: AgentMode[];
|
availableModes: AgentMode[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,6 +429,14 @@ export type AgentStreamEvent =
|
|||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
thinkingOptionId: string | null;
|
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";
|
type: "turn_failed";
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
@@ -610,7 +618,6 @@ export interface AgentSessionConfig {
|
|||||||
modeId?: string;
|
modeId?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
providerOptions?: ProviderOptions;
|
providerOptions?: ProviderOptions;
|
||||||
toolPolicy?: ToolPolicy;
|
toolPolicy?: ToolPolicy;
|
||||||
@@ -679,7 +686,14 @@ export interface AgentSession {
|
|||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly id: string | null;
|
readonly id: string | null;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
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
|
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||||
@@ -714,7 +728,20 @@ export interface AgentSession {
|
|||||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||||
setModel?(modelId: string | null): Promise<void>;
|
setModel?(modelId: string | null): Promise<void>;
|
||||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
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>;
|
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||||
revertBoth?(input: { messageId: string }): Promise<void>;
|
revertBoth?(input: { messageId: string }): Promise<void>;
|
||||||
@@ -764,11 +791,17 @@ export interface ResolveAgentDefaultModeInput {
|
|||||||
export interface AgentClient {
|
export interface AgentClient {
|
||||||
readonly provider: AgentProvider;
|
readonly provider: AgentProvider;
|
||||||
readonly capabilities: AgentCapabilityFlags;
|
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(
|
createSession(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
launchContext?: AgentLaunchContext,
|
launchContext?: AgentLaunchContext,
|
||||||
options?: AgentCreateSessionOptions,
|
options?: AgentCreateSessionOptions,
|
||||||
): Promise<AgentSession>;
|
): Promise<AgentSession>;
|
||||||
|
/** Same contract as `createSession`: open with the options, report any difference in `openOptionsPatch`. */
|
||||||
resumeSession(
|
resumeSession(
|
||||||
handle: AgentPersistenceHandle,
|
handle: AgentPersistenceHandle,
|
||||||
overrides?: Partial<AgentSessionConfig>,
|
overrides?: Partial<AgentSessionConfig>,
|
||||||
@@ -797,7 +830,9 @@ export interface AgentClient {
|
|||||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
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?(
|
listImportableSessions?(
|
||||||
options?: ListImportableSessionsOptions,
|
options?: ListImportableSessionsOptions,
|
||||||
): Promise<ImportableProviderSession[]>;
|
): Promise<ImportableProviderSession[]>;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
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 { promises as fs } from "node:fs";
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
@@ -44,9 +44,6 @@ function buildManagedAgentConfig(
|
|||||||
systemPrompt: configOverrides.systemPrompt,
|
systemPrompt: configOverrides.systemPrompt,
|
||||||
mcpServers: configOverrides.mcpServers,
|
mcpServers: configOverrides.mcpServers,
|
||||||
};
|
};
|
||||||
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
|
||||||
config.featureValues = configOverrides.featureValues;
|
|
||||||
}
|
|
||||||
return config;
|
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", () => {
|
describe("AgentStorage", () => {
|
||||||
let tmpDir: string;
|
let tmpDir: string;
|
||||||
let storagePath: string;
|
let storagePath: string;
|
||||||
@@ -195,62 +200,77 @@ describe("AgentStorage", () => {
|
|||||||
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
|
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(
|
await storage.applySnapshot(
|
||||||
createManagedAgent({
|
createManagedAgent({
|
||||||
id: "agent-feature-values",
|
id: "agent-provider-options",
|
||||||
config: {
|
config: { providerOptions: { fast_mode: true } },
|
||||||
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",
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const reloaded = new AgentStorage(storagePath, logger);
|
const reloaded = new AgentStorage(storagePath, logger);
|
||||||
const persisted = await reloaded.get("agent-no-feature-values");
|
const persisted = await reloaded.get("agent-provider-options");
|
||||||
expect(persisted?.config?.featureValues).toBeUndefined();
|
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
|
||||||
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
|
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
|
||||||
});
|
expect(buildConfigOverrides(persisted!)).toMatchObject({
|
||||||
|
|
||||||
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({
|
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
featureValues: {
|
providerOptions: { fast_mode: true },
|
||||||
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 () => {
|
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||||
const agentId = "agent-created-at";
|
const agentId = "agent-created-at";
|
||||||
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
||||||
|
|||||||
@@ -2,19 +2,23 @@ import { promises as fs, type Dirent } from "node:fs";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
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 { toStoredAgentRecord } from "./agent-projections.js";
|
||||||
import type { ManagedAgent } from "./agent-manager.js";
|
import type { ManagedAgent } from "./agent-manager.js";
|
||||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||||
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
||||||
|
import { controlsFromFeatures } from "@getpaseo/protocol/option-controls";
|
||||||
|
import { foldFeatureValues } from "./option-controls.js";
|
||||||
|
|
||||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||||
.object({
|
.object({
|
||||||
modeId: z.string().nullable().optional(),
|
modeId: z.string().nullable().optional(),
|
||||||
model: z.string().nullable().optional(),
|
model: z.string().nullable().optional(),
|
||||||
thinkingOptionId: 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(),
|
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
toolPolicy: z
|
toolPolicy: z
|
||||||
@@ -42,7 +46,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
|
|||||||
.nullable()
|
.nullable()
|
||||||
.optional();
|
.optional();
|
||||||
|
|
||||||
const STORED_AGENT_SCHEMA = z.object({
|
const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
provider: z.string(),
|
provider: z.string(),
|
||||||
cwd: z.string(),
|
cwd: z.string(),
|
||||||
@@ -53,6 +57,7 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
lastUserMessageAt: z.string().nullable().optional(),
|
lastUserMessageAt: z.string().nullable().optional(),
|
||||||
title: z.string().nullable().optional(),
|
title: z.string().nullable().optional(),
|
||||||
labels: z.record(z.string(), z.string()).default({}),
|
labels: z.record(z.string(), z.string()).default({}),
|
||||||
|
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
|
||||||
lastStatus: AgentStatusSchema.default("closed"),
|
lastStatus: AgentStatusSchema.default("closed"),
|
||||||
resumeOnStartup: z.boolean().optional(),
|
resumeOnStartup: z.boolean().optional(),
|
||||||
lastModeId: z.string().nullable().optional(),
|
lastModeId: z.string().nullable().optional(),
|
||||||
@@ -68,6 +73,8 @@ const STORED_AGENT_SCHEMA = z.object({
|
|||||||
extra: z.record(z.string(), z.unknown()).optional(),
|
extra: z.record(z.string(), z.unknown()).optional(),
|
||||||
})
|
})
|
||||||
.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(),
|
features: z.array(AgentFeatureSchema).optional(),
|
||||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||||
lastError: z.string().nullable().optional(),
|
lastError: z.string().nullable().optional(),
|
||||||
@@ -84,14 +91,32 @@ export type SerializableAgentConfig = Pick<
|
|||||||
| "modeId"
|
| "modeId"
|
||||||
| "model"
|
| "model"
|
||||||
| "thinkingOptionId"
|
| "thinkingOptionId"
|
||||||
| "featureValues"
|
|
||||||
| "providerOptions"
|
| "providerOptions"
|
||||||
| "toolPolicy"
|
| "toolPolicy"
|
||||||
| "systemPrompt"
|
| "systemPrompt"
|
||||||
| "mcpServers"
|
| "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 {
|
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
||||||
return STORED_AGENT_SCHEMA.parse(value);
|
return STORED_AGENT_SCHEMA.parse(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
|
|||||||
availableModes: availableModeIds,
|
availableModes: availableModeIds,
|
||||||
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
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();
|
const stub = createProviderSnapshotManagerStub();
|
||||||
stub.resolveCreateConfig.mockResolvedValue({
|
stub.resolveCreateConfig.mockResolvedValue({
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
const dependencies: Parameters<typeof createAgentCommand>[0] = {
|
const dependencies: Parameters<typeof createAgentCommand>[0] = {
|
||||||
agentManager: {
|
agentManager: {
|
||||||
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
|
|||||||
expect(createAgent).toHaveBeenCalledWith(
|
expect(createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
expect.anything(),
|
expect.anything(),
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import { buildAgentPrompt } from "../prompt-attachments.js";
|
|||||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||||
import { resolveCreateAgentIntent } from "./intent.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 {
|
export interface CreateAgentCommandDependencies {
|
||||||
finishSubscriptions?: FinishSubscriptions;
|
finishSubscriptions?: FinishSubscriptions;
|
||||||
@@ -47,7 +49,8 @@ export interface CreateAgentFromSessionInput {
|
|||||||
kind: "session";
|
kind: "session";
|
||||||
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
|
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
config: AgentSessionConfig;
|
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
|
||||||
|
config: AgentSessionConfig & LegacyFeatureValues;
|
||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
source?: AgentPromptSource;
|
source?: AgentPromptSource;
|
||||||
@@ -56,6 +59,7 @@ export interface CreateAgentFromSessionInput {
|
|||||||
images?: Array<{ data: string; mimeType: string }>;
|
images?: Array<{ data: string; mimeType: string }>;
|
||||||
attachments?: AgentAttachment[];
|
attachments?: AgentAttachment[];
|
||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
|
metadata?: CreateAgentOptions["metadata"];
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
provisionalTitle: string | null;
|
provisionalTitle: string | null;
|
||||||
firstAgentContext: FirstAgentContext;
|
firstAgentContext: FirstAgentContext;
|
||||||
@@ -70,7 +74,7 @@ export interface CreateAgentFromMcpInput {
|
|||||||
cwd?: string;
|
cwd?: string;
|
||||||
workspaceId?: string;
|
workspaceId?: string;
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
features?: Record<string, unknown>;
|
providerOptions?: ProviderOptions;
|
||||||
labels?: Record<string, string>;
|
labels?: Record<string, string>;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
unattended?: boolean;
|
unattended?: boolean;
|
||||||
@@ -218,19 +222,19 @@ async function resolveSessionCreateAgent(
|
|||||||
input: CreateAgentFromSessionInput,
|
input: CreateAgentFromSessionInput,
|
||||||
): Promise<ResolvedCreateAgent> {
|
): Promise<ResolvedCreateAgent> {
|
||||||
const trimmedPrompt = input.initialPrompt?.trim();
|
const trimmedPrompt = input.initialPrompt?.trim();
|
||||||
const builtSessionConfig = input.config;
|
const builtSessionConfig = foldFeatureValues(input.config);
|
||||||
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||||
cwd: builtSessionConfig.cwd,
|
cwd: builtSessionConfig.cwd,
|
||||||
provider: builtSessionConfig.provider,
|
provider: builtSessionConfig.provider,
|
||||||
requestedMode: builtSessionConfig.modeId,
|
requestedMode: builtSessionConfig.modeId,
|
||||||
featureValues: builtSessionConfig.featureValues,
|
providerOptions: builtSessionConfig.providerOptions,
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: false,
|
unattended: false,
|
||||||
});
|
});
|
||||||
const sessionConfig: AgentSessionConfig = {
|
const sessionConfig: AgentSessionConfig = {
|
||||||
...builtSessionConfig,
|
...builtSessionConfig,
|
||||||
modeId: resolvedCreateConfig.modeId,
|
modeId: resolvedCreateConfig.modeId,
|
||||||
featureValues: resolvedCreateConfig.featureValues,
|
providerOptions: resolvedCreateConfig.providerOptions,
|
||||||
};
|
};
|
||||||
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
||||||
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
||||||
@@ -248,6 +252,7 @@ async function resolveSessionCreateAgent(
|
|||||||
config: sessionConfig,
|
config: sessionConfig,
|
||||||
createOptions: {
|
createOptions: {
|
||||||
labels: input.labels,
|
labels: input.labels,
|
||||||
|
metadata: input.metadata,
|
||||||
initialPrompt: trimmedPrompt,
|
initialPrompt: trimmedPrompt,
|
||||||
env: input.env,
|
env: input.env,
|
||||||
initialTitle: input.provisionalTitle,
|
initialTitle: input.provisionalTitle,
|
||||||
@@ -323,7 +328,7 @@ async function resolveMcpCreateAgent(
|
|||||||
resolvedCwd: intent.cwd,
|
resolvedCwd: intent.cwd,
|
||||||
trimmedPrompt,
|
trimmedPrompt,
|
||||||
resolvedMode: resolvedCreateConfig.modeId,
|
resolvedMode: resolvedCreateConfig.modeId,
|
||||||
resolvedFeatures: resolvedCreateConfig.featureValues,
|
resolvedProviderOptions: resolvedCreateConfig.providerOptions,
|
||||||
}),
|
}),
|
||||||
createOptions: {
|
createOptions: {
|
||||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||||
@@ -361,18 +366,23 @@ async function resolveMcpProviderCreateConfig(params: {
|
|||||||
provider: string;
|
provider: string;
|
||||||
resolvedCwd: string;
|
resolvedCwd: string;
|
||||||
parentAgent: ManagedAgent | null;
|
parentAgent: ManagedAgent | null;
|
||||||
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
|
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
|
||||||
const passthroughConfig = params.input.config;
|
const passthroughConfig = params.input.config;
|
||||||
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
|
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||||
cwd: params.resolvedCwd,
|
cwd: params.resolvedCwd,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
|
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
|
||||||
featureValues: params.input.features ?? passthroughConfig?.featureValues,
|
providerOptions: mergeMcpProviderOptions(params.input),
|
||||||
parent: params.parentAgent,
|
parent: params.parentAgent,
|
||||||
unattended: params.input.unattended ?? false,
|
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: {
|
function buildMcpSessionConfig(params: {
|
||||||
input: CreateAgentFromMcpInput;
|
input: CreateAgentFromMcpInput;
|
||||||
resolvedProviderModel: ResolvedProviderModel;
|
resolvedProviderModel: ResolvedProviderModel;
|
||||||
@@ -380,14 +390,14 @@ function buildMcpSessionConfig(params: {
|
|||||||
resolvedCwd: string;
|
resolvedCwd: string;
|
||||||
trimmedPrompt: string;
|
trimmedPrompt: string;
|
||||||
resolvedMode?: string;
|
resolvedMode?: string;
|
||||||
resolvedFeatures?: Record<string, unknown>;
|
resolvedProviderOptions?: ProviderOptions;
|
||||||
}): AgentSessionConfig {
|
}): AgentSessionConfig {
|
||||||
const passthroughConfig = params.input.config;
|
const passthroughConfig = params.input.config;
|
||||||
const { provisionalTitle } = resolveCreateAgentTitles({
|
const { provisionalTitle } = resolveCreateAgentTitles({
|
||||||
configTitle: passthroughConfig?.title ?? params.input.title,
|
configTitle: passthroughConfig?.title ?? params.input.title,
|
||||||
initialPrompt: params.trimmedPrompt,
|
initialPrompt: params.trimmedPrompt,
|
||||||
});
|
});
|
||||||
const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
|
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
|
||||||
const config: AgentSessionConfig = {
|
const config: AgentSessionConfig = {
|
||||||
...passthroughConfig,
|
...passthroughConfig,
|
||||||
provider: params.provider,
|
provider: params.provider,
|
||||||
@@ -400,8 +410,8 @@ function buildMcpSessionConfig(params: {
|
|||||||
if (provisionalTitle) {
|
if (provisionalTitle) {
|
||||||
config.title = provisionalTitle;
|
config.title = provisionalTitle;
|
||||||
}
|
}
|
||||||
if (featureValues) {
|
if (providerOptions) {
|
||||||
config.featureValues = featureValues;
|
config.providerOptions = providerOptions;
|
||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
|
|||||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||||
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
setAgentOptions: vi.fn().mockResolvedValue(undefined),
|
||||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||||
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
|
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
|
||||||
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
|
|||||||
cancelAgentRun: vi.fn(),
|
cancelAgentRun: vi.fn(),
|
||||||
getPendingPermissions: vi.fn(),
|
getPendingPermissions: vi.fn(),
|
||||||
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||||
listDraftFeatures: vi.fn(),
|
listDraftControls: vi.fn(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -472,9 +472,9 @@ function configureOpenCodeProviderStub(
|
|||||||
const opts = input as {
|
const opts = input as {
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
requestedMode: string | undefined;
|
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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -562,6 +562,7 @@ function createManagedAgent(overrides: Partial<ManagedAgent> = {}): ManagedAgent
|
|||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
persistence: null,
|
persistence: null,
|
||||||
labels: {},
|
labels: {},
|
||||||
|
metadata: {},
|
||||||
attention: { requiresAttention: false },
|
attention: { requiresAttention: false },
|
||||||
...overrides,
|
...overrides,
|
||||||
} as ManagedAgent;
|
} as ManagedAgent;
|
||||||
@@ -1805,7 +1806,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Feature test", featureValues: { fast_mode: true } },
|
config: { title: "Feature test", providerOptions: { fast_mode: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -1834,7 +1835,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { fast_mode: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: "workspace-created" },
|
{ workspaceId: "workspace-created" },
|
||||||
@@ -3039,12 +3040,12 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: null,
|
currentModeId: null,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Child", featureValues: { fast_mode: true } },
|
config: { title: "Child", providerOptions: { fast_mode: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const providerSnapshot = createOpenCodeManager();
|
const providerSnapshot = createOpenCodeManager();
|
||||||
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||||
const opts = input as { featureValues: Record<string, unknown> | undefined };
|
const opts = input as { providerOptions: Record<string, unknown> | undefined };
|
||||||
return { modeId: undefined, featureValues: opts.featureValues };
|
return { modeId: undefined, providerOptions: opts.providerOptions };
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -3072,7 +3073,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { fast_mode: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
{
|
{
|
||||||
@@ -3273,7 +3274,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||||
const opts = input as { requestedMode: string | undefined };
|
const opts = input as { requestedMode: string | undefined };
|
||||||
expect(opts.requestedMode).toBe("dynamic");
|
expect(opts.requestedMode).toBe("dynamic");
|
||||||
return { modeId: "dynamic", featureValues: undefined };
|
return { modeId: "dynamic", providerOptions: undefined };
|
||||||
});
|
});
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
@@ -3307,12 +3308,12 @@ describe("create_agent MCP tool", () => {
|
|||||||
lifecycle: "idle",
|
lifecycle: "idle",
|
||||||
currentModeId: "build",
|
currentModeId: "build",
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
config: { title: "Child", featureValues: { auto_accept: true } },
|
config: { title: "Child", providerOptions: { auto_accept: true } },
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const providerSnapshot = createOpenCodeManager();
|
const providerSnapshot = createOpenCodeManager();
|
||||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
@@ -3332,7 +3333,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }),
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: "workspace-created" },
|
{ workspaceId: "workspace-created" },
|
||||||
);
|
);
|
||||||
@@ -3360,7 +3361,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
const providerSnapshot = createOpenCodeManager();
|
const providerSnapshot = createOpenCodeManager();
|
||||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||||
modeId: "resolver-mode",
|
modeId: "resolver-mode",
|
||||||
featureValues: { resolver_feature: true },
|
providerOptions: { resolver_feature: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
const server = await createAgentMcpServer({
|
const server = await createAgentMcpServer({
|
||||||
@@ -3384,7 +3385,7 @@ describe("create_agent MCP tool", () => {
|
|||||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
modeId: "resolver-mode",
|
modeId: "resolver-mode",
|
||||||
featureValues: { resolver_feature: true },
|
providerOptions: { resolver_feature: true },
|
||||||
}),
|
}),
|
||||||
undefined,
|
undefined,
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
@@ -4717,7 +4718,7 @@ describe("update_agent MCP tool", () => {
|
|||||||
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||||
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||||
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
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", {
|
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
||||||
title: "Updated agent",
|
title: "Updated agent",
|
||||||
labels: { role: "worker" },
|
labels: { role: "worker" },
|
||||||
@@ -4742,12 +4743,12 @@ describe("update_agent MCP tool", () => {
|
|||||||
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
||||||
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
||||||
expect(spies.agentManager.setAgentThinkingOption).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 () => {
|
it("does not update metadata when runtime settings fail", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
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({
|
const server = await createAgentMcpServer({
|
||||||
agentManager,
|
agentManager,
|
||||||
agentStorage,
|
agentStorage,
|
||||||
@@ -5065,7 +5066,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
config: {
|
config: {
|
||||||
title: "Parent agent",
|
title: "Parent agent",
|
||||||
model: "openai/gpt-5.5",
|
model: "openai/gpt-5.5",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
},
|
},
|
||||||
} as ManagedAgent);
|
} as ManagedAgent);
|
||||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||||
@@ -5093,7 +5094,7 @@ describe("create_schedule MCP tool", () => {
|
|||||||
cwd: REPO_CWD,
|
cwd: REPO_CWD,
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
model: "openai/gpt-5.5",
|
model: "openai/gpt-5.5",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5867,7 +5868,7 @@ describe("agent profile listing MCP tool", () => {
|
|||||||
model: "claude-test-model",
|
model: "claude-test-model",
|
||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
featureValues: { fast_mode: true },
|
options: { fast_mode: true },
|
||||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -5882,7 +5883,21 @@ describe("agent profile listing MCP tool", () => {
|
|||||||
|
|
||||||
const response = await tool.handler({});
|
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 () => {
|
it("returns an empty array when no profiles are configured", async () => {
|
||||||
@@ -5934,14 +5949,10 @@ describe("provider MCP tools", () => {
|
|||||||
|
|
||||||
it("inspects provider features for a draft agent configuration", async () => {
|
it("inspects provider features for a draft agent configuration", async () => {
|
||||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||||
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
spies.agentManager.listDraftControls.mockResolvedValue({
|
||||||
{
|
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
|
||||||
type: "toggle",
|
options: { fast_mode: true },
|
||||||
id: "fast_mode",
|
});
|
||||||
label: "Fast mode",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const provStub = createProviderSnapshotManagerStub();
|
const provStub = createProviderSnapshotManagerStub();
|
||||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||||
const codexEntry = buildSnapshotEntry({
|
const codexEntry = buildSnapshotEntry({
|
||||||
@@ -5974,13 +5985,13 @@ describe("provider MCP tools", () => {
|
|||||||
|
|
||||||
const response = await tool.handler(input);
|
const response = await tool.handler(input);
|
||||||
|
|
||||||
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
|
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
cwd: expect.stringContaining("repo"),
|
cwd: expect.stringContaining("repo"),
|
||||||
modeId: "full-access",
|
modeId: "full-access",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { fast_mode: true },
|
||||||
});
|
});
|
||||||
expect(response.structuredContent).toEqual({
|
expect(response.structuredContent).toEqual({
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
@@ -5995,7 +6006,7 @@ describe("provider MCP tools", () => {
|
|||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: "fast_mode",
|
id: "fast_mode",
|
||||||
label: "Fast mode",
|
label: "Fast mode",
|
||||||
value: false,
|
value: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -6211,14 +6222,8 @@ describe("agent snapshot MCP serialization", () => {
|
|||||||
description: "Default coding mode",
|
description: "Default coding mode",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
features: [
|
controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
|
||||||
{
|
effectiveOptions: { "web-search": true },
|
||||||
type: "toggle",
|
|
||||||
id: "web-search",
|
|
||||||
label: "Web search",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
persistence: {
|
persistence: {
|
||||||
provider: "codex",
|
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,204 @@
|
|||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A patch over an agent's own options: top-level keys replace, `null` removes the key so the
|
||||||
|
* `config.json` default (or the provider's own default) applies again.
|
||||||
|
*/
|
||||||
|
export type ProviderOptionsPatch = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function applyProviderOptionsPatch(input: {
|
||||||
|
options: ProviderOptions | undefined;
|
||||||
|
patch: ProviderOptionsPatch;
|
||||||
|
}): ProviderOptions {
|
||||||
|
const { options, patch } = input;
|
||||||
|
const next: ProviderOptions = { ...options };
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete next[key];
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. */
|
||||||
|
export interface LegacyFeatureValues {
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast
|
||||||
|
* preferences have migrated. Codex used to take `fast_mode`; it takes `service_tier` now. Old
|
||||||
|
* data (stored records, config.json defaults, profiles, older clients, hooks) is converted here
|
||||||
|
* as it enters, so nothing past this point sees `fast_mode` for Codex. A `service_tier` in the
|
||||||
|
* same input wins. A patch's `null` stays `null`, which removes the speed.
|
||||||
|
*/
|
||||||
|
interface LegacyOptionsInput<O> {
|
||||||
|
provider: string | undefined;
|
||||||
|
options: O;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||||
|
import type { ProviderConfigState } from "@getpaseo/plugin/server/provider";
|
||||||
|
import type { AgentOptionControl } from "./agent-sdk-types.js";
|
||||||
|
|
||||||
|
/** How a provider session's composer options look to the daemon, whichever shape it reports. */
|
||||||
|
export interface PluginOptionState {
|
||||||
|
controls: AgentOptionControl[];
|
||||||
|
/** The values the session runs with, for the keys it reports. */
|
||||||
|
options: Record<string, JsonValue>;
|
||||||
|
/** The provider reports `settings`, so it also reads `changes.settings`. */
|
||||||
|
legacySettings: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A provider that reports `controls` and `providerOptions` is read as is.
|
||||||
|
* COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A provider still
|
||||||
|
* reporting `settings` has each setting id read as an option key and its value as the option.
|
||||||
|
*/
|
||||||
|
export function readPluginOptionState(config: ProviderConfigState): PluginOptionState {
|
||||||
|
if (config.controls || config.providerOptions) {
|
||||||
|
return {
|
||||||
|
controls: (config.controls ?? []).map(toControl),
|
||||||
|
options: { ...config.providerOptions },
|
||||||
|
legacySettings: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const settings = config.settings ?? [];
|
||||||
|
return {
|
||||||
|
controls: settings.map(
|
||||||
|
(setting): AgentOptionControl =>
|
||||||
|
setting.type === "toggle"
|
||||||
|
? {
|
||||||
|
type: "toggle",
|
||||||
|
key: setting.id,
|
||||||
|
label: setting.label,
|
||||||
|
...(setting.description ? { description: setting.description } : {}),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: "select",
|
||||||
|
key: setting.id,
|
||||||
|
label: setting.label,
|
||||||
|
...(setting.description ? { description: setting.description } : {}),
|
||||||
|
choices: setting.options.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
options: Object.fromEntries(settings.map((setting) => [setting.id, setting.value])),
|
||||||
|
legacySettings: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toControl(
|
||||||
|
control: NonNullable<ProviderConfigState["controls"]>[number],
|
||||||
|
): AgentOptionControl {
|
||||||
|
return control.type === "toggle"
|
||||||
|
? { ...control }
|
||||||
|
: { ...control, choices: control.choices.map((choice) => ({ ...choice })) };
|
||||||
|
}
|
||||||
@@ -58,6 +58,14 @@ interface ProviderHarnessOptions {
|
|||||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function controlKey(control: { key: string }): string {
|
||||||
|
return control.key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
|
||||||
|
return inputs.findLast((input) => input.type === "session.configure");
|
||||||
|
}
|
||||||
|
|
||||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||||
let listener: ((event: ProviderEvent) => void) | null = null;
|
let listener: ((event: ProviderEvent) => void) | null = null;
|
||||||
let closeCount = 0;
|
let closeCount = 0;
|
||||||
@@ -497,6 +505,144 @@ test("combined plugin rewind replays the retained native branch", async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a rewind reopens with the options as they stand, a reset included", async () => {
|
||||||
|
const options: Record<string, boolean> = {};
|
||||||
|
const report = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||||
|
emit({
|
||||||
|
type: "session.config",
|
||||||
|
sessionId,
|
||||||
|
config: {
|
||||||
|
model: "plugin-model",
|
||||||
|
mode: "build",
|
||||||
|
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||||
|
modes: [{ id: "build", label: "Build" }],
|
||||||
|
thinkingOptions: [],
|
||||||
|
controls: [{ type: "toggle", key: "concise", label: "Concise" }],
|
||||||
|
providerOptions: { ...options },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const harness = createRewindProviderHarness({
|
||||||
|
handleInput: async (input, emit) => {
|
||||||
|
if (input.type === "session.open" && input.history !== "replay") {
|
||||||
|
options.concise = true;
|
||||||
|
emit({
|
||||||
|
type: "session.opened",
|
||||||
|
requestId: input.requestId,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
capabilities: [...CAPABILITIES, "session.revert.conversation"],
|
||||||
|
restoration: "core",
|
||||||
|
persistence: { version: 1, data: { token: "root" } },
|
||||||
|
cwd: input.config.cwd,
|
||||||
|
});
|
||||||
|
report(emit, input.sessionId);
|
||||||
|
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (input.type === "session.configure") {
|
||||||
|
// A plugin that represents the default by leaving the key out.
|
||||||
|
delete options.concise;
|
||||||
|
report(emit, input.sessionId);
|
||||||
|
emit({ type: "request.completed", requestId: input.requestId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||||
|
registry.replace([harness.registration]);
|
||||||
|
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||||
|
provider: harness.registration.id,
|
||||||
|
cwd: tmpdir(),
|
||||||
|
providerOptions: { concise: true },
|
||||||
|
});
|
||||||
|
await session.run("A");
|
||||||
|
await session.applyOptions?.({ concise: null });
|
||||||
|
|
||||||
|
await session.revertConversation!({ messageId: "user-A" });
|
||||||
|
|
||||||
|
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
|
||||||
|
expect(reopened?.config.providerOptions).toEqual({});
|
||||||
|
await session.close();
|
||||||
|
await registry.shutdown();
|
||||||
|
});
|
||||||
|
|
||||||
|
// A plugin may report no options at all while it runs the ones it was given.
|
||||||
|
async function withUnreportedOptionSession(
|
||||||
|
run: (
|
||||||
|
session: Awaited<ReturnType<AgentClient["createSession"]>>,
|
||||||
|
harness: ReturnType<typeof createRewindProviderHarness>,
|
||||||
|
) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const harness = createRewindProviderHarness({
|
||||||
|
handleInput: async (input, emit) => {
|
||||||
|
if (input.type === "session.open" && input.history !== "replay") {
|
||||||
|
emit({
|
||||||
|
type: "session.opened",
|
||||||
|
requestId: input.requestId,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
capabilities: [...CAPABILITIES, "session.revert.conversation"],
|
||||||
|
restoration: "core",
|
||||||
|
persistence: { version: 1, data: { token: "root" } },
|
||||||
|
cwd: input.config.cwd,
|
||||||
|
});
|
||||||
|
emit({
|
||||||
|
type: "session.config",
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
config: {
|
||||||
|
model: "plugin-model",
|
||||||
|
mode: "build",
|
||||||
|
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||||
|
modes: [{ id: "build", label: "Build" }],
|
||||||
|
thinkingOptions: [],
|
||||||
|
controls: [],
|
||||||
|
providerOptions: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (input.type === "session.configure") {
|
||||||
|
emit({ type: "request.completed", requestId: input.requestId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||||
|
registry.replace([harness.registration]);
|
||||||
|
const session = await registry.clients()[harness.registration.id]!.createSession({
|
||||||
|
provider: harness.registration.id,
|
||||||
|
cwd: tmpdir(),
|
||||||
|
providerOptions: { token_budget: 1000 },
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await run(session, harness);
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
await registry.shutdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("applying an option the plugin never reports leaves the stored option alone", async () => {
|
||||||
|
await withUnreportedOptionSession(async (session) => {
|
||||||
|
// The same value as a whole-profile patch carries it, then a new one.
|
||||||
|
expect(await session.applyOptions?.({ token_budget: 1000 })).toEqual({});
|
||||||
|
expect(await session.applyOptions?.({ token_budget: 2000 })).toEqual({});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rewind reopens with the value the plugin accepted for an option it never reports", async () => {
|
||||||
|
await withUnreportedOptionSession(async (session, harness) => {
|
||||||
|
await session.run("A");
|
||||||
|
await session.applyOptions?.({ token_budget: 2000 });
|
||||||
|
|
||||||
|
await session.revertConversation!({ messageId: "user-A" });
|
||||||
|
|
||||||
|
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
|
||||||
|
expect(reopened?.config.providerOptions).toEqual({ token_budget: 2000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
|
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
|
||||||
await withPluginRewindSession(
|
await withPluginRewindSession(
|
||||||
{
|
{
|
||||||
@@ -970,7 +1116,7 @@ describe("PluginAgentClientRegistry", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stores only agent options while the plugin receives merged defaults", async () => {
|
test("stores the agent options and the settings the plugin opened with, not the merged config defaults", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const harness = createProviderHarness();
|
const harness = createProviderHarness();
|
||||||
const plugins = new PluginAgentClientRegistry(logger);
|
const plugins = new PluginAgentClientRegistry(logger);
|
||||||
@@ -992,13 +1138,329 @@ describe("PluginAgentClientRegistry", () => {
|
|||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: 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({
|
expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({
|
||||||
config: { providerOptions: { nested: { base: true, replace: "agent" } } },
|
config: { providerOptions: { nested: { base: true, replace: "agent" } } },
|
||||||
});
|
});
|
||||||
await agent.session?.close();
|
await agent.session?.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The same contract holds for a plugin that reports `controls` and `providerOptions` and for one
|
||||||
|
// still reporting `settings`.
|
||||||
|
describe.each(["controls", "settings"] as const)("options of a plugin reporting %s", (shape) => {
|
||||||
|
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
|
||||||
|
async function openStubbornVoiceSession(
|
||||||
|
given: Record<string, boolean | string>,
|
||||||
|
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const state: { voice: string; loud: boolean; tokenBudget?: number } = {
|
||||||
|
voice: "direct",
|
||||||
|
loud: false,
|
||||||
|
};
|
||||||
|
let openedSessionId = "";
|
||||||
|
const reported = () =>
|
||||||
|
shape === "controls"
|
||||||
|
? {
|
||||||
|
controls: [
|
||||||
|
{
|
||||||
|
type: "select" as const,
|
||||||
|
key: "voice",
|
||||||
|
label: "Voice",
|
||||||
|
icon: "Mic",
|
||||||
|
defaultValue: "direct",
|
||||||
|
choices: [
|
||||||
|
{ label: "Direct", value: "direct" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "toggle" as const,
|
||||||
|
key: "loud",
|
||||||
|
label: "Loud",
|
||||||
|
icon: "Volume2",
|
||||||
|
defaultValue: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
providerOptions: {
|
||||||
|
voice: state.voice,
|
||||||
|
loud: state.loud,
|
||||||
|
...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
settings: [
|
||||||
|
{
|
||||||
|
type: "select" as const,
|
||||||
|
id: "voice",
|
||||||
|
label: "Voice",
|
||||||
|
value: state.voice,
|
||||||
|
options: [
|
||||||
|
{ label: "Direct", value: "direct" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||||
|
emit({
|
||||||
|
type: "session.config",
|
||||||
|
sessionId,
|
||||||
|
config: {
|
||||||
|
model: "plugin-model",
|
||||||
|
mode: "build",
|
||||||
|
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||||
|
modes: [{ id: "build", label: "Build" }],
|
||||||
|
thinkingOptions: [],
|
||||||
|
...reported(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const harness = createProviderHarness({
|
||||||
|
async handleInput(input, emit) {
|
||||||
|
if (input.type === "session.open") {
|
||||||
|
openedSessionId = input.sessionId;
|
||||||
|
emit({
|
||||||
|
type: "session.opened",
|
||||||
|
requestId: input.requestId,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
capabilities: CAPABILITIES,
|
||||||
|
restoration: "core",
|
||||||
|
persistence: { version: 1, data: { token: "root" } },
|
||||||
|
cwd: input.config.cwd,
|
||||||
|
});
|
||||||
|
emitConfig(emit, input.sessionId);
|
||||||
|
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (input.type === "session.configure") {
|
||||||
|
const patch =
|
||||||
|
shape === "settings" ? input.changes.settings : input.changes.providerOptions;
|
||||||
|
const { loud, token_budget: tokenBudget } = patch ?? {};
|
||||||
|
// A plugin that takes `token_budget` live refuses the whole request on a bad value.
|
||||||
|
if (tokenBudget !== undefined && typeof tokenBudget !== "number") {
|
||||||
|
emit({
|
||||||
|
type: "request.failed",
|
||||||
|
requestId: input.requestId,
|
||||||
|
error: { message: "bad budget" },
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (typeof loud === "boolean") state.loud = loud;
|
||||||
|
if (typeof tokenBudget === "number") state.tokenBudget = tokenBudget;
|
||||||
|
// Going loud returns an unrequested token budget to its default, by leaving it out.
|
||||||
|
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
|
||||||
|
emitConfig(emit, input.sessionId);
|
||||||
|
emit(
|
||||||
|
refuseAfterChanging
|
||||||
|
? { type: "request.failed", requestId: input.requestId, error: { message: "no" } }
|
||||||
|
: { type: "request.completed", requestId: input.requestId },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plugins = new PluginAgentClientRegistry(createTestLogger());
|
||||||
|
plugins.replace([harness.registration]);
|
||||||
|
const session = await plugins.clients()["plugin-direct"]!.createSession({
|
||||||
|
provider: "plugin-direct",
|
||||||
|
cwd: tmpdir(),
|
||||||
|
providerOptions: given,
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
const changeOnItsOwn = (change: Partial<typeof state>) => {
|
||||||
|
Object.assign(state, change);
|
||||||
|
emitConfig(harness.emit, openedSessionId);
|
||||||
|
};
|
||||||
|
return { harness, session, events, changeOnItsOwn };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("forwards a setting the plugin changes on its own", async () => {
|
||||||
|
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
changeOnItsOwn({ loud: true });
|
||||||
|
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
|
||||||
|
]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.runIf(shape === "controls")(
|
||||||
|
"reports an option the plugin stops reporting as removed, alone or with another change",
|
||||||
|
async () => {
|
||||||
|
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||||
|
changeOnItsOwn({ tokenBudget: 500 });
|
||||||
|
|
||||||
|
changeOnItsOwn({ tokenBudget: undefined });
|
||||||
|
changeOnItsOwn({ tokenBudget: 700 });
|
||||||
|
changeOnItsOwn({ tokenBudget: undefined, loud: true });
|
||||||
|
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: null } },
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 700 } },
|
||||||
|
{
|
||||||
|
type: "options_changed",
|
||||||
|
provider: "plugin-direct",
|
||||||
|
patch: { token_budget: null, loud: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await session.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(shape === "controls")(
|
||||||
|
"an apply returns the option the plugin reset along the way as removed",
|
||||||
|
async () => {
|
||||||
|
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||||
|
changeOnItsOwn({ tokenBudget: 500 });
|
||||||
|
|
||||||
|
const ended = await session.applyOptions?.({ loud: true });
|
||||||
|
|
||||||
|
expect(ended).toEqual({ token_budget: null });
|
||||||
|
// The apply reports it; the plugin's later identical report is not a second change.
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
|
||||||
|
]);
|
||||||
|
await session.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
|
||||||
|
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ loud: true })).rejects.toThrow("no");
|
||||||
|
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
|
||||||
|
]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a setting the plugin opened differently is the session's open patch, not an event", async () => {
|
||||||
|
const { session, events } = await openStubbornVoiceSession({ voice: "other", loud: false });
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ voice: "direct" });
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("options the plugin opened with that nobody set are in the open patch, defaults included", async () => {
|
||||||
|
const { session, events } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying options configures only the settings it is given", async () => {
|
||||||
|
const { harness, session } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
await session.applyOptions?.({ loud: true });
|
||||||
|
|
||||||
|
const configure = lastConfigure(harness.inputs);
|
||||||
|
// Only a plugin that reports `settings` is also sent them.
|
||||||
|
expect((configure as { changes: object }).changes).toEqual(
|
||||||
|
shape === "settings"
|
||||||
|
? { providerOptions: { loud: true }, settings: { loud: true } }
|
||||||
|
: { providerOptions: { loud: true } },
|
||||||
|
);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a removal reaches the plugin as null", async () => {
|
||||||
|
const { harness, session } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
const ended = await session.applyOptions?.({ loud: null });
|
||||||
|
|
||||||
|
expect(lastConfigure(harness.inputs)).toMatchObject({
|
||||||
|
changes: { providerOptions: { loud: null } },
|
||||||
|
});
|
||||||
|
// It went back to its default, and what it ended up with is reported like any provider's.
|
||||||
|
expect(ended).toEqual({ loud: false });
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("draws a control from what the plugin reports", async () => {
|
||||||
|
const { session } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test.runIf(shape === "controls")(
|
||||||
|
"keeps a control's icon, default and choices as the plugin declared them",
|
||||||
|
async () => {
|
||||||
|
const { session } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
expect(session.controls?.[0]).toMatchObject({
|
||||||
|
icon: "Mic",
|
||||||
|
defaultValue: "direct",
|
||||||
|
choices: [
|
||||||
|
{ label: "Direct", value: "direct" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await session.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(shape === "controls")(
|
||||||
|
"sends a live option that has no control, and the plugin's answer decides",
|
||||||
|
async () => {
|
||||||
|
const { harness, session, events } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
await session.applyOptions?.({ token_budget: 2000 });
|
||||||
|
expect(lastConfigure(harness.inputs)).toMatchObject({
|
||||||
|
changes: { providerOptions: { token_budget: 2000 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// The plugin refuses the request as a whole, so the other key it was given is not applied.
|
||||||
|
await expect(session.applyOptions?.({ loud: true, token_budget: "lots" })).rejects.toThrow(
|
||||||
|
"bad budget",
|
||||||
|
);
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||||
|
expect(await session.applyOptions?.({ token_budget: 3000 })).toEqual({});
|
||||||
|
await session.close();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.runIf(shape === "settings")(
|
||||||
|
"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: { providerOptions: object };
|
||||||
|
};
|
||||||
|
expect(Object.keys(configure.changes.providerOptions)).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([
|
test.each([
|
||||||
{ options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
|
{ options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
|
||||||
{ params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
|
{ params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
|
||||||
@@ -1511,12 +1973,13 @@ describe("PluginAgentClientRegistry", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
|
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([
|
||||||
expect.objectContaining({
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
id: "voice",
|
key: "voice",
|
||||||
options: [{ id: "direct", label: "Direct", value: "direct" }],
|
label: "Voice",
|
||||||
}),
|
choices: [{ value: "direct", label: "Direct" }],
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(session.describePersistence()).toMatchObject({
|
expect(session.describePersistence()).toMatchObject({
|
||||||
provider: "plugin-direct",
|
provider: "plugin-direct",
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import { PluginSessionHistory } from "./plugin-session-history.js";
|
import { PluginSessionHistory } from "./plugin-session-history.js";
|
||||||
import { resolve as resolvePath } from "node:path";
|
import { resolve as resolvePath } from "node:path";
|
||||||
import type { Logger } from "pino";
|
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 { z } from "zod";
|
||||||
import {
|
import {
|
||||||
PROVIDER_CAPABILITIES,
|
PROVIDER_CAPABILITIES,
|
||||||
@@ -31,10 +32,10 @@ import type {
|
|||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentCreateSessionOptions,
|
AgentCreateSessionOptions,
|
||||||
AgentFeature,
|
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
|
AgentOptionControl,
|
||||||
AgentPermissionRequest,
|
AgentPermissionRequest,
|
||||||
AgentPermissionResponse,
|
AgentPermissionResponse,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
@@ -63,6 +64,8 @@ import {
|
|||||||
createProviderEnv,
|
createProviderEnv,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "./provider-launch-config.js";
|
} from "./provider-launch-config.js";
|
||||||
|
import { assertChangesAreLive } from "./provider-options.js";
|
||||||
|
import { readPluginOptionState } from "./plugin-provider-options.js";
|
||||||
import type { RegisteredProviderDefinition } from "./provider-registry.js";
|
import type { RegisteredProviderDefinition } from "./provider-registry.js";
|
||||||
import { runProviderTurn } from "./providers/provider-runner.js";
|
import { runProviderTurn } from "./providers/provider-runner.js";
|
||||||
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||||
@@ -604,7 +607,7 @@ class ProviderRuntimeSession {
|
|||||||
private readonly activeTurnIds = new Set<string>();
|
private readonly activeTurnIds = new Set<string>();
|
||||||
private terminal = false;
|
private terminal = false;
|
||||||
private closePromise: Promise<void> | null = null;
|
private closePromise: Promise<void> | null = null;
|
||||||
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
|
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [] };
|
||||||
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
|
||||||
persistence: ProviderPersistence | null = null;
|
persistence: ProviderPersistence | null = null;
|
||||||
|
|
||||||
@@ -1115,7 +1118,7 @@ class PluginAgentClient implements AgentClient {
|
|||||||
this.provider,
|
this.provider,
|
||||||
bridge,
|
bridge,
|
||||||
forget,
|
forget,
|
||||||
async (persistence, state) => {
|
async (persistence, state, options) => {
|
||||||
forget();
|
forget();
|
||||||
return await this.runtime.openSession({
|
return await this.runtime.openSession({
|
||||||
sessionId: randomUUID(),
|
sessionId: randomUUID(),
|
||||||
@@ -1124,15 +1127,15 @@ class PluginAgentClient implements AgentClient {
|
|||||||
model: state.model,
|
model: state.model,
|
||||||
mode: state.mode,
|
mode: state.mode,
|
||||||
thinkingOption: state.thinkingOption,
|
thinkingOption: state.thinkingOption,
|
||||||
settings: Object.fromEntries(
|
settings: options,
|
||||||
state.settings.map((setting) => [setting.id, setting.value]),
|
providerOptions: options,
|
||||||
),
|
|
||||||
},
|
},
|
||||||
persistence,
|
persistence,
|
||||||
history: "replay",
|
history: "replay",
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
bind,
|
bind,
|
||||||
|
config.settings,
|
||||||
);
|
);
|
||||||
bind(bridge);
|
bind(bridge);
|
||||||
return session;
|
return session;
|
||||||
@@ -1201,6 +1204,14 @@ class PluginAgentSession implements AgentSession {
|
|||||||
>();
|
>();
|
||||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||||
private unsubscribe: (() => void) | null = null;
|
private unsubscribe: (() => void) | null = null;
|
||||||
|
/** How the opened session differs from the options it was given. */
|
||||||
|
readonly openOptionsPatch: Record<string, JsonValue>;
|
||||||
|
private knownOptions: Record<string, JsonValue> = {};
|
||||||
|
// The options a reopened session starts with: what it launched with, then every value the
|
||||||
|
// plugin reported or was given, minus every key that was reset.
|
||||||
|
private readonly committedOptions: Record<string, JsonValue>;
|
||||||
|
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
|
||||||
|
private applyingOptions = false;
|
||||||
private currentTurnId: string | null = null;
|
private currentTurnId: string | null = null;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private replay: {
|
private replay: {
|
||||||
@@ -1219,10 +1230,16 @@ class PluginAgentSession implements AgentSession {
|
|||||||
private readonly reopen: (
|
private readonly reopen: (
|
||||||
persistence: ProviderPersistence,
|
persistence: ProviderPersistence,
|
||||||
state: ProviderConfigState,
|
state: ProviderConfigState,
|
||||||
|
options: Readonly<Record<string, JsonValue>>,
|
||||||
) => Promise<ProviderRuntimeSession>,
|
) => Promise<ProviderRuntimeSession>,
|
||||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||||
|
private readonly givenOptions: Readonly<Record<string, JsonValue>>,
|
||||||
) {
|
) {
|
||||||
this.subagentIdsBySession.set(bridge.id, null);
|
this.subagentIdsBySession.set(bridge.id, null);
|
||||||
|
// Every setting the session opened with that was not given, including ones nobody set.
|
||||||
|
this.committedOptions = { ...givenOptions };
|
||||||
|
this.openOptionsPatch = this.optionDifferences(givenOptions);
|
||||||
|
this.commitOptions();
|
||||||
for (const event of bridge.history) this.accept(event, false);
|
for (const event of bridge.history) this.accept(event, false);
|
||||||
// Opening notices arrive before the manager subscribes to the session.
|
// Opening notices arrive before the manager subscribes to the session.
|
||||||
// Commit them on registration and replay the same timestamps from history.
|
// Commit them on registration and replay the same timestamps from history.
|
||||||
@@ -1246,15 +1263,8 @@ class PluginAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
return this.bridge.config.settings.map((setting) =>
|
return readPluginOptionState(this.bridge.config).controls;
|
||||||
setting.type === "toggle"
|
|
||||||
? { ...setting }
|
|
||||||
: {
|
|
||||||
...setting,
|
|
||||||
options: setting.options.map((option) => ({ ...option, id: option.value })),
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||||
@@ -1410,11 +1420,53 @@ class PluginAgentSession implements AgentSession {
|
|||||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
|
||||||
this.assertReplayComplete();
|
this.assertReplayComplete();
|
||||||
await this.bridge.configure({
|
const before = this.currentOptions();
|
||||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
|
||||||
});
|
const settings: Record<string, JsonValue> = {};
|
||||||
|
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin
|
||||||
|
// reporting `settings` cannot refuse a key, so only its settings are sent and any other
|
||||||
|
// changed key is rejected here; a value equal to the one it opened with is not a change.
|
||||||
|
if (legacySettings) {
|
||||||
|
const live = controls.map((control) => control.key);
|
||||||
|
assertChangesAreLive({
|
||||||
|
provider: this.provider,
|
||||||
|
patch: options,
|
||||||
|
current: this.givenOptions,
|
||||||
|
live,
|
||||||
|
});
|
||||||
|
for (const [id, value] of Object.entries(options)) {
|
||||||
|
if (live.includes(id)) settings[id] = toJsonValue(value, `option '${id}'`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// The plugin applies every key it is given or rejects the whole request.
|
||||||
|
for (const [id, value] of Object.entries(options)) {
|
||||||
|
settings[id] = toJsonValue(value, `option '${id}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(settings).length === 0) return {};
|
||||||
|
this.applyingOptions = true;
|
||||||
|
try {
|
||||||
|
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
await this.bridge.configure({
|
||||||
|
providerOptions: settings,
|
||||||
|
...(legacySettings ? { settings } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
|
||||||
|
// call has ended.
|
||||||
|
this.applyingOptions = false;
|
||||||
|
for (const event of this.reportOptionChanges()) this.publish(event);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.applyingOptions = false;
|
||||||
|
}
|
||||||
|
// A requested key is a request, not a report: only a key the plugin reported before can be
|
||||||
|
// one it has now dropped.
|
||||||
|
const ended = this.optionChanges({ ...before, ...settings }, before);
|
||||||
|
this.commitOptions(settings);
|
||||||
|
return ended;
|
||||||
}
|
}
|
||||||
|
|
||||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||||
@@ -1596,10 +1648,66 @@ class PluginAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private currentOptions(): Record<string, JsonValue> {
|
||||||
|
return readPluginOptionState(this.bridge.config).options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options whose value differs from `known`: the plugin's own change or its opening state. */
|
||||||
|
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
|
||||||
|
const patch: Record<string, JsonValue> = {};
|
||||||
|
for (const [id, value] of Object.entries(this.currentOptions())) {
|
||||||
|
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settle the options a reopened session starts with: what is committed, then the request the
|
||||||
|
* plugin accepted, then what it reported back. A key it reported before and no longer reports
|
||||||
|
* was reset.
|
||||||
|
*/
|
||||||
|
private commitOptions(accepted: Readonly<Record<string, JsonValue>> = {}): void {
|
||||||
|
for (const [id, value] of Object.entries(accepted)) {
|
||||||
|
if (value === null) delete this.committedOptions[id];
|
||||||
|
else this.committedOptions[id] = value;
|
||||||
|
}
|
||||||
|
const current = this.currentOptions();
|
||||||
|
for (const id of Object.keys(this.knownOptions)) {
|
||||||
|
if (!(id in current)) delete this.committedOptions[id];
|
||||||
|
}
|
||||||
|
Object.assign(this.committedOptions, current);
|
||||||
|
this.knownOptions = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What changed against the options `known` to be set: values that differ, and `null` for a key
|
||||||
|
* in `reported` that the plugin no longer reports, which went back to its default.
|
||||||
|
*/
|
||||||
|
private optionChanges(
|
||||||
|
known: Readonly<Record<string, JsonValue>>,
|
||||||
|
reported: Readonly<Record<string, JsonValue>>,
|
||||||
|
): Record<string, JsonValue> {
|
||||||
|
const patch = this.optionDifferences(known);
|
||||||
|
const current = this.currentOptions();
|
||||||
|
for (const id of Object.keys(reported)) {
|
||||||
|
if (known[id] !== null && !(id in current)) patch[id] = null;
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportOptionChanges(): AgentStreamEvent[] {
|
||||||
|
if (this.applyingOptions) return [];
|
||||||
|
const patch = this.optionChanges(this.knownOptions, this.knownOptions);
|
||||||
|
if (Object.keys(patch).length === 0) return [];
|
||||||
|
this.commitOptions();
|
||||||
|
return [{ type: "options_changed", provider: this.provider, patch }];
|
||||||
|
}
|
||||||
|
|
||||||
private translateConfig(
|
private translateConfig(
|
||||||
event: Extract<ProviderEvent, { type: "session.config" }>,
|
event: Extract<ProviderEvent, { type: "session.config" }>,
|
||||||
): AgentStreamEvent[] {
|
): AgentStreamEvent[] {
|
||||||
return [
|
return [
|
||||||
|
...this.reportOptionChanges(),
|
||||||
{
|
{
|
||||||
type: "mode_changed",
|
type: "mode_changed",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@@ -1750,7 +1858,7 @@ class PluginAgentSession implements AgentSession {
|
|||||||
await this.bridge.close();
|
await this.bridge.close();
|
||||||
replay.closed = true;
|
replay.closed = true;
|
||||||
}
|
}
|
||||||
const bridge = await this.reopen(replay.persistence, replay.config);
|
const bridge = await this.reopen(replay.persistence, replay.config, this.committedOptions);
|
||||||
this.bridge = bridge;
|
this.bridge = bridge;
|
||||||
this.subagentIdsBySession.set(bridge.id, null);
|
this.subagentIdsBySession.set(bridge.id, null);
|
||||||
for (const event of bridge.history) this.accept(event, false);
|
for (const event of bridge.history) this.accept(event, false);
|
||||||
@@ -1852,7 +1960,8 @@ function mapSessionConfig(
|
|||||||
model: config.model,
|
model: config.model,
|
||||||
mode: config.modeId,
|
mode: config.modeId,
|
||||||
thinkingOption: config.thinkingOptionId,
|
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,
|
providerOptions: config.providerOptions,
|
||||||
title: config.title ?? undefined,
|
title: config.title ?? undefined,
|
||||||
persist,
|
persist,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user