mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
|
|||||||
+47
-3
@@ -30,6 +30,50 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
|||||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
for 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.
|
||||||
|
- **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 +159,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
|
|||||||
|
|
||||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
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 +522,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 +544,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
@@ -3,7 +3,7 @@ import {
|
|||||||
composerLocator,
|
composerLocator,
|
||||||
expectComposerDraft,
|
expectComposerDraft,
|
||||||
expectComposerVisible,
|
expectComposerVisible,
|
||||||
fillComposerDraft,
|
submitMessage,
|
||||||
typeIntoFocusedComposer,
|
typeIntoFocusedComposer,
|
||||||
} from "../support/helpers/composer";
|
} from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
|
|||||||
return page.getByTestId("user-message");
|
return page.getByTestId("user-message");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendDraftWhenReady(page: Page) {
|
|
||||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
|
||||||
await pressInComposer(page, "Enter");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||||
await fillComposerDraft(page, text);
|
await submitMessage(page, text);
|
||||||
await sendDraftWhenReady(page);
|
|
||||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||||
await expectComposerDraft(page, "");
|
await expectComposerDraft(page, "");
|
||||||
}
|
}
|
||||||
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
|
|||||||
await pressInComposer(page, "ArrowUp");
|
await pressInComposer(page, "ArrowUp");
|
||||||
await expectComposerDraft(page, "add tests for login");
|
await expectComposerDraft(page, "add tests for login");
|
||||||
|
|
||||||
await sendDraftWhenReady(page);
|
await pressInComposer(page, "Enter");
|
||||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -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() === "?");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -361,6 +361,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 +523,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 +900,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(),
|
||||||
@@ -2055,7 +2093,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>;
|
||||||
@@ -2971,7 +3012,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 +3023,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 +3206,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(),
|
||||||
@@ -3357,6 +3407,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModelsRequestMessageSchema,
|
ListProviderModelsRequestMessageSchema,
|
||||||
ListProviderModesRequestMessageSchema,
|
ListProviderModesRequestMessageSchema,
|
||||||
ListProviderFeaturesRequestMessageSchema,
|
ListProviderFeaturesRequestMessageSchema,
|
||||||
|
ProviderControlsListRequestMessageSchema,
|
||||||
ListAvailableProvidersRequestMessageSchema,
|
ListAvailableProvidersRequestMessageSchema,
|
||||||
GetProvidersSnapshotRequestMessageSchema,
|
GetProvidersSnapshotRequestMessageSchema,
|
||||||
RefreshProvidersSnapshotRequestMessageSchema,
|
RefreshProvidersSnapshotRequestMessageSchema,
|
||||||
@@ -3836,6 +3887,7 @@ 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(),
|
||||||
binaryChunkingV1: z.boolean().optional(),
|
binaryChunkingV1: z.boolean().optional(),
|
||||||
workspaceSetupObservation: z.boolean().optional(),
|
workspaceSetupObservation: z.boolean().optional(),
|
||||||
workspaceSortTimes: z.boolean().optional(),
|
workspaceSortTimes: z.boolean().optional(),
|
||||||
@@ -6303,6 +6355,19 @@ export const ListProviderModesResponseMessageSchema = z.object({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const ProviderControlsListResponseMessageSchema = z.object({
|
||||||
|
type: z.literal("provider.controls.list.response"),
|
||||||
|
payload: z.object({
|
||||||
|
provider: AgentProviderSchema,
|
||||||
|
controls: z.array(AgentOptionControlSchema).optional(),
|
||||||
|
/** Effective options for the draft: `config.json` defaults merged with the draft's own. */
|
||||||
|
options: ProviderOptionsSchema.optional(),
|
||||||
|
error: z.string().nullable().optional(),
|
||||||
|
fetchedAt: z.string(),
|
||||||
|
requestId: z.string(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
export const ListProviderFeaturesResponseMessageSchema = z.object({
|
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({
|
||||||
@@ -7195,6 +7260,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
|||||||
ListProviderModelsResponseMessageSchema,
|
ListProviderModelsResponseMessageSchema,
|
||||||
ListProviderModesResponseMessageSchema,
|
ListProviderModesResponseMessageSchema,
|
||||||
ListProviderFeaturesResponseMessageSchema,
|
ListProviderFeaturesResponseMessageSchema,
|
||||||
|
ProviderControlsListResponseMessageSchema,
|
||||||
ListAvailableProvidersResponseSchema,
|
ListAvailableProvidersResponseSchema,
|
||||||
GetProvidersSnapshotResponseMessageSchema,
|
GetProvidersSnapshotResponseMessageSchema,
|
||||||
ProvidersSnapshotUpdateMessageSchema,
|
ProvidersSnapshotUpdateMessageSchema,
|
||||||
@@ -7449,6 +7515,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
|
||||||
>;
|
>;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -12695,3 +12750,478 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
|
|||||||
expect(h.commands).toEqual(["/maintenance"]);
|
expect(h.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,4 +1,5 @@
|
|||||||
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";
|
||||||
@@ -33,7 +34,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 +60,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 { 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 +220,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;
|
||||||
}
|
}
|
||||||
@@ -428,7 +435,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<
|
||||||
@@ -1243,11 +1252,17 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listDraftControls(
|
||||||
|
config: AgentSessionConfig,
|
||||||
|
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
|
||||||
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
const 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 +1271,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,12 +1380,23 @@ 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,
|
||||||
});
|
});
|
||||||
config = { ...request.config, internal: config.internal };
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||||
|
// against the previous SDK edits `config.featureValues`.
|
||||||
|
config = {
|
||||||
|
...foldHookFeatureValues({
|
||||||
|
returned: request.config,
|
||||||
|
sent,
|
||||||
|
convert: (values) => this.convertLegacyOptions(request.config.provider, values),
|
||||||
|
}),
|
||||||
|
internal: config.internal,
|
||||||
|
};
|
||||||
options = { ...options, env: request.env };
|
options = { ...options, env: request.env };
|
||||||
}
|
}
|
||||||
await this.deleteAgentState(resolvedAgentId);
|
await this.deleteAgentState(resolvedAgentId);
|
||||||
@@ -1395,9 +1426,11 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
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,
|
||||||
initialTitle: options.initialTitle,
|
initialTitle: options.initialTitle,
|
||||||
workspaceId: options.workspaceId,
|
workspaceId: options.workspaceId,
|
||||||
@@ -1520,6 +1553,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 session = await client.resumeSession(
|
const session = await client.resumeSession(
|
||||||
handle,
|
handle,
|
||||||
providerLaunchConfig,
|
providerLaunchConfig,
|
||||||
@@ -1528,6 +1562,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 +1618,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 +1637,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,
|
||||||
@@ -1706,6 +1743,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,6 +1762,7 @@ 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,
|
||||||
workspaceId: existing.workspaceId,
|
workspaceId: existing.workspaceId,
|
||||||
owner: existing.owner,
|
owner: existing.owner,
|
||||||
@@ -2161,7 +2200,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(),
|
||||||
@@ -2254,20 +2297,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));
|
||||||
await agent.session.setFeature(featureId, value);
|
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
||||||
|
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
|
||||||
|
ended = (await agent.session.applyOptions(sent)) ?? {};
|
||||||
|
// Changes the provider made on its own while applying are older than its final answer.
|
||||||
await this.drainSessionEvents(agentId);
|
await this.drainSessionEvents(agentId);
|
||||||
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
|
}
|
||||||
|
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
|
||||||
|
this.updateEffectiveOptions({ agent, sent, reported: ended });
|
||||||
this.touchUpdatedAt(agent);
|
this.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();
|
||||||
@@ -3780,9 +3912,9 @@ 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;
|
||||||
@@ -3800,6 +3932,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 +3949,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 +3971,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 +4119,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:
|
||||||
@@ -3995,7 +4139,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 +4158,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,
|
||||||
@@ -4311,7 +4465,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 +4811,18 @@ export class AgentManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private onStreamOptionsChanged(params: {
|
||||||
|
agent: ActiveManagedAgent;
|
||||||
|
patch: ProviderOptionsPatch;
|
||||||
|
flags: StreamEventFlags;
|
||||||
|
}): void {
|
||||||
|
const { agent, patch, flags } = params;
|
||||||
|
this.storeOptionsPatch({ agent, patch });
|
||||||
|
this.updateEffectiveOptions({ agent, sent: {}, reported: patch });
|
||||||
|
flags.shouldDispatchEvent = false;
|
||||||
|
this.emitState(agent);
|
||||||
|
}
|
||||||
|
|
||||||
private dispatchStreamEventByType(params: {
|
private dispatchStreamEventByType(params: {
|
||||||
agent: ActiveManagedAgent;
|
agent: ActiveManagedAgent;
|
||||||
event: AgentStreamEvent;
|
event: AgentStreamEvent;
|
||||||
@@ -4708,6 +4874,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 +5352,7 @@ export class AgentManager {
|
|||||||
this.enqueueBackgroundPersist(agent);
|
this.enqueueBackgroundPersist(agent);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.syncFeaturesFromSession(agent);
|
this.syncControls(agent);
|
||||||
|
|
||||||
this.logger.trace(
|
this.logger.trace(
|
||||||
{
|
{
|
||||||
@@ -5205,9 +5374,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 +5623,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 +5695,30 @@ export class AgentManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The adapter a provider id runs on: a custom provider is named by the one it extends. */
|
||||||
|
private adapterOf(provider: AgentProvider): AgentProvider {
|
||||||
|
return resolveAdapter({
|
||||||
|
provider,
|
||||||
|
extendsOf: (id) => this.providerDefinitions.get(id)?.derivedFromProviderId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options older data carries are converted for the adapter the provider runs on, before
|
||||||
|
* defaults merge and before anything is stored; nothing past the manager sees the old keys.
|
||||||
|
*/
|
||||||
|
private convertLegacyOptions<T extends ProviderOptions | undefined>(
|
||||||
|
provider: AgentProvider,
|
||||||
|
options: T,
|
||||||
|
): T {
|
||||||
|
return convertLegacyOptions({ provider: this.adapterOf(provider), options }) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
private withConvertedOptions(config: AgentSessionConfig): AgentSessionConfig {
|
||||||
|
const providerOptions = this.convertLegacyOptions(config.provider, config.providerOptions);
|
||||||
|
return providerOptions === config.providerOptions ? config : { ...config, providerOptions };
|
||||||
|
}
|
||||||
|
|
||||||
private async prepareSessionConfig(
|
private async prepareSessionConfig(
|
||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
agentId: string,
|
agentId: string,
|
||||||
|
|||||||
@@ -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({
|
||||||
@@ -474,13 +464,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 "./option-controls.js";
|
||||||
import type { JsonValue } from "../json-utils.js";
|
import type { JsonValue } from "../json-utils.js";
|
||||||
import {
|
import {
|
||||||
isStoredAgentProviderAvailable,
|
isStoredAgentProviderAvailable,
|
||||||
@@ -97,7 +99,7 @@ export function toStoredAgentRecord(
|
|||||||
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,7 +145,12 @@ 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,
|
||||||
@@ -375,12 +382,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 +457,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: ProviderOptions): 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");
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import { z } from "zod";
|
|||||||
import type { Logger } from "pino";
|
import type { Logger } from "pino";
|
||||||
|
|
||||||
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, 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 +44,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(),
|
||||||
@@ -68,6 +70,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 +88,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;
|
||||||
@@ -70,7 +73,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 +221,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;
|
||||||
@@ -323,7 +326,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 +364,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 +388,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 +408,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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1805,7 +1805,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 +1834,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 +3039,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 +3072,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 +3273,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 +3307,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 +3332,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 +3360,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 +3384,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 +4717,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 +4742,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 +5065,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 +5093,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 +5867,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 +5882,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 +5948,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 +5984,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 +6005,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 +6221,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,304 @@
|
|||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
|
import type {
|
||||||
|
AgentFeature,
|
||||||
|
AgentOptionControl,
|
||||||
|
AgentOptionSelectControl,
|
||||||
|
ProviderOptions,
|
||||||
|
} from "@getpaseo/protocol/agent-types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A patch over an agent's own options: top-level keys replace, `null` removes the key so the
|
||||||
|
* `config.json` default (or the provider's own default) applies again.
|
||||||
|
*/
|
||||||
|
export type ProviderOptionsPatch = Record<string, unknown>;
|
||||||
|
|
||||||
|
export function applyProviderOptionsPatch(input: {
|
||||||
|
options: ProviderOptions | undefined;
|
||||||
|
patch: ProviderOptionsPatch;
|
||||||
|
}): ProviderOptions {
|
||||||
|
const { options, patch } = input;
|
||||||
|
const next: ProviderOptions = { ...options };
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null) {
|
||||||
|
delete next[key];
|
||||||
|
} else if (value !== undefined) {
|
||||||
|
next[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. */
|
||||||
|
export interface LegacyFeatureValues {
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast
|
||||||
|
* preferences have migrated. Codex used to take `fast_mode`; it takes `service_tier` now. Old
|
||||||
|
* data (stored records, config.json defaults, profiles, older clients, hooks) is converted here
|
||||||
|
* as it enters, so nothing past this point sees `fast_mode` for Codex. A `service_tier` in the
|
||||||
|
* same input wins. A patch's `null` stays `null`, which removes the speed.
|
||||||
|
*/
|
||||||
|
interface LegacyOptionsInput<O> {
|
||||||
|
provider: string | undefined;
|
||||||
|
options: O;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function convertLegacyOptions(
|
||||||
|
input: LegacyOptionsInput<Record<string, unknown>>,
|
||||||
|
): Record<string, unknown>;
|
||||||
|
export function convertLegacyOptions(
|
||||||
|
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
|
||||||
|
): Record<string, unknown> | undefined;
|
||||||
|
export function convertLegacyOptions(
|
||||||
|
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
|
||||||
|
): Record<string, unknown> | undefined {
|
||||||
|
const { provider, options } = input;
|
||||||
|
if (provider !== "codex" || !options || !("fast_mode" in options)) return options;
|
||||||
|
const { fast_mode: legacy, ...rest } = options;
|
||||||
|
if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") {
|
||||||
|
throw new Error("Codex fast_mode must be true, false or null");
|
||||||
|
}
|
||||||
|
if ("service_tier" in rest || legacy === undefined) return rest;
|
||||||
|
if (legacy === null) return { ...rest, service_tier: null };
|
||||||
|
return { ...rest, service_tier: legacy ? "fast" : "default" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The built-in adapter a provider id runs on: a custom provider is named by the one it
|
||||||
|
* `extends`, followed until a provider that extends nothing. `extendsOf` is where the caller
|
||||||
|
* reads that link (the registry's definitions, or `config.json`'s `providers` section).
|
||||||
|
*/
|
||||||
|
export function resolveAdapter(input: {
|
||||||
|
provider: string;
|
||||||
|
extendsOf: (provider: string) => string | null | undefined;
|
||||||
|
}): string {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let current = input.provider;
|
||||||
|
for (;;) {
|
||||||
|
seen.add(current);
|
||||||
|
const base = input.extendsOf(current);
|
||||||
|
if (!base || seen.has(base)) return current;
|
||||||
|
current = base;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
* Older clients, stored records, profiles, and plugin hooks carry composer values in
|
||||||
|
* `featureValues`. This is the one place they become `providerOptions`; an explicit
|
||||||
|
* `providerOptions` key wins over a `featureValues` key of the same name.
|
||||||
|
*/
|
||||||
|
export function foldFeatureValues<
|
||||||
|
T extends {
|
||||||
|
providerOptions?: ProviderOptions | null;
|
||||||
|
featureValues?: Record<string, unknown> | null;
|
||||||
|
},
|
||||||
|
>(config: T): Omit<T, "featureValues"> {
|
||||||
|
const { featureValues, ...rest } = config;
|
||||||
|
if (!featureValues || Object.keys(featureValues).length === 0) return rest;
|
||||||
|
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
* A plugin written against the previous SDK reads and edits `config.featureValues`, so a
|
||||||
|
* hook sees a copy of the config's options there. What the hook did to that copy (set,
|
||||||
|
* replaced or deleted a key, or dropped the field) is applied as a patch over the options it
|
||||||
|
* returned; keys it left alone change nothing.
|
||||||
|
*/
|
||||||
|
export function exposeFeatureValuesToHook<
|
||||||
|
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
|
||||||
|
>(config: T): T {
|
||||||
|
if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config;
|
||||||
|
return { ...config, featureValues: { ...config.providerOptions } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function foldHookFeatureValues<
|
||||||
|
T extends {
|
||||||
|
providerOptions?: ProviderOptions | null;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
},
|
||||||
|
>(input: {
|
||||||
|
returned: T;
|
||||||
|
sent: T;
|
||||||
|
/** Brings what the hook returned to the form of the copy it was sent. */
|
||||||
|
convert: (options: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
}): Omit<T, "featureValues"> {
|
||||||
|
const { returned, sent, convert } = input;
|
||||||
|
const { featureValues, ...rest } = returned;
|
||||||
|
const before = sent.featureValues;
|
||||||
|
if (!before) {
|
||||||
|
// No copy was sent, so everything the hook put in `featureValues` is new.
|
||||||
|
return foldFeatureValues(returned);
|
||||||
|
}
|
||||||
|
// A hook may return an older key where it was sent the current one; comparing in the same
|
||||||
|
// form keeps a replaced value from reading as a deletion.
|
||||||
|
const after = convert(featureValues ?? {});
|
||||||
|
const patch: ProviderOptionsPatch = {};
|
||||||
|
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||||
|
if (isDeepStrictEqual(after[key], before[key])) continue;
|
||||||
|
patch[key] = key in after ? after[key] : null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
providerOptions: applyProviderOptionsPatch({
|
||||||
|
options: rest.providerOptions ?? undefined,
|
||||||
|
patch,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ConvertedOptions = Record<string, unknown> | undefined;
|
||||||
|
|
||||||
|
interface ProfileOptionFields {
|
||||||
|
provider?: string;
|
||||||
|
options?: Record<string, unknown>;
|
||||||
|
featureValues?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
* Profiles saved before options carry `featureValues`; when one comes from storage, `options`
|
||||||
|
* wins over a stale `featureValues` key.
|
||||||
|
*
|
||||||
|
* A write that carries `options` is from a client that knows them: its `featureValues` fold
|
||||||
|
* over those. A write without `options` is from the unchanged app, whose form only knows the
|
||||||
|
* controls: its `featureValues` are a patch (`null` deletes) over the options stored for that
|
||||||
|
* profile, so options the form never shows, such as `sandbox_mode`, survive an edit. A profile
|
||||||
|
* moved to another provider starts from no options, because the old provider's keys mean
|
||||||
|
* nothing to the new one.
|
||||||
|
*/
|
||||||
|
export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
|
||||||
|
profile: T;
|
||||||
|
stored: ProfileOptionFields | undefined;
|
||||||
|
source: "stored" | "client write";
|
||||||
|
/** Renames old option keys for the adapter the profile's provider runs on. */
|
||||||
|
convert: (provider: string, options: Record<string, unknown> | undefined) => ConvertedOptions;
|
||||||
|
}): Omit<T, "featureValues"> {
|
||||||
|
const { profile, stored, source, convert } = input;
|
||||||
|
const { featureValues: legacyValues, ...rest } = profile;
|
||||||
|
// A profile is stored in the current keys, so removal and merging below see one vocabulary.
|
||||||
|
const provider = rest.provider ?? "";
|
||||||
|
const featureValues = convert(provider, legacyValues);
|
||||||
|
const ownOptions = convert(provider, rest.options);
|
||||||
|
if (source === "stored") {
|
||||||
|
if (featureValues) return { ...rest, options: { ...featureValues, ...ownOptions } };
|
||||||
|
return ownOptions ? { ...rest, options: ownOptions } : rest;
|
||||||
|
}
|
||||||
|
const base =
|
||||||
|
ownOptions ??
|
||||||
|
(stored?.provider === rest.provider ? convert(provider, stored?.options) : undefined);
|
||||||
|
// The form sends no `featureValues` when no control is set, which is still an edit of a
|
||||||
|
// profile that may hold other options.
|
||||||
|
if (!featureValues && !base) return rest;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
options: applyProviderOptionsPatch({ options: base, patch: featureValues ?? {} }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
* The unchanged app reads a profile's options from `featureValues`; the config API answers with
|
||||||
|
* both fields.
|
||||||
|
*/
|
||||||
|
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(profile: T): T {
|
||||||
|
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
|
||||||
|
const value = options[control.key];
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const choice = control.choices.find((candidate) => candidate.value === value);
|
||||||
|
if (choice) return choice.value;
|
||||||
|
}
|
||||||
|
return control.defaultValue ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function describe(control: { description?: string; tooltip?: string; icon?: string }): {
|
||||||
|
description?: string;
|
||||||
|
tooltip?: string;
|
||||||
|
icon?: string;
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
...(control.description !== undefined ? { description: control.description } : {}),
|
||||||
|
...(control.tooltip !== undefined ? { tooltip: control.tooltip } : {}),
|
||||||
|
...(control.icon !== undefined ? { icon: control.icon } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature {
|
||||||
|
if (control.type === "toggle") {
|
||||||
|
const value = effective[control.key];
|
||||||
|
return {
|
||||||
|
type: "toggle",
|
||||||
|
id: control.key,
|
||||||
|
label: control.label,
|
||||||
|
...describe(control),
|
||||||
|
value: typeof value === "boolean" ? value : (control.defaultValue ?? false),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "select",
|
||||||
|
id: control.key,
|
||||||
|
label: control.label,
|
||||||
|
...describe(control),
|
||||||
|
...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}),
|
||||||
|
value: selectedChoice(control, effective),
|
||||||
|
options: control.choices.map((choice) => ({
|
||||||
|
id: choice.value,
|
||||||
|
label: choice.label,
|
||||||
|
...(choice.description ? { description: choice.description } : {}),
|
||||||
|
...(choice.value === control.defaultValue ? { isDefault: true } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
* Apps that predate controls render `features`; build them from controls + effective options.
|
||||||
|
*/
|
||||||
|
export function featuresFromControls(input: {
|
||||||
|
controls: readonly AgentOptionControl[] | undefined;
|
||||||
|
options: ProviderOptions | undefined;
|
||||||
|
}): AgentFeature[] {
|
||||||
|
const { controls, options } = input;
|
||||||
|
const effective = options ?? {};
|
||||||
|
return (controls ?? []).map((control) => featureFromControl(control, effective));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A recorded feature's value is not carried over: controls hold no state, only `options` do. */
|
||||||
|
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
|
||||||
|
const descriptor = {
|
||||||
|
key: feature.id,
|
||||||
|
label: feature.label,
|
||||||
|
...describe(feature),
|
||||||
|
};
|
||||||
|
if (feature.type === "toggle") {
|
||||||
|
return { type: "toggle", ...descriptor };
|
||||||
|
}
|
||||||
|
const defaultChoice = feature.options.find((option) => option.isDefault);
|
||||||
|
return {
|
||||||
|
type: "select",
|
||||||
|
...descriptor,
|
||||||
|
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
|
||||||
|
...(defaultChoice ? { defaultValue: defaultChoice.id } : {}),
|
||||||
|
choices: feature.options.map((option) => ({
|
||||||
|
value: option.id,
|
||||||
|
label: option.label,
|
||||||
|
...(option.description ? { description: option.description } : {}),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
* Agent records written before controls stored `features`; their descriptors become controls.
|
||||||
|
*/
|
||||||
|
export function controlsFromFeatures(
|
||||||
|
features: readonly AgentFeature[] | undefined,
|
||||||
|
): AgentOptionControl[] {
|
||||||
|
return (features ?? []).map(controlFromFeature);
|
||||||
|
}
|
||||||
@@ -58,6 +58,10 @@ interface ProviderHarnessOptions {
|
|||||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
|
||||||
|
return inputs.findLast((input) => input.type === "session.configure");
|
||||||
|
}
|
||||||
|
|
||||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||||
let listener: ((event: ProviderEvent) => void) | null = null;
|
let listener: ((event: ProviderEvent) => void) | null = null;
|
||||||
let closeCount = 0;
|
let closeCount = 0;
|
||||||
@@ -970,7 +974,7 @@ describe("PluginAgentClientRegistry", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stores only agent options while the plugin receives merged defaults", async () => {
|
test("stores the agent options and the settings the plugin opened with, not the merged config defaults", async () => {
|
||||||
const logger = createTestLogger();
|
const logger = createTestLogger();
|
||||||
const harness = createProviderHarness();
|
const harness = createProviderHarness();
|
||||||
const plugins = new PluginAgentClientRegistry(logger);
|
const plugins = new PluginAgentClientRegistry(logger);
|
||||||
@@ -992,13 +996,174 @@ 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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("settings", () => {
|
||||||
|
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
|
||||||
|
async function openStubbornVoiceSession(
|
||||||
|
given: Record<string, boolean | string>,
|
||||||
|
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
|
||||||
|
) {
|
||||||
|
const state = { voice: "direct", loud: false };
|
||||||
|
let openedSessionId = "";
|
||||||
|
const settings = () => [
|
||||||
|
{
|
||||||
|
type: "select" as const,
|
||||||
|
id: "voice",
|
||||||
|
label: "Voice",
|
||||||
|
value: state.voice,
|
||||||
|
options: [
|
||||||
|
{ label: "Direct", value: "direct" },
|
||||||
|
{ label: "Other", value: "other" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
|
||||||
|
];
|
||||||
|
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||||
|
emit({
|
||||||
|
type: "session.config",
|
||||||
|
sessionId,
|
||||||
|
config: {
|
||||||
|
model: "plugin-model",
|
||||||
|
mode: "build",
|
||||||
|
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||||
|
modes: [{ id: "build", label: "Build" }],
|
||||||
|
thinkingOptions: [],
|
||||||
|
settings: settings(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const harness = createProviderHarness({
|
||||||
|
async handleInput(input, emit) {
|
||||||
|
if (input.type === "session.open") {
|
||||||
|
openedSessionId = input.sessionId;
|
||||||
|
emit({
|
||||||
|
type: "session.opened",
|
||||||
|
requestId: input.requestId,
|
||||||
|
sessionId: input.sessionId,
|
||||||
|
capabilities: CAPABILITIES,
|
||||||
|
restoration: "core",
|
||||||
|
persistence: { version: 1, data: { token: "root" } },
|
||||||
|
cwd: input.config.cwd,
|
||||||
|
});
|
||||||
|
emitConfig(emit, input.sessionId);
|
||||||
|
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (input.type === "session.configure") {
|
||||||
|
const loud = input.changes.settings?.loud;
|
||||||
|
if (typeof loud === "boolean") state.loud = loud;
|
||||||
|
emitConfig(emit, input.sessionId);
|
||||||
|
emit(
|
||||||
|
refuseAfterChanging
|
||||||
|
? { type: "request.failed", requestId: input.requestId, error: { message: "no" } }
|
||||||
|
: { type: "request.completed", requestId: input.requestId },
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const plugins = new PluginAgentClientRegistry(createTestLogger());
|
||||||
|
plugins.replace([harness.registration]);
|
||||||
|
const session = await plugins.clients()["plugin-direct"]!.createSession({
|
||||||
|
provider: "plugin-direct",
|
||||||
|
cwd: tmpdir(),
|
||||||
|
providerOptions: given,
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
const changeOnItsOwn = (change: Partial<typeof state>) => {
|
||||||
|
Object.assign(state, change);
|
||||||
|
emitConfig(harness.emit, openedSessionId);
|
||||||
|
};
|
||||||
|
return { harness, session, events, changeOnItsOwn };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("forwards a setting the plugin changes on its own", async () => {
|
||||||
|
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
changeOnItsOwn({ loud: true });
|
||||||
|
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
|
||||||
|
]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
|
||||||
|
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ loud: true })).rejects.toThrow("no");
|
||||||
|
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||||
|
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
|
||||||
|
]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a setting the plugin opened differently is the session's open patch, not an event", async () => {
|
||||||
|
const { session, events } = await openStubbornVoiceSession({ voice: "other", loud: false });
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ voice: "direct" });
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings the plugin opened with that nobody set are in the open patch", async () => {
|
||||||
|
const { session, events } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying options configures only the settings it is given", async () => {
|
||||||
|
const { harness, session } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
await session.applyOptions?.({ loud: true });
|
||||||
|
|
||||||
|
const configure = lastConfigure(harness.inputs);
|
||||||
|
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
|
||||||
|
expect(
|
||||||
|
Object.keys((configure as { changes: { settings: object } }).changes.settings),
|
||||||
|
).toEqual(["loud"]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => {
|
||||||
|
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
|
||||||
|
const configureBefore = lastConfigure(harness.inputs);
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
|
||||||
|
/launch_key.*cannot change on a running session/,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
|
||||||
|
|
||||||
|
// The value it opened with is not a change, so a whole profile can be applied.
|
||||||
|
await session.applyOptions?.({ loud: true, launch_key: "original" });
|
||||||
|
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
|
||||||
|
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying options returns the settings that ended up different, and emits nothing", async () => {
|
||||||
|
const { session, events } = await openStubbornVoiceSession({});
|
||||||
|
|
||||||
|
const ended = await session.applyOptions?.({ voice: "other", loud: true });
|
||||||
|
|
||||||
|
expect(ended).toEqual({ voice: "direct" });
|
||||||
|
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test.each([
|
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 +1676,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,7 @@ import {
|
|||||||
createProviderEnv,
|
createProviderEnv,
|
||||||
type ProviderRuntimeSettings,
|
type ProviderRuntimeSettings,
|
||||||
} from "./provider-launch-config.js";
|
} from "./provider-launch-config.js";
|
||||||
|
import { assertChangesAreLive } from "./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";
|
||||||
@@ -1133,6 +1135,7 @@ class PluginAgentClient implements AgentClient {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
bind,
|
bind,
|
||||||
|
config.settings,
|
||||||
);
|
);
|
||||||
bind(bridge);
|
bind(bridge);
|
||||||
return session;
|
return session;
|
||||||
@@ -1201,6 +1204,11 @@ 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 settings it was given. */
|
||||||
|
readonly openOptionsPatch: Record<string, JsonValue>;
|
||||||
|
private knownSettings: Record<string, JsonValue> = {};
|
||||||
|
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
|
||||||
|
private applyingOptions = false;
|
||||||
private currentTurnId: string | null = null;
|
private currentTurnId: string | null = null;
|
||||||
private closed = false;
|
private closed = false;
|
||||||
private replay: {
|
private replay: {
|
||||||
@@ -1221,8 +1229,12 @@ class PluginAgentSession implements AgentSession {
|
|||||||
state: ProviderConfigState,
|
state: ProviderConfigState,
|
||||||
) => Promise<ProviderRuntimeSession>,
|
) => Promise<ProviderRuntimeSession>,
|
||||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||||
|
private readonly givenSettings: 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.openOptionsPatch = this.settingDifferences(givenSettings);
|
||||||
|
this.knownSettings = this.currentSettings();
|
||||||
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,13 +1258,26 @@ class PluginAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
/** The plugin's `settings` are its option controls; each setting id is an option key. */
|
||||||
return this.bridge.config.settings.map((setting) =>
|
get controls(): AgentOptionControl[] {
|
||||||
|
return this.bridge.config.settings.map(
|
||||||
|
(setting): AgentOptionControl =>
|
||||||
setting.type === "toggle"
|
setting.type === "toggle"
|
||||||
? { ...setting }
|
? {
|
||||||
|
type: "toggle",
|
||||||
|
key: setting.id,
|
||||||
|
label: setting.label,
|
||||||
|
...(setting.description ? { description: setting.description } : {}),
|
||||||
|
}
|
||||||
: {
|
: {
|
||||||
...setting,
|
type: "select",
|
||||||
options: setting.options.map((option) => ({ ...option, id: option.value })),
|
key: setting.id,
|
||||||
|
label: setting.label,
|
||||||
|
...(setting.description ? { description: setting.description } : {}),
|
||||||
|
choices: setting.options.map((option) => ({
|
||||||
|
value: option.value,
|
||||||
|
label: option.label,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1410,11 +1435,38 @@ class PluginAgentSession implements AgentSession {
|
|||||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
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.currentSettings();
|
||||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
// A key the plugin does not report as a setting is one it only reads when the session opens;
|
||||||
|
// a value equal to the one it opened with is not a change.
|
||||||
|
assertChangesAreLive({
|
||||||
|
provider: this.provider,
|
||||||
|
patch: options,
|
||||||
|
current: this.givenSettings,
|
||||||
|
live: Object.keys(before),
|
||||||
});
|
});
|
||||||
|
const settings: Record<string, JsonValue> = {};
|
||||||
|
for (const [id, value] of Object.entries(options)) {
|
||||||
|
if (!(id in before)) continue;
|
||||||
|
settings[id] = toJsonValue(value, `setting '${id}'`);
|
||||||
|
}
|
||||||
|
if (Object.keys(settings).length === 0) return {};
|
||||||
|
this.applyingOptions = true;
|
||||||
|
try {
|
||||||
|
await this.bridge.configure({ settings });
|
||||||
|
} catch (error) {
|
||||||
|
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
|
||||||
|
// call has ended.
|
||||||
|
this.applyingOptions = false;
|
||||||
|
for (const event of this.reportSettingChanges()) this.publish(event);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.applyingOptions = false;
|
||||||
|
}
|
||||||
|
const ended = this.settingDifferences({ ...before, ...settings });
|
||||||
|
this.knownSettings = this.currentSettings();
|
||||||
|
return ended;
|
||||||
}
|
}
|
||||||
|
|
||||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||||
@@ -1596,10 +1648,36 @@ class PluginAgentSession implements AgentSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private currentSettings(): Record<string, JsonValue> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
|
||||||
|
private settingDifferences(
|
||||||
|
known: Readonly<Record<string, JsonValue>>,
|
||||||
|
): Record<string, JsonValue> {
|
||||||
|
const patch: Record<string, JsonValue> = {};
|
||||||
|
for (const [id, value] of Object.entries(this.currentSettings())) {
|
||||||
|
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
|
||||||
|
}
|
||||||
|
return patch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private reportSettingChanges(): AgentStreamEvent[] {
|
||||||
|
if (this.applyingOptions) return [];
|
||||||
|
const patch = this.settingDifferences(this.knownSettings);
|
||||||
|
if (Object.keys(patch).length === 0) return [];
|
||||||
|
this.knownSettings = this.currentSettings();
|
||||||
|
return [{ type: "options_changed", provider: this.provider, patch }];
|
||||||
|
}
|
||||||
|
|
||||||
private translateConfig(
|
private translateConfig(
|
||||||
event: Extract<ProviderEvent, { type: "session.config" }>,
|
event: Extract<ProviderEvent, { type: "session.config" }>,
|
||||||
): AgentStreamEvent[] {
|
): AgentStreamEvent[] {
|
||||||
return [
|
return [
|
||||||
|
...this.reportSettingChanges(),
|
||||||
{
|
{
|
||||||
type: "mode_changed",
|
type: "mode_changed",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@@ -1852,7 +1930,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,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { isDeepStrictEqual } from "node:util";
|
||||||
import type { z } from "zod";
|
import type { z } from "zod";
|
||||||
|
|
||||||
export interface ProviderOptionIssue {
|
export interface ProviderOptionIssue {
|
||||||
@@ -103,3 +104,26 @@ function isPlainObject(value: unknown): value is ProviderOptions {
|
|||||||
const prototype = Object.getPrototypeOf(value);
|
const prototype = Object.getPrototypeOf(value);
|
||||||
return prototype === Object.prototype || prototype === null;
|
return prototype === Object.prototype || prototype === null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rejects a live patch that changes a key the running session cannot apply (one the provider
|
||||||
|
* reads only when it opens). A key whose value already matches the session's is not a change.
|
||||||
|
*/
|
||||||
|
export function assertChangesAreLive(input: {
|
||||||
|
provider: string;
|
||||||
|
patch: ProviderOptions;
|
||||||
|
current: ProviderOptions | undefined;
|
||||||
|
live: readonly string[];
|
||||||
|
}): void {
|
||||||
|
const issues = Object.entries(input.patch)
|
||||||
|
.filter(
|
||||||
|
([key, value]) =>
|
||||||
|
!input.live.includes(key) &&
|
||||||
|
!isDeepStrictEqual(input.current?.[key] ?? null, value ?? null),
|
||||||
|
)
|
||||||
|
.map(([key]) => ({
|
||||||
|
path: [key],
|
||||||
|
message: "cannot change on a running session; it applies when the agent is opened",
|
||||||
|
}));
|
||||||
|
if (issues.length > 0) throw new ProviderOptionsValidationError(input.provider, issues);
|
||||||
|
}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
|
|||||||
"listCommands",
|
"listCommands",
|
||||||
"setModel",
|
"setModel",
|
||||||
"setThinkingOption",
|
"setThinkingOption",
|
||||||
"setFeature",
|
"applyOptions",
|
||||||
"revertConversation",
|
"revertConversation",
|
||||||
"revertFiles",
|
"revertFiles",
|
||||||
"revertBoth",
|
"revertBoth",
|
||||||
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
|
|||||||
this.recordedCalls.push("setThinkingOption");
|
this.recordedCalls.push("setThinkingOption");
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature() {
|
async applyOptions() {
|
||||||
this.recordedCalls.push("setFeature");
|
this.recordedCalls.push("applyOptions");
|
||||||
}
|
}
|
||||||
|
|
||||||
async revertConversation() {
|
async revertConversation() {
|
||||||
@@ -193,7 +193,7 @@ describe("wrapSessionProvider", () => {
|
|||||||
await wrapped.listCommands?.();
|
await wrapped.listCommands?.();
|
||||||
await wrapped.setModel?.("sonnet");
|
await wrapped.setModel?.("sonnet");
|
||||||
await wrapped.setThinkingOption?.("high");
|
await wrapped.setThinkingOption?.("high");
|
||||||
await wrapped.setFeature?.("feature-1", true);
|
await wrapped.applyOptions?.({ "feature-1": true });
|
||||||
await wrapped.revertConversation?.({ messageId: "message-1" });
|
await wrapped.revertConversation?.({ messageId: "message-1" });
|
||||||
await wrapped.revertFiles?.({ messageId: "message-1" });
|
await wrapped.revertFiles?.({ messageId: "message-1" });
|
||||||
await wrapped.revertBoth?.({ messageId: "message-1" });
|
await wrapped.revertBoth?.({ messageId: "message-1" });
|
||||||
@@ -208,7 +208,7 @@ describe("wrapSessionProvider", () => {
|
|||||||
"listCommands",
|
"listCommands",
|
||||||
"setModel",
|
"setModel",
|
||||||
"setThinkingOption",
|
"setThinkingOption",
|
||||||
"setFeature",
|
"applyOptions",
|
||||||
"revertConversation",
|
"revertConversation",
|
||||||
"revertFiles",
|
"revertFiles",
|
||||||
"revertBoth",
|
"revertBoth",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
|
|||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||||
import type {
|
import type {
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
mockState.cursorListFeaturesConfigs.push(config);
|
mockState.cursorListFeaturesConfigs.push(config);
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
id: "fast",
|
key: "fast",
|
||||||
label: "Fast",
|
label: "Fast",
|
||||||
value: "false",
|
defaultValue: "false",
|
||||||
options: [{ id: "false", label: "Off" }],
|
choices: [{ value: "false", label: "Off" }],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -791,6 +791,33 @@ test("ACP provider configuration retains opaque options", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a saved legacy Codex Fast preference wins over a configured service_tier default", () => {
|
||||||
|
const registry = buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: { codex: { options: { service_tier: "default", plan_mode: false } } },
|
||||||
|
});
|
||||||
|
const client = registry.codex.createClient(logger);
|
||||||
|
|
||||||
|
// The manager hands the registry a pre-v0.10 record's `fast_mode` already converted.
|
||||||
|
expect(client.effectiveOptions?.({ service_tier: "fast" })).toEqual({
|
||||||
|
service_tier: "fast",
|
||||||
|
plan_mode: false,
|
||||||
|
});
|
||||||
|
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "default", plan_mode: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a configured legacy Codex Fast default supplies the service_tier default", () => {
|
||||||
|
const registry = buildProviderRegistry(logger, {
|
||||||
|
providerOverrides: { codex: { options: { fast_mode: true } } },
|
||||||
|
});
|
||||||
|
const client = registry.codex.createClient(logger);
|
||||||
|
|
||||||
|
// Removing an agent's own speed falls back to the configured Fast.
|
||||||
|
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "fast" });
|
||||||
|
expect(client.effectiveOptions?.({ service_tier: "default" })).toEqual({
|
||||||
|
service_tier: "default",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
@@ -823,7 +850,7 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
|||||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("wrapped cursor client lists ACP features through the inner provider", async () => {
|
test("wrapped cursor client lists ACP controls through the inner provider", async () => {
|
||||||
const registry = buildProviderRegistry(logger, {
|
const registry = buildProviderRegistry(logger, {
|
||||||
providerOverrides: {
|
providerOverrides: {
|
||||||
cursor: {
|
cursor: {
|
||||||
@@ -837,17 +864,17 @@ test("wrapped cursor client lists ACP features through the inner provider", asyn
|
|||||||
const client = registry.cursor.createClient(logger);
|
const client = registry.cursor.createClient(logger);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures?.({
|
client.listControls?.({
|
||||||
provider: "cursor",
|
provider: "cursor",
|
||||||
cwd: "/tmp/cursor",
|
cwd: "/tmp/cursor",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([
|
).resolves.toEqual([
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
id: "fast",
|
key: "fast",
|
||||||
label: "Fast",
|
label: "Fast",
|
||||||
value: "false",
|
defaultValue: "false",
|
||||||
options: [{ id: "false", label: "Off" }],
|
choices: [{ value: "false", label: "Off" }],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mockState.cursorListFeaturesConfigs).toEqual([
|
expect(mockState.cursorListFeaturesConfigs).toEqual([
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
|||||||
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
|
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
|
||||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||||
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
|
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
|
||||||
|
import { convertLegacyOptions } from "./option-controls.js";
|
||||||
import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js";
|
import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js";
|
||||||
import {
|
import {
|
||||||
AGENT_PROVIDER_DEFINITIONS,
|
AGENT_PROVIDER_DEFINITIONS,
|
||||||
@@ -447,8 +448,11 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
|||||||
get initialTimeline() {
|
get initialTimeline() {
|
||||||
return inner.initialTimeline;
|
return inner.initialTimeline;
|
||||||
},
|
},
|
||||||
get features() {
|
get controls() {
|
||||||
return inner.features;
|
return inner.controls;
|
||||||
|
},
|
||||||
|
get openOptionsPatch() {
|
||||||
|
return inner.openOptionsPatch;
|
||||||
},
|
},
|
||||||
usageSession: inner.usageSession?.bind(inner),
|
usageSession: inner.usageSession?.bind(inner),
|
||||||
run: (prompt, options) => inner.run(prompt, options),
|
run: (prompt, options) => inner.run(prompt, options),
|
||||||
@@ -473,7 +477,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
|||||||
listCommands: inner.listCommands?.bind(inner),
|
listCommands: inner.listCommands?.bind(inner),
|
||||||
setModel: inner.setModel?.bind(inner),
|
setModel: inner.setModel?.bind(inner),
|
||||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||||
setFeature: inner.setFeature?.bind(inner),
|
applyOptions: inner.applyOptions?.bind(inner),
|
||||||
revertConversation: inner.revertConversation?.bind(inner),
|
revertConversation: inner.revertConversation?.bind(inner),
|
||||||
revertFiles: inner.revertFiles?.bind(inner),
|
revertFiles: inner.revertFiles?.bind(inner),
|
||||||
revertBoth: inner.revertBoth?.bind(inner),
|
revertBoth: inner.revertBoth?.bind(inner),
|
||||||
@@ -487,14 +491,20 @@ function wrapClientProvider(
|
|||||||
profileModels: ProviderProfileModel[],
|
profileModels: ProviderProfileModel[],
|
||||||
additionalModels: ProviderProfileModel[],
|
additionalModels: ProviderProfileModel[],
|
||||||
profileModelsAreAdditive: boolean,
|
profileModelsAreAdditive: boolean,
|
||||||
providerOptions: ProviderOptions | undefined,
|
configuredOptions: ProviderOptions | undefined,
|
||||||
): AgentClient {
|
): AgentClient {
|
||||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||||
const importSession = inner.importSession?.bind(inner);
|
const importSession = inner.importSession?.bind(inner);
|
||||||
const listFeatures = inner.listFeatures?.bind(inner);
|
const listControls = inner.listControls?.bind(inner);
|
||||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||||
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
|
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
|
||||||
|
// Converted where it is used, so a malformed configured default fails the call that reads it
|
||||||
|
// rather than the registry that holds every provider.
|
||||||
|
const configuredDefaults = () =>
|
||||||
|
convertLegacyOptions({ provider: inner.provider, options: configuredOptions });
|
||||||
|
const withDefaults = (own: ProviderOptions | undefined) =>
|
||||||
|
mergeProviderOptions(configuredDefaults(), own);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
provider,
|
provider,
|
||||||
@@ -509,7 +519,7 @@ function wrapClientProvider(
|
|||||||
{
|
{
|
||||||
...config,
|
...config,
|
||||||
provider: inner.provider,
|
provider: inner.provider,
|
||||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
providerOptions: withDefaults(config.providerOptions),
|
||||||
},
|
},
|
||||||
launchContext,
|
launchContext,
|
||||||
{ ...options, configuredModelIds },
|
{ ...options, configuredModelIds },
|
||||||
@@ -526,14 +536,17 @@ function wrapClientProvider(
|
|||||||
{
|
{
|
||||||
...overrides,
|
...overrides,
|
||||||
provider: inner.provider,
|
provider: inner.provider,
|
||||||
providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
|
providerOptions: withDefaults(overrides?.providerOptions),
|
||||||
},
|
},
|
||||||
launchContext,
|
launchContext,
|
||||||
{ ...options, configuredModelIds },
|
{ ...options, configuredModelIds },
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
fetchCatalog: async (options, context) => {
|
fetchCatalog: async (options, context) => {
|
||||||
const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context);
|
const catalog = await inner.fetchCatalog(
|
||||||
|
{ ...options, providerOptions: configuredDefaults() },
|
||||||
|
context,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
...catalog,
|
...catalog,
|
||||||
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
|
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
|
||||||
@@ -548,7 +561,7 @@ function wrapClientProvider(
|
|||||||
config: {
|
config: {
|
||||||
...config,
|
...config,
|
||||||
provider: inner.provider,
|
provider: inner.provider,
|
||||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
providerOptions: withDefaults(config.providerOptions),
|
||||||
},
|
},
|
||||||
env,
|
env,
|
||||||
signal,
|
signal,
|
||||||
@@ -557,16 +570,18 @@ function wrapClientProvider(
|
|||||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||||
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
|
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
|
||||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||||
listFeatures: listFeatures
|
effectiveOptions: (own) => withDefaults(own) ?? {},
|
||||||
|
listControls: listControls
|
||||||
? async (config) =>
|
? async (config) =>
|
||||||
await listFeatures({
|
await listControls({
|
||||||
...config,
|
...config,
|
||||||
provider: inner.provider,
|
provider: inner.provider,
|
||||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
providerOptions: withDefaults(config.providerOptions),
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
listImportableSessions: listImportableSessions
|
listImportableSessions: listImportableSessions
|
||||||
? async (options) => await listImportableSessions({ ...options, providerOptions })
|
? async (options) =>
|
||||||
|
await listImportableSessions({ ...options, providerOptions: configuredDefaults() })
|
||||||
: undefined,
|
: undefined,
|
||||||
importSession: importSession
|
importSession: importSession
|
||||||
? async (input, context) => {
|
? async (input, context) => {
|
||||||
@@ -575,10 +590,7 @@ function wrapClientProvider(
|
|||||||
config: {
|
config: {
|
||||||
...context.config,
|
...context.config,
|
||||||
provider: inner.provider,
|
provider: inner.provider,
|
||||||
providerOptions: mergeProviderOptions(
|
providerOptions: withDefaults(context.config.providerOptions),
|
||||||
providerOptions,
|
|
||||||
context.config.providerOptions,
|
|
||||||
),
|
|
||||||
},
|
},
|
||||||
storedConfig: {
|
storedConfig: {
|
||||||
...context.storedConfig,
|
...context.storedConfig,
|
||||||
|
|||||||
@@ -1307,7 +1307,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
resolverInputs.push(input);
|
resolverInputs.push(input);
|
||||||
return {
|
return {
|
||||||
modeId: input.parent?.isUnattended ? "child-unattended" : undefined,
|
modeId: input.parent?.isUnattended ? "child-unattended" : undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent,
|
parent,
|
||||||
unattended: false,
|
unattended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined });
|
expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined });
|
||||||
expect(resolverInputs).toEqual([
|
expect(resolverInputs).toEqual([
|
||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent: {
|
parent: {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
modeId: "parent-unattended",
|
modeId: "parent-unattended",
|
||||||
@@ -1386,7 +1386,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
resolverInputs.push(input);
|
resolverInputs.push(input);
|
||||||
return {
|
return {
|
||||||
modeId: input.unattended ? "worker" : undefined,
|
modeId: input.unattended ? "worker" : undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
@@ -1397,17 +1397,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { fast_mode: true },
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: true,
|
unattended: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolved).toEqual({ modeId: "worker", featureValues: undefined });
|
expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined });
|
||||||
expect(resolverInputs).toEqual([
|
expect(resolverInputs).toEqual([
|
||||||
{
|
{
|
||||||
provider: "codex",
|
provider: "codex",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { fast_mode: true },
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: true,
|
unattended: true,
|
||||||
availableModes: modes,
|
availableModes: modes,
|
||||||
@@ -1456,7 +1456,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
config: {
|
config: {
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1464,12 +1464,28 @@ describe("ProviderSnapshotManager public surface", () => {
|
|||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
requestedMode: "base",
|
requestedMode: "base",
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent,
|
parent,
|
||||||
unattended: false,
|
unattended: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
|
expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||||
|
|
||||||
|
// A parent whose Auto Accept comes from the provider's config.json default has no own
|
||||||
|
// option; it is classified on its effective options.
|
||||||
|
const inheriting = await manager.resolveCreateConfig({
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
provider: "opencode",
|
||||||
|
requestedMode: "base",
|
||||||
|
providerOptions: undefined,
|
||||||
|
parent: {
|
||||||
|
...parent,
|
||||||
|
config: { provider: "opencode", cwd: "/tmp/project" },
|
||||||
|
effectiveOptions: { auto_accept: true },
|
||||||
|
},
|
||||||
|
unattended: false,
|
||||||
|
});
|
||||||
|
expect(inheriting).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||||
} finally {
|
} finally {
|
||||||
manager.destroy();
|
manager.destroy();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types";
|
import { normalizeAgentModelCatalog, type ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { EventEmitter } from "node:events";
|
import { EventEmitter } from "node:events";
|
||||||
import { homedir } from "node:os";
|
import { homedir } from "node:os";
|
||||||
@@ -160,14 +160,14 @@ export interface ResolveProviderCreateConfigOptions {
|
|||||||
cwd?: string | null;
|
cwd?: string | null;
|
||||||
provider: AgentProvider;
|
provider: AgentProvider;
|
||||||
requestedMode: string | undefined;
|
requestedMode: string | undefined;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
providerOptions: ProviderOptions | undefined;
|
||||||
parent: ManagedAgent | null;
|
parent: ManagedAgent | null;
|
||||||
unattended: boolean;
|
unattended: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedProviderCreateConfig {
|
export interface ResolvedProviderCreateConfig {
|
||||||
modeId: string | undefined;
|
modeId: string | undefined;
|
||||||
featureValues: Record<string, unknown> | undefined;
|
providerOptions: ProviderOptions | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ResolveDefaultModelOptions {
|
interface ResolveDefaultModelOptions {
|
||||||
@@ -563,7 +563,7 @@ export class ProviderSnapshotManager {
|
|||||||
return definition.resolveCreateConfig({
|
return definition.resolveCreateConfig({
|
||||||
provider: input.provider,
|
provider: input.provider,
|
||||||
requestedMode: input.requestedMode,
|
requestedMode: input.requestedMode,
|
||||||
featureValues: input.featureValues,
|
providerOptions: input.providerOptions,
|
||||||
parent,
|
parent,
|
||||||
unattended: input.unattended || parent?.isUnattended === true,
|
unattended: input.unattended || parent?.isUnattended === true,
|
||||||
availableModes: entry.modes ?? [],
|
availableModes: entry.modes ?? [],
|
||||||
@@ -744,8 +744,13 @@ export class ProviderSnapshotManager {
|
|||||||
modeId: parent.currentModeId,
|
modeId: parent.currentModeId,
|
||||||
isUnattended: definition.isCreateConfigUnattended({
|
isUnattended: definition.isCreateConfigUnattended({
|
||||||
modeId: parent.currentModeId,
|
modeId: parent.currentModeId,
|
||||||
config: parent.config,
|
// Provider defaults from `config.json` apply to the parent too, so classify on the
|
||||||
features: parent.features,
|
// effective options while the stored overrides stay as they are.
|
||||||
|
config: {
|
||||||
|
...parent.config,
|
||||||
|
providerOptions: parent.effectiveOptions ?? parent.config.providerOptions,
|
||||||
|
},
|
||||||
|
controls: parent.controls,
|
||||||
availableModes: parent.availableModes ?? definition.modes ?? [],
|
availableModes: parent.availableModes ?? definition.modes ?? [],
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
|||||||
import { createRequire } from "node:module";
|
import { createRequire } from "node:module";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
AgentSideConnection,
|
AgentSideConnection,
|
||||||
@@ -198,7 +198,7 @@ function createSessionWithConfig(
|
|||||||
provider?: string;
|
provider?: string;
|
||||||
modeId?: string | null;
|
modeId?: string | null;
|
||||||
model?: string | null;
|
model?: string | null;
|
||||||
featureValues?: Record<string, unknown>;
|
providerOptions?: Record<string, unknown>;
|
||||||
} = {},
|
} = {},
|
||||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||||
): ACPAgentSession {
|
): ACPAgentSession {
|
||||||
@@ -208,7 +208,7 @@ function createSessionWithConfig(
|
|||||||
cwd: "/tmp/paseo-acp-test",
|
cwd: "/tmp/paseo-acp-test",
|
||||||
modeId: config.modeId ?? undefined,
|
modeId: config.modeId ?? undefined,
|
||||||
model: config.model ?? undefined,
|
model: config.model ?? undefined,
|
||||||
featureValues: config.featureValues,
|
providerOptions: config.providerOptions,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: config.provider ?? "claude-acp",
|
provider: config.provider ?? "claude-acp",
|
||||||
@@ -300,19 +300,20 @@ function selectConfigOption(
|
|||||||
|
|
||||||
function createCopilotSessionWithConfig(
|
function createCopilotSessionWithConfig(
|
||||||
modeId?: string | null,
|
modeId?: string | null,
|
||||||
featureValues?: Record<string, unknown>,
|
providerOptions?: Record<string, unknown>,
|
||||||
|
defaultCommand: string[] = ["copilot", "--acp"],
|
||||||
): ACPAgentSession {
|
): ACPAgentSession {
|
||||||
return new ACPAgentSession(
|
return new ACPAgentSession(
|
||||||
{
|
{
|
||||||
provider: "copilot",
|
provider: "copilot",
|
||||||
cwd: "/tmp/paseo-acp-test",
|
cwd: "/tmp/paseo-acp-test",
|
||||||
modeId: modeId ?? undefined,
|
modeId: modeId ?? undefined,
|
||||||
...(featureValues ? { featureValues } : {}),
|
...(providerOptions ? { providerOptions } : {}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
provider: "copilot",
|
provider: "copilot",
|
||||||
logger: createTestLogger(),
|
logger: createTestLogger(),
|
||||||
defaultCommand: ["copilot", "--acp"],
|
defaultCommand,
|
||||||
defaultModes: COPILOT_MODES,
|
defaultModes: COPILOT_MODES,
|
||||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||||
configOptionsTransformer: transformCopilotConfigOptions,
|
configOptionsTransformer: transformCopilotConfigOptions,
|
||||||
@@ -1498,7 +1499,7 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
|
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
|
||||||
const session = createSessionWithConfig({
|
const session = createSessionWithConfig({
|
||||||
provider: "cursor-acp",
|
provider: "cursor-acp",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
const events: Array<{ type: string }> = [];
|
const events: Array<{ type: string }> = [];
|
||||||
|
|
||||||
@@ -1530,7 +1531,7 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
test("does not auto-accept ACP chooser requests", async () => {
|
test("does not auto-accept ACP chooser requests", async () => {
|
||||||
const session = createSessionWithConfig({
|
const session = createSessionWithConfig({
|
||||||
provider: "kimi-acp",
|
provider: "kimi-acp",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
|
|
||||||
@@ -1583,11 +1584,8 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
internals.sessionId = "session-1";
|
internals.sessionId = "session-1";
|
||||||
internals.connection = {};
|
internals.connection = {};
|
||||||
|
|
||||||
await session.setFeature("auto_accept", true);
|
await session.applyOptions({ auto_accept: true });
|
||||||
|
|
||||||
expect(session.features).toContainEqual(
|
|
||||||
expect.objectContaining({ type: "toggle", id: "auto_accept", value: true }),
|
|
||||||
);
|
|
||||||
await expect(
|
await expect(
|
||||||
session.requestPermission({
|
session.requestPermission({
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
@@ -1605,7 +1603,7 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
|
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
|
||||||
const session = createSessionWithConfig({ featureValues: { auto_accept: true } });
|
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } });
|
||||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||||
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
|
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
|
||||||
@@ -1765,35 +1763,18 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
const internals = asInternals<ACPSessionInternals>(session);
|
const internals = asInternals<ACPSessionInternals>(session);
|
||||||
internals.configOptions = [copilotAgentConfigOption("")];
|
internals.configOptions = [copilotAgentConfigOption("")];
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||||
type: "toggle",
|
|
||||||
id: "auto_accept",
|
|
||||||
value: false,
|
|
||||||
}),
|
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
id: "agent",
|
key: "agent",
|
||||||
label: "Agent",
|
label: "Agent",
|
||||||
description: "Use a Copilot custom agent profile",
|
description: "Use a Copilot custom agent profile",
|
||||||
tooltip: "Select Copilot agent",
|
tooltip: "Select Copilot agent",
|
||||||
icon: undefined,
|
icon: undefined,
|
||||||
value: "",
|
choices: [
|
||||||
options: [
|
{ value: "", label: "Default", description: undefined },
|
||||||
{
|
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
|
||||||
id: "",
|
|
||||||
label: "Default",
|
|
||||||
description: undefined,
|
|
||||||
isDefault: true,
|
|
||||||
metadata: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "Probe Agent",
|
|
||||||
label: "Probe Agent",
|
|
||||||
description: "Temporary probe agent",
|
|
||||||
isDefault: false,
|
|
||||||
metadata: undefined,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -1816,15 +1797,9 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
configId: "agent",
|
configId: "agent",
|
||||||
value: "Probe Agent",
|
value: "Probe Agent",
|
||||||
});
|
});
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({ key: "auto_accept" }),
|
||||||
id: "auto_accept",
|
expect.objectContaining({ key: "agent" }),
|
||||||
value: false,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: "agent",
|
|
||||||
value: "Probe Agent",
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1838,26 +1813,182 @@ describe("ACPAgentSession Zed parity", () => {
|
|||||||
connection: { setSessionConfigOption },
|
connection: { setSessionConfigOption },
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.setFeature("agent", "Probe Agent");
|
await session.applyOptions({ agent: "Probe Agent" });
|
||||||
|
|
||||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
configId: "agent",
|
configId: "agent",
|
||||||
value: "Probe Agent",
|
value: "Probe Agent",
|
||||||
});
|
});
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({ key: "auto_accept" }),
|
||||||
id: "auto_accept",
|
expect.objectContaining({ key: "agent" }),
|
||||||
value: false,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
id: "agent",
|
|
||||||
value: "Probe Agent",
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ACPAgentSession options against an ACP agent", () => {
|
||||||
|
const fakeAgentPath = fileURLToPath(new URL("./test-utils/fake-acp-agent.mjs", import.meta.url));
|
||||||
|
const sessions: ACPAgentSession[] = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(sessions.splice(0).map((session) => session.close()));
|
||||||
|
});
|
||||||
|
|
||||||
|
function thoughtOption(currentValue: string): SessionConfigOption {
|
||||||
|
return {
|
||||||
|
id: "thought",
|
||||||
|
name: "Thinking",
|
||||||
|
category: "thought_level",
|
||||||
|
type: "select",
|
||||||
|
currentValue,
|
||||||
|
options: [
|
||||||
|
{ value: "low", name: "Low" },
|
||||||
|
{ value: "high", name: "High" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openSession(input: {
|
||||||
|
agent?: string;
|
||||||
|
scenario?: Record<string, unknown>;
|
||||||
|
providerOptions?: Record<string, unknown>;
|
||||||
|
}): Promise<{ session: ACPAgentSession; optionEvents: AgentStreamEvent[] }> {
|
||||||
|
const scenario = {
|
||||||
|
options: [copilotAgentConfigOption(input.agent ?? ""), thoughtOption("low")],
|
||||||
|
...input.scenario,
|
||||||
|
};
|
||||||
|
await mkdir("/tmp/paseo-acp-test", { recursive: true });
|
||||||
|
const session = createCopilotSessionWithConfig(null, input.providerOptions, [
|
||||||
|
process.execPath,
|
||||||
|
fakeAgentPath,
|
||||||
|
JSON.stringify(scenario),
|
||||||
|
]);
|
||||||
|
sessions.push(session);
|
||||||
|
const optionEvents: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => {
|
||||||
|
if (event.type === "options_changed") optionEvents.push(event);
|
||||||
|
});
|
||||||
|
await session.initializeNewSession();
|
||||||
|
return { session, optionEvents };
|
||||||
|
}
|
||||||
|
|
||||||
|
test("a select the agent opened differently from the options is the open patch, not an event", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({
|
||||||
|
providerOptions: { agent: "Probe Agent" },
|
||||||
|
scenario: { answers: { agent: "" } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ agent: "" });
|
||||||
|
expect(optionEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a select the agent opened with that nobody set is in the open patch", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ agent: "Probe Agent" });
|
||||||
|
expect(optionEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a notification that arrives before the open write is answered is not an event", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({
|
||||||
|
providerOptions: { agent: "Probe Agent" },
|
||||||
|
scenario: { onWrite: "notify-first" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({});
|
||||||
|
expect(optionEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applying a patch returns the selects that ended up different and emits nothing", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({ scenario: { answers: { agent: "" } } });
|
||||||
|
|
||||||
|
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({ agent: "" });
|
||||||
|
expect(optionEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a notification that arrives before the apply write is answered is not an event", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({ scenario: { onWrite: "notify-first" } });
|
||||||
|
|
||||||
|
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({});
|
||||||
|
expect(optionEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rejected apply reports what the agent moved before refusing, since nothing is stored", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({
|
||||||
|
scenario: { onWrite: "notify-then-reject" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.applyOptions({ agent: "Probe Agent" })).rejects.toThrow(/write refused/);
|
||||||
|
expect(optionEvents).toEqual([
|
||||||
|
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overlapping applies that both fail still report what the agent moved", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({ scenario: { onWrite: "slow-reject" } });
|
||||||
|
|
||||||
|
const first = session.applyOptions({ agent: "Probe Agent" });
|
||||||
|
const second = session.applyOptions({ agent: null });
|
||||||
|
|
||||||
|
await expect(first).rejects.toThrow(/write refused/);
|
||||||
|
await expect(second).rejects.toThrow(/write refused/);
|
||||||
|
expect(optionEvents).toEqual([
|
||||||
|
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a removal writes the select's empty choice and returns nothing when that took", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
|
||||||
|
|
||||||
|
await expect(session.applyOptions({ agent: null })).resolves.toEqual({});
|
||||||
|
expect(optionEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a live auto_accept the creation schema would reject, and writes nothing", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({
|
||||||
|
scenario: { onWrite: "notify-then-reject" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
session.applyOptions({ auto_accept: "yes", agent: "Probe Agent" }),
|
||||||
|
).rejects.toThrow(/auto_accept/);
|
||||||
|
expect(optionEvents).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forwards a select the agent changes on its own", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({
|
||||||
|
scenario: { moves: { thought: { agent: "Probe Agent" } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.setThinkingOption("high");
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(optionEvents).toEqual([
|
||||||
|
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a select that appears after startup is reported with its native value", async () => {
|
||||||
|
const { session, optionEvents } = await openSession({
|
||||||
|
scenario: {
|
||||||
|
options: [thoughtOption("low")],
|
||||||
|
adds: { thought: [copilotAgentConfigOption("Probe Agent")] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(session.openOptionsPatch).toEqual({});
|
||||||
|
|
||||||
|
await session.setThinkingOption("high");
|
||||||
|
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(optionEvents).toEqual([
|
||||||
|
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("deriveModelDefinitionsFromACP", () => {
|
describe("deriveModelDefinitionsFromACP", () => {
|
||||||
test("attaches shared thinking options to ACP model state", () => {
|
test("attaches shared thinking options to ACP model state", () => {
|
||||||
const result = deriveModelDefinitionsFromACP(
|
const result = deriveModelDefinitionsFromACP(
|
||||||
@@ -2084,14 +2215,14 @@ describe("ACPAgentClient config features", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "generic-acp",
|
provider: "generic-acp",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: { provider_feature: "kept" },
|
providerOptions: { provider_feature: "kept" },
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: true,
|
unattended: true,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
modeId: undefined,
|
modeId: undefined,
|
||||||
featureValues: { provider_feature: "kept", auto_accept: true },
|
providerOptions: { provider_feature: "kept", auto_accept: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2106,12 +2237,12 @@ describe("ACPAgentClient config features", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "generic-acp",
|
provider: "generic-acp",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: { auto_accept: false },
|
providerOptions: { auto_accept: false },
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: true,
|
unattended: true,
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
}),
|
}),
|
||||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: false } });
|
).toEqual({ modeId: undefined, providerOptions: { auto_accept: false } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
|
test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
|
||||||
@@ -2125,7 +2256,7 @@ describe("ACPAgentClient config features", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "generic-acp",
|
provider: "generic-acp",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent: {
|
parent: {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
@@ -2134,7 +2265,7 @@ describe("ACPAgentClient config features", () => {
|
|||||||
unattended: false,
|
unattended: false,
|
||||||
availableModes: [{ id: "agent", label: "Agent" }],
|
availableModes: [{ id: "agent", label: "Agent" }],
|
||||||
}),
|
}),
|
||||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("treats Auto Accept as an unattended ACP configuration", () => {
|
test("treats Auto Accept as an unattended ACP configuration", () => {
|
||||||
@@ -2150,7 +2281,7 @@ describe("ACPAgentClient config features", () => {
|
|||||||
config: {
|
config: {
|
||||||
provider: "generic-acp",
|
provider: "generic-acp",
|
||||||
cwd: "/tmp/acp-features",
|
cwd: "/tmp/acp-features",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
},
|
},
|
||||||
availableModes: [],
|
availableModes: [],
|
||||||
}),
|
}),
|
||||||
@@ -2165,22 +2296,17 @@ describe("ACPAgentClient config features", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "generic-acp",
|
provider: "generic-acp",
|
||||||
cwd: "/tmp/acp-features",
|
cwd: "/tmp/acp-features",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([
|
).resolves.toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
|
||||||
type: "toggle",
|
|
||||||
id: "auto_accept",
|
|
||||||
label: "Auto Accept",
|
|
||||||
value: true,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("derives features from configured ACP select options", async () => {
|
test("derives controls from configured ACP select options", async () => {
|
||||||
class TestACPAgentClient extends ACPAgentClient {
|
class TestACPAgentClient extends ACPAgentClient {
|
||||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||||
return {
|
return {
|
||||||
@@ -2206,23 +2332,18 @@ describe("ACPAgentClient config features", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "copilot",
|
provider: "copilot",
|
||||||
cwd: "/tmp/acp-features",
|
cwd: "/tmp/acp-features",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([
|
).resolves.toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||||
type: "toggle",
|
|
||||||
id: "auto_accept",
|
|
||||||
value: false,
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
type: "select",
|
type: "select",
|
||||||
id: "agent",
|
key: "agent",
|
||||||
value: "Probe Agent",
|
choices: [
|
||||||
options: [
|
expect.objectContaining({ value: "", label: "Default" }),
|
||||||
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
|
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
|
||||||
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
|
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -3997,7 +4118,7 @@ describe("ACPAgentClient probe cleanup", () => {
|
|||||||
terminateProcess: terminator.terminate,
|
terminateProcess: terminator.terminate,
|
||||||
});
|
});
|
||||||
|
|
||||||
await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" });
|
await client.listControls({ provider: "copilot", cwd: "/tmp/acp-features" });
|
||||||
|
|
||||||
expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" });
|
expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" });
|
||||||
expect(terminator.terminated).toContain(child);
|
expect(terminator.terminated).toContain(child);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
import { ACPProviderOptionsSchema } from "./acp-options.js";
|
import { ACPProviderOptionsSchema } from "./acp-options.js";
|
||||||
|
import { applyProviderOptionsPatch } from "../option-controls.js";
|
||||||
|
import { assertChangesAreLive } from "../provider-options.js";
|
||||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
@@ -65,7 +68,7 @@ import {
|
|||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentCreateConfigUnattendedInput,
|
type AgentCreateConfigUnattendedInput,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -848,10 +851,10 @@ export function deriveModelDefinitionsFromACP(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function deriveFeaturesFromACP(
|
export function deriveControlsFromACP(
|
||||||
configOptions: SessionConfigOption[] | null | undefined,
|
configOptions: SessionConfigOption[] | null | undefined,
|
||||||
featureOptions: ACPConfigFeatureOption[],
|
featureOptions: ACPConfigFeatureOption[],
|
||||||
): AgentFeature[] {
|
): AgentOptionControl[] {
|
||||||
return featureOptions.flatMap((featureOption) => {
|
return featureOptions.flatMap((featureOption) => {
|
||||||
const option = findSelectConfigFeatureOption(configOptions, featureOption);
|
const option = findSelectConfigFeatureOption(configOptions, featureOption);
|
||||||
if (!option) {
|
if (!option) {
|
||||||
@@ -861,42 +864,42 @@ export function deriveFeaturesFromACP(
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
id: featureOption.id,
|
key: featureOption.id,
|
||||||
label: featureOption.label,
|
label: featureOption.label,
|
||||||
description: featureOption.description,
|
description: featureOption.description,
|
||||||
tooltip: featureOption.tooltip,
|
tooltip: featureOption.tooltip,
|
||||||
icon: featureOption.icon,
|
icon: featureOption.icon,
|
||||||
value: option.currentValue ?? null,
|
choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({
|
||||||
options: deriveConfigFeatureSelectOptions(option, featureOption),
|
value: choice.id,
|
||||||
|
label: choice.label,
|
||||||
|
description: choice.description,
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||||
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
const ACP_AUTO_ACCEPT_CONTROL: AgentOptionControl = {
|
||||||
return {
|
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: ACP_AUTO_ACCEPT_FEATURE_ID,
|
key: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||||
label: "Auto Accept",
|
label: "Auto Accept",
|
||||||
description: "Automatically approves ACP permission prompts.",
|
description: "Automatically approves ACP permission prompts.",
|
||||||
tooltip: "Auto accept permission prompts",
|
tooltip: "Auto accept permission prompts",
|
||||||
icon: "shield-check",
|
icon: "shield-check",
|
||||||
value: isACPAutoAcceptEnabled(config),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function resolveACPCreateConfig(
|
function resolveACPCreateConfig(
|
||||||
input: ResolveAgentCreateConfigInput,
|
input: ResolveAgentCreateConfigInput,
|
||||||
): ResolveAgentCreateConfigResult {
|
): ResolveAgentCreateConfigResult {
|
||||||
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
|
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
|
||||||
const featureValues =
|
const providerOptions =
|
||||||
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||||
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||||
: input.featureValues;
|
: input.providerOptions;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
input.requestedMode === undefined &&
|
input.requestedMode === undefined &&
|
||||||
@@ -904,22 +907,16 @@ function resolveACPCreateConfig(
|
|||||||
input.parent !== null &&
|
input.parent !== null &&
|
||||||
input.parent.provider !== input.provider
|
input.parent.provider !== input.provider
|
||||||
) {
|
) {
|
||||||
return { modeId: undefined, featureValues };
|
return { modeId: undefined, providerOptions };
|
||||||
}
|
}
|
||||||
|
|
||||||
return resolveDefaultAgentCreateConfig({ ...input, featureValues });
|
return resolveDefaultAgentCreateConfig({ ...input, providerOptions });
|
||||||
}
|
}
|
||||||
|
|
||||||
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
|
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
|
||||||
return (
|
return (
|
||||||
isDefaultAgentCreateConfigUnattended(input) ||
|
isDefaultAgentCreateConfigUnattended(input) ||
|
||||||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
|
input.config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true
|
||||||
input.features?.some(
|
|
||||||
(feature) =>
|
|
||||||
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
|
|
||||||
feature.type === "toggle" &&
|
|
||||||
feature.value === true,
|
|
||||||
) === true
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1198,10 +1195,9 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
|
|
||||||
if (this.configFeatureOptions.length === 0) {
|
if (this.configFeatureOptions.length === 0) {
|
||||||
return [autoAcceptFeature];
|
return [ACP_AUTO_ACCEPT_CONTROL];
|
||||||
}
|
}
|
||||||
|
|
||||||
this.assertProvider(config);
|
this.assertProvider(config);
|
||||||
@@ -1217,8 +1213,8 @@ export class ACPAgentClient implements AgentClient {
|
|||||||
probeSessionId = response.sessionId;
|
probeSessionId = response.sessionId;
|
||||||
const transformed = this.transformSessionResponse(response);
|
const transformed = this.transformSessionResponse(response);
|
||||||
return [
|
return [
|
||||||
autoAcceptFeature,
|
ACP_AUTO_ACCEPT_CONTROL,
|
||||||
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
|
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||||
];
|
];
|
||||||
} finally {
|
} finally {
|
||||||
await this.closeProbe(probe, probeSessionId);
|
await this.closeProbe(probe, probeSessionId);
|
||||||
@@ -1761,6 +1757,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
private currentTitle: string | null = null;
|
private currentTitle: string | null = null;
|
||||||
private lastActivityAt: string | null = null;
|
private lastActivityAt: string | null = null;
|
||||||
private configOptions: SessionConfigOption[] = [];
|
private configOptions: SessionConfigOption[] = [];
|
||||||
|
/** How the opened session differs from the options it was given. */
|
||||||
|
openOptionsPatch: Record<string, unknown> = {};
|
||||||
|
/** Selects the provider moves while Paseo is opening or applying options belong to that result, not to events. */
|
||||||
|
private optionsTransactionDepth = 0;
|
||||||
|
private applying: Promise<unknown> = Promise.resolve();
|
||||||
private cachedCommands: AgentSlashCommand[] = [];
|
private cachedCommands: AgentSlashCommand[] = [];
|
||||||
/** When the last command batch arrived; null while nothing has been published. */
|
/** When the last command batch arrived; null while nothing has been published. */
|
||||||
private commandsUpdatedAt: number | null = null;
|
private commandsUpdatedAt: number | null = null;
|
||||||
@@ -2054,10 +2055,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
return this.currentMode;
|
return this.currentMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
return [
|
return [
|
||||||
buildACPAutoAcceptFeature(this.config),
|
ACP_AUTO_ACCEPT_CONTROL,
|
||||||
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
|
...deriveControlsFromACP(this.configOptions, this.configFeatureOptions),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2198,6 +2199,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { connection, sessionId } = this;
|
const { connection, sessionId } = this;
|
||||||
|
const before = this.featureValues();
|
||||||
const response = await this.runACPRequest(() =>
|
const response = await this.runACPRequest(() =>
|
||||||
connection.setSessionConfigOption({
|
connection.setSessionConfigOption({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -2212,6 +2214,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
requestedValue: modeId,
|
requestedValue: modeId,
|
||||||
label: "mode",
|
label: "mode",
|
||||||
});
|
});
|
||||||
|
this.reportFeatureChanges(before);
|
||||||
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
|
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
type: "mode_changed",
|
type: "mode_changed",
|
||||||
@@ -2323,6 +2326,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { connection, sessionId } = this;
|
const { connection, sessionId } = this;
|
||||||
|
const before = this.featureValues();
|
||||||
const response = await this.runACPRequest(() =>
|
const response = await this.runACPRequest(() =>
|
||||||
connection.setSessionConfigOption({
|
connection.setSessionConfigOption({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -2337,6 +2341,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
requestedValue: modelId,
|
requestedValue: modelId,
|
||||||
label: "model",
|
label: "model",
|
||||||
});
|
});
|
||||||
|
this.reportFeatureChanges(before);
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
type: "model_changed",
|
type: "model_changed",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@@ -2374,6 +2379,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
|
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
|
||||||
}
|
}
|
||||||
const { connection, sessionId } = this;
|
const { connection, sessionId } = this;
|
||||||
|
const before = this.featureValues();
|
||||||
const response = await this.runACPRequest(() =>
|
const response = await this.runACPRequest(() =>
|
||||||
connection.setSessionConfigOption({
|
connection.setSessionConfigOption({
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -2388,6 +2394,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
requestedValue: thinkingOptionId,
|
requestedValue: thinkingOptionId,
|
||||||
label: "thought-level",
|
label: "thought-level",
|
||||||
});
|
});
|
||||||
|
this.reportFeatureChanges(before);
|
||||||
this.pushEvent({
|
this.pushEvent({
|
||||||
type: "thinking_option_changed",
|
type: "thinking_option_changed",
|
||||||
provider: this.provider,
|
provider: this.provider,
|
||||||
@@ -2395,14 +2402,103 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
/**
|
||||||
|
* Applies only the options the patch sets; returns the selects that ended up different.
|
||||||
|
* Provider notifications that arrive meanwhile are part of that result. When a write is
|
||||||
|
* rejected, what the provider moved is reported as an event, since nothing is stored.
|
||||||
|
*/
|
||||||
|
/** One apply at a time: each reconciles against the state the one before it left. */
|
||||||
|
applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||||
|
const run = this.applying.then(() => this.applyOptionsNow(options));
|
||||||
|
this.applying = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async applyOptionsNow(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||||
|
assertChangesAreLive({
|
||||||
|
provider: this.config.provider,
|
||||||
|
patch: options,
|
||||||
|
current: this.config.providerOptions,
|
||||||
|
live: [ACP_AUTO_ACCEPT_FEATURE_ID, ...this.configFeatureOptions.map(({ id }) => id)],
|
||||||
|
});
|
||||||
|
ACPProviderOptionsSchema.parse(
|
||||||
|
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
|
||||||
|
);
|
||||||
|
const before = this.featureValues();
|
||||||
|
const requested: Record<string, unknown> = {};
|
||||||
|
try {
|
||||||
|
await this.inOptionsTransaction(async () => {
|
||||||
|
for (const [id, value] of Object.entries(options)) {
|
||||||
|
const featureOption = this.configFeatureOptions.find((candidate) => candidate.id === id);
|
||||||
|
if (!featureOption) continue;
|
||||||
|
// A removal asks for the select's empty choice; a provider without one rejects it.
|
||||||
|
requested[id] = value ?? "";
|
||||||
|
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
|
||||||
|
if (!option || requested[id] !== option.currentValue) {
|
||||||
|
await this.setConfigFeature(id, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.reportFeatureChanges(before);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
// Auto Accept is Paseo's own setting, so it changes only once every write has been accepted.
|
||||||
|
if (ACP_AUTO_ACCEPT_FEATURE_ID in options) {
|
||||||
|
this.config.providerOptions = {
|
||||||
|
...this.config.providerOptions,
|
||||||
|
[ACP_AUTO_ACCEPT_FEATURE_ID]: options[ACP_AUTO_ACCEPT_FEATURE_ID] === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return this.featureDifferences({ ...before, ...requested });
|
||||||
|
}
|
||||||
|
|
||||||
|
private async inOptionsTransaction<T>(run: () => Promise<T>): Promise<T> {
|
||||||
|
this.optionsTransactionDepth += 1;
|
||||||
|
try {
|
||||||
|
return await run();
|
||||||
|
} finally {
|
||||||
|
this.optionsTransactionDepth -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selects as the provider reports them now. */
|
||||||
|
private featureValues(): Record<string, string> {
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const featureOption of this.configFeatureOptions) {
|
||||||
|
const current = findSelectConfigFeatureOption(
|
||||||
|
this.configOptions,
|
||||||
|
featureOption,
|
||||||
|
)?.currentValue;
|
||||||
|
if (current != null) values[featureOption.id] = current;
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The selects whose value is not what `expected` says, including ones it does not name. */
|
||||||
|
private featureDifferences(expected: Record<string, unknown>): Record<string, string> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.entries(this.featureValues()).filter(([id, value]) => expected[id] !== value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reports the selects the provider moved on its own, compared with their values `before`. */
|
||||||
|
private reportFeatureChanges(before: Record<string, string>): void {
|
||||||
|
if (this.optionsTransactionDepth > 0) return;
|
||||||
|
const patch = this.featureDifferences(before);
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
this.pushEvent({ type: "options_changed", provider: this.provider, patch });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setConfigFeature(featureId: string, value: unknown): Promise<void> {
|
||||||
if (!this.connection || !this.sessionId) {
|
if (!this.connection || !this.sessionId) {
|
||||||
throw new Error("ACP session not initialized");
|
throw new Error("ACP session not initialized");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
|
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
|
||||||
this.config.featureValues = {
|
this.config.providerOptions = {
|
||||||
...this.config.featureValues,
|
...this.config.providerOptions,
|
||||||
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
|
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
|
||||||
};
|
};
|
||||||
return;
|
return;
|
||||||
@@ -2434,14 +2530,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
value: requestedValue,
|
value: requestedValue,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const currentValue = this.applyConfigOptionResponse({
|
this.applyConfigOptionResponse({
|
||||||
response,
|
response,
|
||||||
configId: option.id,
|
configId: option.id,
|
||||||
category: featureOption.category,
|
category: featureOption.category,
|
||||||
requestedValue,
|
requestedValue,
|
||||||
label: featureOption.label,
|
label: featureOption.label,
|
||||||
});
|
});
|
||||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyConfigOptionResponse({
|
private applyConfigOptionResponse({
|
||||||
@@ -2986,7 +3081,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
|
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyConfiguredOverrides(): Promise<void> {
|
private applyConfiguredOverrides(): Promise<void> {
|
||||||
|
return this.inOptionsTransaction(() => this.writeConfiguredOverrides());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async writeConfiguredOverrides(): Promise<void> {
|
||||||
const configuredModeId = this.config.modeId;
|
const configuredModeId = this.config.modeId;
|
||||||
if (configuredModeId && configuredModeId !== this.currentMode) {
|
if (configuredModeId && configuredModeId !== this.currentMode) {
|
||||||
const selection = resolveACPModeSelection({
|
const selection = resolveACPModeSelection({
|
||||||
@@ -3018,13 +3117,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||||
}
|
}
|
||||||
const configuredFeatureValues = this.config.featureValues ?? {};
|
const configuredFeatureValues = this.config.providerOptions ?? {};
|
||||||
for (const featureOption of this.configFeatureOptions) {
|
for (const featureOption of this.configFeatureOptions) {
|
||||||
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
await this.setConfigFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
|
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -3035,6 +3134,9 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The options are the state, so every select the session opened with is reported, including
|
||||||
|
// ones nobody set (a native default is stored like any other value).
|
||||||
|
this.openOptionsPatch = this.featureDifferences(configuredFeatureValues);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -3248,6 +3350,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
|
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
|
||||||
|
const before = this.featureValues();
|
||||||
this.configOptions = this.transformConfigOptions(update.configOptions);
|
this.configOptions = this.transformConfigOptions(update.configOptions);
|
||||||
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
||||||
const nextMode = modeInfo.currentModeId;
|
const nextMode = modeInfo.currentModeId;
|
||||||
@@ -3258,6 +3361,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
|||||||
this.currentMode = nextMode ?? this.currentMode;
|
this.currentMode = nextMode ?? this.currentMode;
|
||||||
this.currentModel = nextModel ?? this.currentModel;
|
this.currentModel = nextModel ?? this.currentModel;
|
||||||
this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId;
|
this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId;
|
||||||
|
this.reportFeatureChanges(before);
|
||||||
|
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
if (nextMode !== null) {
|
if (nextMode !== null) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { z } from "zod";
|
|||||||
|
|
||||||
export const ACPProviderOptionsSchema = z
|
export const ACPProviderOptionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
// Composer control: approves permission prompts without asking.
|
||||||
|
auto_accept: z.boolean().optional(),
|
||||||
supportsMcpServers: z.boolean().optional(),
|
supportsMcpServers: z.boolean().optional(),
|
||||||
clientCapabilities: z
|
clientCapabilities: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { constants as bufferConstants } from "node:buffer";
|
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -237,64 +236,4 @@ describe("importing a renamed Claude session", () => {
|
|||||||
|
|
||||||
await expect(importSession(sessionId)).rejects.toThrow();
|
await expect(importSession(sessionId)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reads the rename from a transcript too large to hold as one string", async () => {
|
|
||||||
const sessionId = "oversized-transcript";
|
|
||||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
|
||||||
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
|
|
||||||
// A hole stands in for the long session this used to fail on: the file only
|
|
||||||
// has to be longer than a string can be, and the rename sits at its end.
|
|
||||||
await fs.writeFile(transcript, "");
|
|
||||||
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
|
|
||||||
await fs.appendFile(
|
|
||||||
transcript,
|
|
||||||
`\n${[
|
|
||||||
userRecord({ sessionId, content: "Trace the slow query" }),
|
|
||||||
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
|
|
||||||
]
|
|
||||||
.map((record) => JSON.stringify(record))
|
|
||||||
.join("\n")}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const imported = await importSession(sessionId);
|
|
||||||
|
|
||||||
expect(imported.config.title).toBe("Slow query hunt");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reads the rename from behind more records than one read covers", async () => {
|
|
||||||
const sessionId = "late-records-rename";
|
|
||||||
await writeSession({
|
|
||||||
sessionId,
|
|
||||||
records: [
|
|
||||||
userRecord({ sessionId, content: "Review this project" }),
|
|
||||||
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
|
|
||||||
// One record longer than a single read, then enough records to leave the
|
|
||||||
// rename several reads back from the end of the file.
|
|
||||||
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
|
|
||||||
...Array.from({ length: 64 }, (_, index) =>
|
|
||||||
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const imported = await importSession(sessionId);
|
|
||||||
|
|
||||||
expect(imported.config.title).toBe("Scrollback review");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("imports a large record after the last rename without repeatedly copying it", async () => {
|
|
||||||
const sessionId = "large-record-after-rename";
|
|
||||||
await writeSession({
|
|
||||||
sessionId,
|
|
||||||
records: [
|
|
||||||
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
|
|
||||||
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const imported = await importSession(sessionId);
|
|
||||||
|
|
||||||
expect(imported.config.title).toBe("Large attachment review");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import pino from "pino";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
|
||||||
|
|
||||||
describe("Claude runtime info before a turn", () => {
|
|
||||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
|
||||||
|
|
||||||
test("reports the explicitly configured model before the first turn", async () => {
|
|
||||||
const session = await client.createSession({
|
|
||||||
provider: "claude",
|
|
||||||
cwd: process.cwd(),
|
|
||||||
model: "claude-haiku-5-5",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports the persisted configured model after rebuilding an idle session", async () => {
|
|
||||||
const session = await client.resumeSession({
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: "runtime-model-before-turn",
|
|
||||||
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(await session.getRuntimeInfo()).toMatchObject({
|
|
||||||
sessionId: "runtime-model-before-turn",
|
|
||||||
model: "claude-haiku-5-5",
|
|
||||||
});
|
|
||||||
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
|
|
||||||
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves the model unknown when Claude will choose its default", async () => {
|
|
||||||
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
|
|
||||||
try {
|
|
||||||
expect((await session.getRuntimeInfo()).model).toBeNull();
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
resolveClaudeCodeVersion,
|
resolveClaudeCodeVersion,
|
||||||
toClaudeSdkMcpConfig,
|
toClaudeSdkMcpConfig,
|
||||||
} from "./agent.js";
|
} from "./agent.js";
|
||||||
|
import { AgentManager } from "../../agent-manager.js";
|
||||||
|
import { ensureAgentLoaded } from "../../agent-loading.js";
|
||||||
|
import { AgentStorage } from "../../agent-storage.js";
|
||||||
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
|
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
|
||||||
import { claudeProjectDirSync } from "./project-dir.js";
|
import { claudeProjectDirSync } from "./project-dir.js";
|
||||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||||
@@ -30,6 +33,8 @@ import type {
|
|||||||
} from "../../agent-sdk-types.js";
|
} from "../../agent-sdk-types.js";
|
||||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||||
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||||
|
import { applyProviderOptionsPatch } from "../../option-controls.js";
|
||||||
|
import { mergeProviderOptions } from "../../provider-options.js";
|
||||||
|
|
||||||
interface TestClaudeSession {
|
interface TestClaudeSession {
|
||||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||||
@@ -953,39 +958,39 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "claude-opus-4-8",
|
model: "claude-opus-4-8",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "claude-opus-4-8[1m]",
|
model: "claude-opus-4-8[1m]",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "claude-opus-4-8-20260101",
|
model: "claude-opus-4-8-20260101",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "claude-opus-5",
|
model: "claude-opus-5",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "openrouter/anthropic/claude-opus-4-8",
|
model: "openrouter/anthropic/claude-opus-4-8",
|
||||||
@@ -993,7 +998,7 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "claude-sonnet-5",
|
model: "claude-sonnet-5",
|
||||||
@@ -1001,7 +1006,7 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "claude-sonnet-4-6",
|
model: "claude-sonnet-4-6",
|
||||||
@@ -1009,6 +1014,25 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("opens with Fast off on a model that has none, so unrelated options still apply", async () => {
|
||||||
|
const { queryFactory } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-sonnet-5",
|
||||||
|
providerOptions: { fast_mode: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ fast_mode: false });
|
||||||
|
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("passes initial fast mode through Claude flag settings", async () => {
|
test("passes initial fast mode through Claude flag settings", async () => {
|
||||||
const { queryFactory, queryMock } = createQueryMock();
|
const { queryFactory, queryMock } = createQueryMock();
|
||||||
const client = new ClaudeAgentClient({
|
const client = new ClaudeAgentClient({
|
||||||
@@ -1020,7 +1044,7 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
provider: "claude",
|
provider: "claude",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "claude-opus-4-8",
|
model: "claude-opus-4-8",
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { fast_mode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
@@ -1492,7 +1516,7 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
ensureQuery(): Promise<unknown>;
|
ensureQuery(): Promise<unknown>;
|
||||||
}
|
}
|
||||||
).ensureQuery();
|
).ensureQuery();
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
|
|
||||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
||||||
@@ -1501,6 +1525,242 @@ describe("ClaudeAgentSession features", () => {
|
|||||||
|
|
||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects a change to a launch option while the first query is starting", async () => {
|
||||||
|
const { queryFactory } = createQueryMock();
|
||||||
|
let releaseBinary!: () => void;
|
||||||
|
const binaryLookup = new Promise<void>((resolve) => {
|
||||||
|
releaseBinary = resolve;
|
||||||
|
});
|
||||||
|
let lookupStarted!: () => void;
|
||||||
|
const lookupEntered = new Promise<void>((resolve) => {
|
||||||
|
lookupStarted = resolve;
|
||||||
|
});
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => {
|
||||||
|
lookupStarted();
|
||||||
|
await binaryLookup;
|
||||||
|
return "/test/claude/bin";
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
providerOptions: { disallowedTools: ["Bash"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
const turn = session.startTurn("hello");
|
||||||
|
await lookupEntered;
|
||||||
|
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
|
||||||
|
/disallowedTools.*cannot change on a running session/,
|
||||||
|
);
|
||||||
|
releaseBinary();
|
||||||
|
await turn;
|
||||||
|
|
||||||
|
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ disallowedTools: ["Bash"] });
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a patch before the first prompt keeps the restrictions the session opened with", async () => {
|
||||||
|
const { queryFactory } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
providerOptions: {
|
||||||
|
disallowedTools: ["Bash"],
|
||||||
|
sandbox: { enabled: true, failIfUnavailable: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
|
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||||
|
|
||||||
|
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
|
||||||
|
disallowedTools: ["Bash"],
|
||||||
|
sandbox: { enabled: true, failIfUnavailable: true },
|
||||||
|
});
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an option Claude reads when it starts changes before the query exists and is rejected after", async () => {
|
||||||
|
const { queryFactory } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
providerOptions: { disallowedTools: ["Bash"] },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.applyOptions?.({ disallowedTools: ["Bash", "Write"] });
|
||||||
|
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||||
|
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
|
||||||
|
disallowedTools: ["Bash", "Write"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The query now runs with these; replacing it would end its background work.
|
||||||
|
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
|
||||||
|
/disallowedTools.*cannot change on a running session/,
|
||||||
|
);
|
||||||
|
// The same value is not a change, and Fast still applies.
|
||||||
|
await expect(
|
||||||
|
session.applyOptions?.({ disallowedTools: ["Bash", "Write"], fast_mode: true }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching to a model without Fast turns it off, reports that, and later patches still apply", async () => {
|
||||||
|
const { queryFactory, queryMock } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
providerOptions: { fast_mode: true },
|
||||||
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
await session.setModel?.("claude-fable-5");
|
||||||
|
|
||||||
|
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
|
||||||
|
const reported = events.filter((event) => event.type === "options_changed");
|
||||||
|
expect(reported).toEqual([
|
||||||
|
{ type: "options_changed", provider: "claude", patch: { fast_mode: false } },
|
||||||
|
]);
|
||||||
|
// With a `config.json` default of `fast_mode: true`, the agent's effective options must
|
||||||
|
// still read Fast as off: a removed key would let the default fill `true` back in.
|
||||||
|
const stored = applyProviderOptionsPatch({
|
||||||
|
options: { fast_mode: true },
|
||||||
|
patch: reported[0].patch,
|
||||||
|
});
|
||||||
|
expect(mergeProviderOptions({ fast_mode: true }, stored)).toEqual({ fast_mode: false });
|
||||||
|
await expect(session.applyOptions?.({ fast_mode: null })).resolves.toBeUndefined();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing fast_mode turns Fast off", async () => {
|
||||||
|
const { queryFactory, queryMock } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
});
|
||||||
|
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||||
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
|
|
||||||
|
await session.applyOptions?.({ fast_mode: null });
|
||||||
|
|
||||||
|
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("retries a fast mode change the runtime rejected", async () => {
|
||||||
|
const { queryFactory, queryMock } = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "claude",
|
||||||
|
cwd: process.cwd(),
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
});
|
||||||
|
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||||
|
queryMock.applyFlagSettings.mockClear();
|
||||||
|
queryMock.applyFlagSettings.mockRejectedValueOnce(new Error("runtime unavailable"));
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
|
||||||
|
"runtime unavailable",
|
||||||
|
);
|
||||||
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
|
|
||||||
|
expect(queryMock.applyFlagSettings).toHaveBeenCalledTimes(2);
|
||||||
|
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
||||||
|
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fast mode survives a daemon restart through the stored providerOptions", async () => {
|
||||||
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-"));
|
||||||
|
const storagePath = path.join(root, "agents");
|
||||||
|
const makeClient = () => {
|
||||||
|
const mock = createQueryMock();
|
||||||
|
const client = new ClaudeAgentClient({
|
||||||
|
logger,
|
||||||
|
queryFactory: mock.queryFactory,
|
||||||
|
resolveBinary: async () => "/test/claude/bin",
|
||||||
|
});
|
||||||
|
return { ...mock, client };
|
||||||
|
};
|
||||||
|
const agentId = "00000000-0000-4000-8000-0000000000f1";
|
||||||
|
const before = makeClient();
|
||||||
|
const storage = new AgentStorage(storagePath, logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: { claude: before.client },
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await manager.createAgent(
|
||||||
|
{ provider: "claude", cwd: root, model: "claude-opus-4-8" },
|
||||||
|
agentId,
|
||||||
|
{ workspaceId: undefined },
|
||||||
|
);
|
||||||
|
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||||
|
await manager.closeAgent(agentId);
|
||||||
|
await manager.flush();
|
||||||
|
await storage.flush();
|
||||||
|
|
||||||
|
const after = makeClient();
|
||||||
|
const restartedStorage = new AgentStorage(storagePath, logger);
|
||||||
|
const restarted = new AgentManager({
|
||||||
|
clients: { claude: after.client },
|
||||||
|
registry: restartedStorage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const agent = await ensureAgentLoaded(agentId, {
|
||||||
|
agentManager: restarted,
|
||||||
|
agentStorage: restartedStorage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const session = (agent as unknown as { session: { ensureQuery(): Promise<unknown> } | null })
|
||||||
|
.session;
|
||||||
|
await session?.ensureQuery();
|
||||||
|
|
||||||
|
expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true });
|
||||||
|
expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
|
||||||
|
fastMode: true,
|
||||||
|
});
|
||||||
|
await restarted.closeAgent(agentId);
|
||||||
|
} finally {
|
||||||
|
await fs.rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { setImmediate } from "node:timers/promises";
|
import { setImmediate } from "node:timers/promises";
|
||||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||||
import { validateProviderOptions } from "../../provider-options.js";
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { applyProviderOptionsPatch } from "../../option-controls.js";
|
||||||
|
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
|
||||||
import type { ChildProcess } from "node:child_process";
|
import type { ChildProcess } from "node:child_process";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
@@ -65,7 +67,7 @@ import { foldSubagentObservations, type SubagentObservation } from "./subagents/
|
|||||||
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
|
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
|
||||||
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
|
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
|
||||||
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
|
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
|
||||||
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||||
import {
|
import {
|
||||||
buildBinaryDiagnosticRows,
|
buildBinaryDiagnosticRows,
|
||||||
buildCommandResolutionDiagnosticRows,
|
buildCommandResolutionDiagnosticRows,
|
||||||
@@ -104,7 +106,7 @@ import {
|
|||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentCreateSessionOptions,
|
type AgentCreateSessionOptions,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -1752,12 +1754,8 @@ export class ClaudeAgentClient implements AgentClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
const claudeConfig = this.assertConfig(config);
|
return buildClaudeControls(this.assertConfig(config).model);
|
||||||
return buildClaudeFeatures({
|
|
||||||
modelId: claudeConfig.model,
|
|
||||||
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async listImportableSessions(
|
async listImportableSessions(
|
||||||
@@ -2380,6 +2378,8 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private compactionMarkerOpen = false;
|
private compactionMarkerOpen = false;
|
||||||
private queryPumpPromise: Promise<void> | null = null;
|
private queryPumpPromise: Promise<void> | null = null;
|
||||||
private queryRestartNeeded = false;
|
private queryRestartNeeded = false;
|
||||||
|
/** True from the moment a query's launch options are copied until the query exists. */
|
||||||
|
private queryStarting = false;
|
||||||
private pendingInterruptAbort = false;
|
private pendingInterruptAbort = false;
|
||||||
/**
|
/**
|
||||||
* Whether Claude's main session is inside one of its own turns: set by system/init and by a
|
* Whether Claude's main session is inside one of its own turns: set by system/init and by a
|
||||||
@@ -2397,6 +2397,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private pendingFreshSessionId: string | null = null;
|
private pendingFreshSessionId: string | null = null;
|
||||||
private recentStderr = "";
|
private recentStderr = "";
|
||||||
private closed = false;
|
private closed = false;
|
||||||
|
readonly openOptionsPatch: Record<string, unknown> = {};
|
||||||
|
|
||||||
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
||||||
this.config = config;
|
this.config = config;
|
||||||
@@ -2440,17 +2441,21 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (this.currentMode !== "plan") {
|
if (this.currentMode !== "plan") {
|
||||||
this.planResumeMode = this.currentMode;
|
this.planResumeMode = this.currentMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fast cannot run on this model, so the session opens with it off. Off rather than removed
|
||||||
|
// keeps a `config.json` default of `fast_mode: true` from filling it back in.
|
||||||
|
if (config.providerOptions.fast_mode === true && !claudeModelSupportsFastMode(config.model)) {
|
||||||
|
this.config.providerOptions = { ...config.providerOptions, fast_mode: false };
|
||||||
|
this.openOptionsPatch = { fast_mode: false };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get id(): string | null {
|
get id(): string | null {
|
||||||
return this.claudeSessionId;
|
return this.claudeSessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
return buildClaudeFeatures({
|
return buildClaudeControls(this.config.model);
|
||||||
modelId: this.config.model,
|
|
||||||
fastModeEnabled: this.config.featureValues?.fast_mode === true,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||||
@@ -2460,7 +2465,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const info: AgentRuntimeInfo = {
|
const info: AgentRuntimeInfo = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: this.claudeSessionId,
|
sessionId: this.claudeSessionId,
|
||||||
model: this.lastOptionsModel ?? this.config.model ?? null,
|
model: this.lastOptionsModel,
|
||||||
modeId: this.currentMode ?? null,
|
modeId: this.currentMode ?? null,
|
||||||
...(this.lastRuntimeModel
|
...(this.lastRuntimeModel
|
||||||
? {
|
? {
|
||||||
@@ -2748,8 +2753,20 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
await activeQuery.setModel(normalizedModelId ?? undefined);
|
await activeQuery.setModel(normalizedModelId ?? undefined);
|
||||||
this.config.model = normalizedModelId ?? undefined;
|
this.config.model = normalizedModelId ?? undefined;
|
||||||
this.reconcileThinkingOptionForModel(normalizedModelId);
|
this.reconcileThinkingOptionForModel(normalizedModelId);
|
||||||
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
|
if (this.config.providerOptions.fast_mode) {
|
||||||
await this.applyFastModeFeature(false, activeQuery);
|
const supported = claudeModelSupportsFastMode(this.config.model);
|
||||||
|
await activeQuery.applyFlagSettings({ fastMode: supported });
|
||||||
|
if (!supported) {
|
||||||
|
// Fast cannot run on this model. Reporting it off rather than removed keeps a
|
||||||
|
// `config.json` default of `fast_mode: true` from filling it back in.
|
||||||
|
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: false };
|
||||||
|
this.pushEvent({
|
||||||
|
type: "options_changed",
|
||||||
|
provider: "claude",
|
||||||
|
patch: { fast_mode: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.cachedRuntimeInfo = null;
|
||||||
}
|
}
|
||||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||||
getClaudeContextWindowMaxTokens(this.config.model),
|
getClaudeContextWindowMaxTokens(this.config.model),
|
||||||
@@ -2801,32 +2818,42 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||||
if (featureId !== "fast_mode") {
|
// Every option but Fast is read when the query is built, and replacing a started query would
|
||||||
throw new Error(`Unknown Claude feature: ${featureId}`);
|
// end its background work, so once it exists only Fast can change.
|
||||||
|
if (this.query || this.queryStarting) {
|
||||||
|
assertChangesAreLive({
|
||||||
|
provider: "claude",
|
||||||
|
patch: options,
|
||||||
|
current: this.config.providerOptions,
|
||||||
|
live: ["fast_mode"],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
const providerOptions =
|
||||||
const enabled = Boolean(value);
|
validateProviderOptions(
|
||||||
if (enabled && !claudeModelSupportsFastMode(this.config.model)) {
|
"claude",
|
||||||
|
ClaudeProviderOptionsSchema,
|
||||||
|
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
|
||||||
|
) ?? {};
|
||||||
|
if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
|
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const fastModeChanged =
|
||||||
await this.applyFastModeFeature(enabled);
|
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
|
||||||
}
|
if (fastModeChanged) {
|
||||||
|
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
|
||||||
private async applyFastModeFeature(enabled: boolean, query?: Query): Promise<void> {
|
|
||||||
this.config.featureValues = {
|
|
||||||
...this.config.featureValues,
|
|
||||||
fast_mode: enabled,
|
|
||||||
};
|
|
||||||
const activeQuery = query ?? this.query;
|
|
||||||
if (activeQuery) {
|
|
||||||
await activeQuery.applyFlagSettings({ fastMode: enabled });
|
|
||||||
}
|
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
}
|
}
|
||||||
|
// Held until the runtime accepts, so a rejected change is retried instead of skipped. Only
|
||||||
|
// this call's keys are applied, over the options as they are now: another patch may have
|
||||||
|
// landed while the runtime was answering.
|
||||||
|
this.config.providerOptions = applyProviderOptionsPatch({
|
||||||
|
options: this.config.providerOptions,
|
||||||
|
patch: options,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getPendingPermissions(): AgentPermissionRequest[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
|
return Array.from(this.pendingPermissions.values()).map((entry) => entry.request);
|
||||||
@@ -3517,7 +3544,15 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.persistence = null;
|
this.persistence = null;
|
||||||
|
|
||||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||||
const options = await this.buildOptions(launchMode);
|
// buildOptions copies the launch options before it waits, so from here they are fixed.
|
||||||
|
this.queryStarting = true;
|
||||||
|
let options: ClaudeOptions;
|
||||||
|
try {
|
||||||
|
options = await this.buildOptions(launchMode);
|
||||||
|
} catch (error) {
|
||||||
|
this.queryStarting = false;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||||
this.input = input;
|
this.input = input;
|
||||||
this.runtimeSafety.reset();
|
this.runtimeSafety.reset();
|
||||||
@@ -3535,6 +3570,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
this.queryStarting = false;
|
||||||
const fastMode = this.resolveFastModeSetting();
|
const fastMode = this.resolveFastModeSetting();
|
||||||
if (fastMode !== null) {
|
if (fastMode !== null) {
|
||||||
await this.query.applyFlagSettings({ fastMode });
|
await this.query.applyFlagSettings({ fastMode });
|
||||||
@@ -3654,10 +3690,9 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
|
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
|
||||||
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
||||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||||
const providerOptions = applyClaudeToolPolicy(
|
// `fast_mode` is a composer control the provider applies through flag settings.
|
||||||
this.config.providerOptions,
|
const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
|
||||||
this.config.toolPolicy,
|
const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
|
||||||
);
|
|
||||||
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
|
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
|
||||||
const sdkEnv = this.harnessEnvironment;
|
const sdkEnv = this.harnessEnvironment;
|
||||||
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
||||||
@@ -3776,7 +3811,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
if (!claudeModelSupportsFastMode(this.config.model)) {
|
if (!claudeModelSupportsFastMode(this.config.model)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return this.config.featureValues?.fast_mode === true;
|
return this.config.providerOptions.fast_mode === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeMcpServers(
|
private normalizeMcpServers(
|
||||||
@@ -6853,79 +6888,40 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||||
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
|
|
||||||
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
|
||||||
|
|
||||||
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
|
|
||||||
// The needle also matches a prompt that quotes it, so a record still has to
|
|
||||||
// parse as a `custom-title` entry carrying a usable name. Records without the
|
|
||||||
// needle are never decoded, so a long one costs a byte search and nothing more.
|
|
||||||
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(record.toString("utf8").trim());
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const entry = toObjectRecord(parsed);
|
|
||||||
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return normalizeClaudeSessionTitle(
|
|
||||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||||
* the current name is the last usable one in the file. Reading backwards a chunk
|
* the current name is the last usable one in the file. Searching backwards finds it without
|
||||||
* at a time finds it without walking the transcript, which matters because
|
* walking the transcript, which matters because sessions reach tens of
|
||||||
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
* megabytes.
|
||||||
* transcript past V8's maximum string length still imports.
|
|
||||||
*/
|
*/
|
||||||
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
function readLastClaudeCustomTitle(content: string): string | null {
|
||||||
const handle = await fsPromises.open(filePath, "r");
|
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
||||||
|
let at = content.lastIndexOf(needle);
|
||||||
|
while (at >= 0) {
|
||||||
|
const lineStart = content.lastIndexOf("\n", at) + 1;
|
||||||
|
const lineEnd = content.indexOf("\n", at);
|
||||||
|
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
||||||
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
const stats = await handle.stat();
|
parsed = JSON.parse(line.trim());
|
||||||
if (!stats.isFile()) {
|
} catch {
|
||||||
// Opening a directory succeeds on Windows even though reading it does not,
|
parsed = undefined;
|
||||||
// so reject the way reading the transcript as a file would.
|
}
|
||||||
throw Object.assign(
|
const entry = toObjectRecord(parsed);
|
||||||
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||||
{ code: "EISDIR", path: filePath },
|
const title = normalizeClaudeSessionTitle(
|
||||||
|
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||||
);
|
);
|
||||||
|
if (title) {
|
||||||
|
return title;
|
||||||
}
|
}
|
||||||
let end = stats.size;
|
|
||||||
// Retain unfinished record pieces in reverse file order. Assemble each
|
|
||||||
// complete record once, so a large tool/image record costs linear work.
|
|
||||||
let pieces: Buffer[] = [];
|
|
||||||
while (end > 0) {
|
|
||||||
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
|
|
||||||
const chunk = Buffer.alloc(length);
|
|
||||||
await handle.read(chunk, 0, length, end - length);
|
|
||||||
let recordEnd = chunk.length;
|
|
||||||
for (
|
|
||||||
let at = chunk.lastIndexOf(10);
|
|
||||||
at >= 0;
|
|
||||||
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
|
|
||||||
) {
|
|
||||||
pieces.push(chunk.subarray(at + 1, recordEnd));
|
|
||||||
const record = Buffer.concat(pieces.toReversed());
|
|
||||||
const title = readClaudeCustomTitleFromRecord(record);
|
|
||||||
if (title) return title;
|
|
||||||
pieces = [];
|
|
||||||
recordEnd = at;
|
|
||||||
}
|
}
|
||||||
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||||
end -= length;
|
// usable name, so keep looking backwards for one that does.
|
||||||
}
|
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||||
// The first record in the file has no newline in front of it.
|
|
||||||
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
|
||||||
} finally {
|
|
||||||
await handle.close();
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||||
@@ -6937,8 +6933,9 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
configDir: string;
|
configDir: string;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
|
let content: string;
|
||||||
try {
|
try {
|
||||||
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A session with no transcript on disk has no rename to carry over. Every
|
// A session with no transcript on disk has no rename to carry over. Every
|
||||||
// other read failure is a real fault and stays explicit.
|
// other read failure is a real fault and stays explicit.
|
||||||
@@ -6947,6 +6944,7 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
return readLastClaudeCustomTitle(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeImportablePromptPreview(text: string): string | null {
|
function normalizeImportablePromptPreview(text: string): string | null {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js";
|
import type { AgentOptionControl, AgentOptionToggleControl } from "../../agent-sdk-types.js";
|
||||||
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
|
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
|
||||||
|
|
||||||
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: "fast_mode",
|
key: "fast_mode",
|
||||||
label: "Fast",
|
label: "Fast",
|
||||||
description: "Lower latency Opus responses at higher token cost",
|
description: "Lower latency Opus responses at higher token cost",
|
||||||
tooltip: "Toggle fast mode",
|
tooltip: "Toggle fast mode",
|
||||||
@@ -14,18 +14,6 @@ export function claudeModelSupportsFastMode(modelId: string | null | undefined):
|
|||||||
return claudeManifestModelSupportsFastMode(modelId);
|
return claudeManifestModelSupportsFastMode(modelId);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildClaudeFeatures(input: {
|
export function buildClaudeControls(modelId: string | null | undefined): AgentOptionControl[] {
|
||||||
modelId: string | null | undefined;
|
return claudeModelSupportsFastMode(modelId) ? [{ ...CLAUDE_FAST_MODE_CONTROL }] : [];
|
||||||
fastModeEnabled: boolean;
|
|
||||||
}): AgentFeature[] {
|
|
||||||
if (!claudeModelSupportsFastMode(input.modelId)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
...CLAUDE_FAST_MODE_FEATURE,
|
|
||||||
value: input.fastModeEnabled,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ const SandboxFilesystemSchema = z
|
|||||||
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
|
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
|
||||||
export const ClaudeProviderOptionsSchema = z
|
export const ClaudeProviderOptionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
/** Composer control, not a Claude SDK option: the provider reads it and keeps it out of the SDK options. */
|
||||||
|
fast_mode: z.boolean().optional(),
|
||||||
allowedTools: z.array(z.string()).optional(),
|
allowedTools: z.array(z.string()).optional(),
|
||||||
disallowedTools: z.array(z.string()).optional(),
|
disallowedTools: z.array(z.string()).optional(),
|
||||||
additionalDirectories: z.array(z.string()).optional(),
|
additionalDirectories: z.array(z.string()).optional(),
|
||||||
|
|||||||
+536
-153
@@ -1,7 +1,19 @@
|
|||||||
|
import { mkdtempSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
import { AgentManager } from "../agent-manager.js";
|
||||||
|
import { toAgentPayload } from "../agent-projections.js";
|
||||||
|
import { AgentStorage } from "../agent-storage.js";
|
||||||
|
import { convertLegacyOptions, featuresFromControls } from "../option-controls.js";
|
||||||
|
import type {
|
||||||
|
AgentClient,
|
||||||
|
AgentSession,
|
||||||
|
AgentSessionConfig,
|
||||||
|
AgentStreamEvent,
|
||||||
|
} from "../agent-sdk-types.js";
|
||||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||||
import {
|
import {
|
||||||
createFakeCodexAppServer,
|
createFakeCodexAppServer,
|
||||||
@@ -37,23 +49,32 @@ const TEST_SPEED_MODELS = [
|
|||||||
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
|
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function speedFeature(value: string) {
|
function speedControl() {
|
||||||
return {
|
return {
|
||||||
type: "select",
|
type: "select",
|
||||||
id: "service_tier",
|
key: "service_tier",
|
||||||
label: "Speed",
|
label: "Speed",
|
||||||
description: "Choose processing speed. Faster tiers increase usage.",
|
description: "Choose processing speed. Faster tiers increase usage.",
|
||||||
tooltip: "Select speed",
|
tooltip: "Select speed",
|
||||||
icon: "zap",
|
icon: "zap",
|
||||||
desktopTrigger: "icon",
|
desktopTrigger: "icon",
|
||||||
value,
|
defaultValue: "default",
|
||||||
options: [
|
choices: [
|
||||||
{ id: "default", label: "Normal", isDefault: true },
|
{ value: "default", label: "Normal" },
|
||||||
{ id: "priority", label: "Fast" },
|
{ value: "priority", label: "Fast" },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLAN_CONTROL = {
|
||||||
|
type: "toggle",
|
||||||
|
key: "plan_mode",
|
||||||
|
label: "Plan",
|
||||||
|
description: "Switch Codex into planning-only collaboration mode",
|
||||||
|
tooltip: "Toggle plan mode",
|
||||||
|
icon: "list-todo",
|
||||||
|
};
|
||||||
|
|
||||||
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||||
{
|
{
|
||||||
name: "Code",
|
name: "Code",
|
||||||
@@ -132,7 +153,7 @@ async function createConnectedSession(
|
|||||||
return harness;
|
return harness;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Codex app-server provider features", () => {
|
describe("Codex app-server provider controls", () => {
|
||||||
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
|
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
|
||||||
const { session, appServer } = await createConnectedSession(
|
const { session, appServer } = await createConnectedSession(
|
||||||
{ model: "gpt-6.1-sol" },
|
{ model: "gpt-6.1-sol" },
|
||||||
@@ -149,19 +170,19 @@ describe("Codex app-server provider features", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
expect(session.features).toContainEqual(
|
expect(session.controls).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "service_tier",
|
key: "service_tier",
|
||||||
type: "select",
|
type: "select",
|
||||||
value: "default",
|
defaultValue: "default",
|
||||||
options: [
|
choices: [
|
||||||
{ id: "default", label: "Normal", isDefault: true },
|
{ value: "default", label: "Normal" },
|
||||||
{ id: "priority", label: "Fast" },
|
{ value: "priority", label: "Fast" },
|
||||||
{ id: "ultrafast", label: "Ultrafast" },
|
{ value: "ultrafast", label: "Ultrafast" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await session.setFeature?.("service_tier", "ultrafast");
|
await session.applyOptions?.({ service_tier: "ultrafast" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
serviceTier: "ultrafast",
|
serviceTier: "ultrafast",
|
||||||
@@ -186,16 +207,16 @@ describe("Codex app-server provider features", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
expect(session.features).toContainEqual(
|
expect(session.controls).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "service_tier",
|
key: "service_tier",
|
||||||
options: [
|
choices: [
|
||||||
{ id: "default", label: "Normal", isDefault: true },
|
{ value: "default", label: "Normal" },
|
||||||
{ id: "future-tier", label: "Future speed" },
|
{ value: "future-tier", label: "Future speed" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await session.setFeature?.("service_tier", "future-tier");
|
await session.applyOptions?.({ service_tier: "future-tier" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
model: "future-codex-model",
|
model: "future-codex-model",
|
||||||
@@ -210,7 +231,7 @@ describe("Codex app-server provider features", () => {
|
|||||||
"restores %s and Normal explicitly overrides an existing thread tier",
|
"restores %s and Normal explicitly overrides an existing thread tier",
|
||||||
async (tier) => {
|
async (tier) => {
|
||||||
const { session, appServer } = await createConnectedSession(
|
const { session, appServer } = await createConnectedSession(
|
||||||
{ model: "future-model", featureValues: { service_tier: tier } },
|
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||||
{
|
{
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -224,11 +245,8 @@ describe("Codex app-server provider features", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
expect(session.features).toContainEqual(
|
|
||||||
expect.objectContaining({ id: "service_tier", value: tier }),
|
|
||||||
);
|
|
||||||
await session.getRuntimeInfo();
|
await session.getRuntimeInfo();
|
||||||
await session.setFeature?.("service_tier", "default");
|
await session.applyOptions?.({ service_tier: "default" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
serviceTier: "default",
|
serviceTier: "default",
|
||||||
@@ -239,17 +257,40 @@ describe("Codex app-server provider features", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("rejects unadvertised tiers and non-string values", async () => {
|
test.each(["priority", "ultrafast"])("restores %s from providerOptions", async (tier) => {
|
||||||
const { session } = await createConnectedSession();
|
const { session, appServer } = await createConnectedSession(
|
||||||
|
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||||
|
{
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "future-model",
|
||||||
|
serviceTiers: [
|
||||||
|
{ id: "priority", name: "Fast", description: "Fast processing" },
|
||||||
|
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
for (const value of ["ultrafast", "bogus", true, null]) {
|
await session.startTurn("hello");
|
||||||
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: tier });
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects unadvertised tiers and non-string values", async () => {
|
||||||
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
try {
|
||||||
|
for (const value of ["ultrafast", "bogus"]) {
|
||||||
|
await expect(session.applyOptions?.({ service_tier: value })).rejects.toThrow(
|
||||||
"is not available",
|
"is not available",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
expect(session.features).toContainEqual(
|
await expect(session.applyOptions?.({ service_tier: true })).rejects.toThrow();
|
||||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
await session.startTurn("hello");
|
||||||
);
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||||
} finally {
|
} finally {
|
||||||
await session.close();
|
await session.close();
|
||||||
}
|
}
|
||||||
@@ -257,7 +298,7 @@ describe("Codex app-server provider features", () => {
|
|||||||
|
|
||||||
test("switching to a model without the selected tier resets speed", async () => {
|
test("switching to a model without the selected tier resets speed", async () => {
|
||||||
const { session, appServer } = await createConnectedSession(
|
const { session, appServer } = await createConnectedSession(
|
||||||
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
|
{ model: "ultra-model", providerOptions: { service_tier: "ultrafast" } },
|
||||||
{
|
{
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
@@ -270,8 +311,14 @@ describe("Codex app-server provider features", () => {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await session.setModel("gpt-6.1-sol");
|
await session.setModel("gpt-6.1-sol");
|
||||||
expect(session.features).toContainEqual(
|
expect(session.controls).toContainEqual(
|
||||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
expect.objectContaining({
|
||||||
|
key: "service_tier",
|
||||||
|
choices: [
|
||||||
|
{ value: "default", label: "Normal" },
|
||||||
|
{ value: "priority", label: "Fast" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||||
@@ -280,10 +327,47 @@ describe("Codex app-server provider features", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("maps legacy Fast commands to the catalog's request id", async () => {
|
test("a Fast chosen as `fast` is kept as the catalog's id, so a model with a different Fast id resets it", async () => {
|
||||||
|
const { session, appServer } = await createConnectedSession(
|
||||||
|
{ model: "model-a" },
|
||||||
|
{
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "model-a",
|
||||||
|
isDefault: true,
|
||||||
|
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "model-b",
|
||||||
|
serviceTiers: [{ id: "rapid", name: "Fast", description: "Faster" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
try {
|
||||||
|
await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({
|
||||||
|
service_tier: "priority",
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.setModel("model-b");
|
||||||
|
|
||||||
|
// The option the session reports and the tier the turn runs are the same speed.
|
||||||
|
expect(events.filter((event) => event.type === "options_changed")).toEqual([
|
||||||
|
expect.objectContaining({ patch: { service_tier: "default" } }),
|
||||||
|
]);
|
||||||
|
await session.startTurn("hello");
|
||||||
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("`fast` maps to the catalog's request id", async () => {
|
||||||
const { session, appServer } = await createConnectedSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
try {
|
try {
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.applyOptions?.({ service_tier: "fast" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
serviceTier: "priority",
|
serviceTier: "priority",
|
||||||
@@ -307,13 +391,13 @@ describe("Codex app-server provider features", () => {
|
|||||||
])("exposes and sends Fast for %s", async (model) => {
|
])("exposes and sends Fast for %s", async (model) => {
|
||||||
const { session, appServer } = await createConnectedSession({ model });
|
const { session, appServer } = await createConnectedSession({ model });
|
||||||
try {
|
try {
|
||||||
expect(session.features).toContainEqual(
|
expect(session.controls).toContainEqual(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "service_tier",
|
key: "service_tier",
|
||||||
value: "default",
|
defaultValue: "default",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
await session.setFeature?.("service_tier", "priority");
|
await session.applyOptions?.({ service_tier: "priority" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
model,
|
model,
|
||||||
@@ -338,12 +422,13 @@ describe("Codex app-server provider features", () => {
|
|||||||
])("does not expose or restore Fast for %s", async (model) => {
|
])("does not expose or restore Fast for %s", async (model) => {
|
||||||
const { session, appServer } = await createConnectedSession({
|
const { session, appServer } = await createConnectedSession({
|
||||||
model,
|
model,
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { service_tier: "fast" },
|
||||||
});
|
});
|
||||||
|
const fresh = await createConnectedSession({ model });
|
||||||
try {
|
try {
|
||||||
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]);
|
expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]);
|
||||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
await expect(fresh.session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow(
|
||||||
`Codex fast mode is not available for model '${model}'`,
|
`Codex speed 'fast' is not available for model '${model}'`,
|
||||||
);
|
);
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
@@ -351,21 +436,16 @@ describe("Codex app-server provider features", () => {
|
|||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
await session.close();
|
await session.close();
|
||||||
|
await fresh.session.close();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("restores Fast on Astra and preserves it when switching supported models", async () => {
|
test("restores Fast on Astra and preserves it when switching supported models", async () => {
|
||||||
const { session, appServer } = await createConnectedSession({
|
const { session, appServer } = await createConnectedSession({
|
||||||
model: "gpt-6-astra",
|
model: "gpt-6-astra",
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { service_tier: "fast" },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
expect(session.features).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
id: "service_tier",
|
|
||||||
value: "priority",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
await session.setModel("gpt-5.6-sol");
|
await session.setModel("gpt-5.6-sol");
|
||||||
await session.setModel("gpt-6-astra");
|
await session.setModel("gpt-6-astra");
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
@@ -378,72 +458,33 @@ describe("Codex app-server provider features", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("features returns speed selector and plan toggle when supported", async () => {
|
test("controls return the speed selector and plan toggle when supported", async () => {
|
||||||
const { session } = await createConnectedSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
|
||||||
speedFeature("default"),
|
|
||||||
{
|
|
||||||
type: "toggle",
|
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
description: "Switch Codex into planning-only collaboration mode",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
icon: "list-todo",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await session.setFeature?.("service_tier", "priority");
|
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
|
||||||
await session.setFeature?.("plan_mode", true);
|
await session.startTurn("hello");
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
speedFeature("priority"),
|
serviceTier: "priority",
|
||||||
{
|
collaborationMode: expect.objectContaining({ mode: "plan" }),
|
||||||
type: "toggle",
|
});
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
description: "Switch Codex into planning-only collaboration mode",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
icon: "list-todo",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("features returns only plan toggle when model does not support fast mode", async () => {
|
test("controls return only the plan toggle when model does not support fast mode", async () => {
|
||||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||||
{
|
|
||||||
type: "toggle",
|
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
description: "Switch Codex into planning-only collaboration mode",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
icon: "list-todo",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("constructor ignores restored fast mode when model does not support it", async () => {
|
test("constructor ignores restored fast mode when model does not support it", async () => {
|
||||||
const { session, appServer } = await createConnectedSession({
|
const { session, appServer } = await createConnectedSession({
|
||||||
model: "gpt-3.5-turbo",
|
model: "gpt-3.5-turbo",
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { service_tier: "fast" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||||
{
|
|
||||||
type: "toggle",
|
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
description: "Switch Codex into planning-only collaboration mode",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
icon: "list-todo",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
@@ -454,7 +495,7 @@ describe("Codex app-server provider features", () => {
|
|||||||
test("selecting Fast sends the catalog service tier", async () => {
|
test("selecting Fast sends the catalog service tier", async () => {
|
||||||
const { session, appServer } = await createConnectedSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
|
||||||
await session.setFeature?.("service_tier", "priority");
|
await session.applyOptions?.({ service_tier: "priority" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
@@ -462,12 +503,12 @@ describe("Codex app-server provider features", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("legacy Fast off explicitly selects Normal", async () => {
|
test("selecting Normal after Fast explicitly selects Normal", async () => {
|
||||||
const { session, appServer } = await createConnectedSession({
|
const { session, appServer } = await createConnectedSession({
|
||||||
featureValues: { fast_mode: true },
|
providerOptions: { service_tier: "fast" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.setFeature?.("fast_mode", false);
|
await session.applyOptions?.({ service_tier: "default" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
@@ -475,53 +516,39 @@ describe("Codex app-server provider features", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => {
|
test('applyOptions({ service_tier: "fast" }) rejects models that do not support fast mode', async () => {
|
||||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||||
|
|
||||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
await expect(session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow(
|
||||||
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
|
"Codex speed 'fast' is not available for model 'gpt-3.5-turbo'",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setFeature invalidates runtime info", async () => {
|
test("applyOptions invalidates runtime info", async () => {
|
||||||
const { session } = await createConnectedSession();
|
const { session } = await createConnectedSession();
|
||||||
|
|
||||||
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
||||||
extra: { collaborationMode: "Plan" },
|
extra: { collaborationMode: "Plan" },
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.setFeature?.("plan_mode", true);
|
await session.applyOptions?.({ plan_mode: true });
|
||||||
|
|
||||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||||
extra: { collaborationMode: "Plan" },
|
extra: { collaborationMode: "Plan" },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("setFeature throws for unknown feature ids", async () => {
|
test("constructor restores composer options from config.providerOptions", async () => {
|
||||||
const { session } = createSessionHarness();
|
|
||||||
|
|
||||||
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
|
||||||
"Unknown Codex feature: unknown_feature",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("constructor restores feature flags from config.featureValues", async () => {
|
|
||||||
const { session, appServer } = await createConnectedSession({
|
const { session, appServer } = await createConnectedSession({
|
||||||
featureValues: { fast_mode: true, plan_mode: true },
|
providerOptions: { service_tier: "fast", plan_mode: true },
|
||||||
});
|
});
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
|
||||||
speedFeature("priority"),
|
// A saved `fast` opens as the model's Fast tier, which the open patch says.
|
||||||
{
|
expect(session.openOptionsPatch).toEqual({ service_tier: "priority" });
|
||||||
type: "toggle",
|
expect(events.filter((event) => event.type === "options_changed")).toEqual([]);
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
description: "Switch Codex into planning-only collaboration mode",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
icon: "list-todo",
|
|
||||||
value: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
@@ -532,10 +559,36 @@ describe("Codex app-server provider features", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a patch that sets another option keeps the running sandbox and an explicit speed", async () => {
|
||||||
|
const { session, appServer } = await createConnectedSession({
|
||||||
|
providerOptions: { sandbox_mode: "read-only", service_tier: "default" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.applyOptions?.({ plan_mode: true });
|
||||||
|
await session.startTurn("hello");
|
||||||
|
|
||||||
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
|
serviceTier: "default",
|
||||||
|
sandboxPolicy: expect.objectContaining({ type: "readOnly" }),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a live change to the approval policy or sandbox, which the thread fixed when it started", async () => {
|
||||||
|
const { session } = await createConnectedSession({
|
||||||
|
providerOptions: { sandbox_mode: "workspace-write" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).rejects.toThrow(
|
||||||
|
/sandbox_mode.*cannot change on a running session/,
|
||||||
|
);
|
||||||
|
// The same value is not a change.
|
||||||
|
await expect(session.applyOptions?.({ sandbox_mode: "workspace-write" })).resolves.toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||||
const { session, appServer } = await createConnectedSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
|
||||||
await session.setFeature?.("service_tier", "priority");
|
await session.applyOptions?.({ service_tier: "priority" });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
@@ -543,11 +596,351 @@ describe("Codex app-server provider features", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("opening on a model without the saved tier runs Normal and the open patch says so", async () => {
|
||||||
|
const models = [
|
||||||
|
{ id: "plain", isDefault: true, defaultReasoningEffort: "medium", serviceTiers: [] },
|
||||||
|
];
|
||||||
|
const { session, appServer } = await createConnectedSession(
|
||||||
|
{ model: "plain", providerOptions: { service_tier: "priority" } },
|
||||||
|
{ models },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ service_tier: "default" });
|
||||||
|
|
||||||
|
await session.startTurn("hello");
|
||||||
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a saved service_tier: fast runs as the model's Fast tier and the open patch names that tier", async () => {
|
||||||
|
const { session, appServer } = await createConnectedSession({
|
||||||
|
providerOptions: { service_tier: "fast" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(session.openOptionsPatch).toEqual({ service_tier: "priority" });
|
||||||
|
|
||||||
|
await session.startTurn("hello");
|
||||||
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a live service_tier: fast is returned as the model's Fast tier", async () => {
|
||||||
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({
|
||||||
|
service_tier: "priority",
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.startTurn("hello");
|
||||||
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("switching to a model without the speed tier says Normal and keeps it so on the way back", async () => {
|
||||||
|
const models = [
|
||||||
|
...TEST_SPEED_MODELS,
|
||||||
|
{ id: "plain", isDefault: false, defaultReasoningEffort: "medium", serviceTiers: [] },
|
||||||
|
];
|
||||||
|
const { session, appServer } = await createConnectedSession(
|
||||||
|
{ model: "gpt-5.4", providerOptions: { service_tier: "priority" } },
|
||||||
|
{ models },
|
||||||
|
);
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
await session.setModel?.("plain");
|
||||||
|
await session.setModel?.("gpt-5.4");
|
||||||
|
|
||||||
|
expect(events.filter((event) => event.type === "options_changed")).toEqual([
|
||||||
|
{ type: "options_changed", provider: "codex", patch: { service_tier: "default" } },
|
||||||
|
]);
|
||||||
|
await session.startTurn("hello");
|
||||||
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("legacy Fast (fast_mode) saved or requested before service_tier", () => {
|
||||||
|
function createLegacyFastHarness(
|
||||||
|
rawDefaults: Record<string, unknown> = {},
|
||||||
|
providerId = CODEX_PROVIDER,
|
||||||
|
) {
|
||||||
|
// The provider registry converts configured defaults before it merges them.
|
||||||
|
const configDefaults = convertLegacyOptions({
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
options: rawDefaults,
|
||||||
|
});
|
||||||
|
const appServer = createFakeCodexAppServer({
|
||||||
|
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||||
|
"model/list": () => ({ data: TEST_SPEED_MODELS }),
|
||||||
|
});
|
||||||
|
const logger = createTestLogger();
|
||||||
|
const openSession = async (
|
||||||
|
config: AgentSessionConfig,
|
||||||
|
resumeSessionId: string | null,
|
||||||
|
): Promise<AgentSession> => {
|
||||||
|
const session = new CodexAppServerAgentSession(
|
||||||
|
// The provider registry hands sessions the `config.json` defaults merged into the agent's own options.
|
||||||
|
{
|
||||||
|
...config,
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
providerOptions: { ...configDefaults, ...config.providerOptions },
|
||||||
|
},
|
||||||
|
resumeSessionId ? { sessionId: resumeSessionId } : null,
|
||||||
|
logger,
|
||||||
|
async () => appServer.child,
|
||||||
|
);
|
||||||
|
await session.connect();
|
||||||
|
return session;
|
||||||
|
};
|
||||||
|
const client: AgentClient = {
|
||||||
|
provider: providerId,
|
||||||
|
capabilities: {
|
||||||
|
supportsStreaming: true,
|
||||||
|
supportsSessionPersistence: true,
|
||||||
|
supportsDynamicModes: false,
|
||||||
|
supportsMcpServers: false,
|
||||||
|
supportsReasoningStream: false,
|
||||||
|
supportsToolInvocations: false,
|
||||||
|
},
|
||||||
|
isAvailable: async () => true,
|
||||||
|
fetchCatalog: async () => ({ models: [], modes: [] }),
|
||||||
|
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
|
||||||
|
effectiveOptions: (own) => ({ ...configDefaults, ...own }),
|
||||||
|
createSession: (config) => openSession(config, null),
|
||||||
|
resumeSession: (handle, config) =>
|
||||||
|
openSession(
|
||||||
|
{ ...config, provider: CODEX_PROVIDER } as AgentSessionConfig,
|
||||||
|
handle.sessionId,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
const workdir = mkdtempSync(join(tmpdir(), "codex-legacy-fast-"));
|
||||||
|
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||||
|
const manager = new AgentManager({
|
||||||
|
clients: { [providerId]: client },
|
||||||
|
// A custom provider that extends Codex runs on the Codex adapter.
|
||||||
|
providerDefinitions:
|
||||||
|
providerId === CODEX_PROVIDER
|
||||||
|
? {}
|
||||||
|
: { [providerId]: { enabled: true, derivedFromProviderId: CODEX_PROVIDER } },
|
||||||
|
registry: storage,
|
||||||
|
logger,
|
||||||
|
});
|
||||||
|
const turnStarts = () =>
|
||||||
|
appServer
|
||||||
|
.requests()
|
||||||
|
.filter((message) => message.method === "turn/start")
|
||||||
|
.map((message) => message.params);
|
||||||
|
|
||||||
|
async function create(providerOptions?: Record<string, unknown>) {
|
||||||
|
const agent = await manager.createAgent(
|
||||||
|
{
|
||||||
|
provider: providerId,
|
||||||
|
cwd: workdir,
|
||||||
|
model: "gpt-5.4",
|
||||||
|
...(providerOptions ? { providerOptions } : {}),
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
{ workspaceId: undefined },
|
||||||
|
);
|
||||||
|
return agent.id;
|
||||||
|
}
|
||||||
|
async function resume(providerOptions: Record<string, unknown>) {
|
||||||
|
const agent = await manager.resumeAgentFromPersistence({
|
||||||
|
provider: providerId,
|
||||||
|
sessionId: "thread-1",
|
||||||
|
metadata: { cwd: workdir, model: "gpt-5.4", providerOptions },
|
||||||
|
});
|
||||||
|
return agent.id;
|
||||||
|
}
|
||||||
|
function shownSpeed(agentId: string) {
|
||||||
|
const payload = toAgentPayload(manager.getAgent(agentId)!);
|
||||||
|
const control = payload.controls?.find((candidate) => candidate.key === "service_tier");
|
||||||
|
const choice =
|
||||||
|
control?.type === "select"
|
||||||
|
? control.choices.find((candidate) => candidate.value === payload.options?.service_tier)
|
||||||
|
: undefined;
|
||||||
|
return {
|
||||||
|
options: payload.options,
|
||||||
|
own: manager.getAgent(agentId)?.config.providerOptions,
|
||||||
|
// What a client reading `options` + `controls` selects, and what the legacy `features` show.
|
||||||
|
selected:
|
||||||
|
payload.options?.service_tier === undefined && control?.type === "select"
|
||||||
|
? control.defaultValue
|
||||||
|
: choice?.value,
|
||||||
|
feature: payload.features?.find((feature) => feature.id === "service_tier")?.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
async function runTurn(agentId: string, expectedTier: string) {
|
||||||
|
const before = turnStarts().length;
|
||||||
|
await manager.getAgent(agentId)!.session!.startTurn("hello");
|
||||||
|
await vi.waitFor(() => expect(turnStarts()).toHaveLength(before + 1));
|
||||||
|
const turn = turnStarts()[before]!;
|
||||||
|
expect(turn).toMatchObject({ serviceTier: expectedTier });
|
||||||
|
appServer.completeTurn({ threadId: String(turn.threadId) });
|
||||||
|
await vi.waitFor(() => expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"));
|
||||||
|
}
|
||||||
|
return { manager, create, resume, shownSpeed, runTurn };
|
||||||
|
}
|
||||||
|
|
||||||
|
const FAST = { selected: "priority", feature: "priority" };
|
||||||
|
|
||||||
|
test("a saved fast_mode opens as the model's Fast tier and is stored as that tier", async () => {
|
||||||
|
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||||
|
const agentId = await create({ fast_mode: true });
|
||||||
|
await manager.flush();
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({
|
||||||
|
options: { service_tier: "priority" },
|
||||||
|
own: { service_tier: "priority" },
|
||||||
|
...FAST,
|
||||||
|
});
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("is shown as Fast after a stored record is resumed", async () => {
|
||||||
|
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||||
|
const agentId = await resume({ fast_mode: true });
|
||||||
|
await manager.flush();
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("from config.json defaults runs Fast and is shown as Fast", async () => {
|
||||||
|
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
|
||||||
|
const agentId = await create();
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject(FAST);
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a saved fast_mode beats a configured service_tier default", async () => {
|
||||||
|
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({
|
||||||
|
service_tier: "default",
|
||||||
|
});
|
||||||
|
const agentId = await resume({ fast_mode: true });
|
||||||
|
await manager.flush();
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a provider extending Codex opens a saved fast_mode as Fast", async () => {
|
||||||
|
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
|
||||||
|
const agentId = await resume({ fast_mode: true });
|
||||||
|
await manager.flush();
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a provider extending Codex creates with a requested fast_mode and applies a later one", async () => {
|
||||||
|
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
|
||||||
|
const agentId = await create({ fast_mode: false });
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
|
||||||
|
|
||||||
|
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an older client's fast_mode runs Fast at once, and a later service_tier replaces it", async () => {
|
||||||
|
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||||
|
const agentId = await create();
|
||||||
|
expect(shownSpeed(agentId).selected).toBe("default");
|
||||||
|
|
||||||
|
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
|
||||||
|
await manager.setAgentOptions(agentId, { service_tier: "default" });
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({
|
||||||
|
own: { service_tier: "default" },
|
||||||
|
selected: "default",
|
||||||
|
feature: "default",
|
||||||
|
});
|
||||||
|
await runTurn(agentId, "default");
|
||||||
|
|
||||||
|
await manager.setAgentOptions(agentId, { service_tier: "priority" });
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ selected: "priority" });
|
||||||
|
await runTurn(agentId, "priority");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a request carrying both speed keys follows service_tier", async () => {
|
||||||
|
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||||
|
const agentId = await create({ service_tier: "priority" });
|
||||||
|
|
||||||
|
await manager.setAgentOptions(agentId, { service_tier: "default", fast_mode: true });
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
|
||||||
|
await runTurn(agentId, "default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
[CODEX_PROVIDER, "a requested legacy fast_mode", {}, { fast_mode: true }],
|
||||||
|
["my-codex", "a requested legacy fast_mode", {}, { fast_mode: true }],
|
||||||
|
[CODEX_PROVIDER, "a configured legacy fast_mode", { fast_mode: true }, undefined],
|
||||||
|
[CODEX_PROVIDER, "a configured Fast alias", { service_tier: "fast" }, undefined],
|
||||||
|
])(
|
||||||
|
"draft speed for %s with %s is the one the first turn runs with",
|
||||||
|
async (providerId, _name, configured, requested) => {
|
||||||
|
const drafting = createLegacyFastHarness(configured, providerId);
|
||||||
|
|
||||||
|
const draft = await drafting.manager.listDraftControls({
|
||||||
|
provider: providerId,
|
||||||
|
cwd: tmpdir(),
|
||||||
|
model: "gpt-5.4",
|
||||||
|
...(requested ? { providerOptions: requested } : {}),
|
||||||
|
});
|
||||||
|
const features = featuresFromControls({ controls: draft.controls, options: draft.options });
|
||||||
|
|
||||||
|
expect(draft.options).toEqual({ service_tier: "priority" });
|
||||||
|
expect(draft.controls).toContainEqual(expect.objectContaining({ key: "service_tier" }));
|
||||||
|
expect(features).toContainEqual(
|
||||||
|
expect.objectContaining({ id: "service_tier", value: "priority" }),
|
||||||
|
);
|
||||||
|
// A draft session ends with its app-server; creating runs on a fresh one.
|
||||||
|
const creating = createLegacyFastHarness(configured, providerId);
|
||||||
|
const agentId = await creating.create(requested);
|
||||||
|
await creating.runTurn(agentId, "priority");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
test.each([
|
||||||
|
["a string", "false"],
|
||||||
|
["a number", 1],
|
||||||
|
])("a legacy fast_mode that is %s is rejected, not read as Fast", async (_name, value) => {
|
||||||
|
const { manager, create } = createLegacyFastHarness();
|
||||||
|
const agentId = await create({});
|
||||||
|
|
||||||
|
await expect(manager.setAgentOptions(agentId, { fast_mode: value })).rejects.toThrow(
|
||||||
|
/fast_mode must be true, false or null/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an older client turning fast_mode off selects Normal", async () => {
|
||||||
|
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||||
|
const agentId = await create({ service_tier: "priority" });
|
||||||
|
|
||||||
|
await manager.setAgentOptions(agentId, { fast_mode: false });
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
|
||||||
|
await runTurn(agentId, "default");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removing the speed returns the agent to Normal", async () => {
|
||||||
|
const { create, manager, shownSpeed } = createLegacyFastHarness();
|
||||||
|
const agentId = await create({ service_tier: "priority" });
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject(FAST);
|
||||||
|
|
||||||
|
await manager.setAgentOptions(agentId, { service_tier: null });
|
||||||
|
|
||||||
|
expect(shownSpeed(agentId)).toMatchObject({ own: undefined, selected: "default" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
|
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
|
||||||
const capture = createCapturedLogger();
|
const capture = createCapturedLogger();
|
||||||
const prompt = "secret prompt text should not be logged";
|
const prompt = "secret prompt text should not be logged";
|
||||||
const { session } = await createConnectedSession(
|
const { session } = await createConnectedSession(
|
||||||
{ featureValues: { fast_mode: true } },
|
{ providerOptions: { service_tier: "fast" } },
|
||||||
{ logger: capture.logger },
|
{ logger: capture.logger },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -570,20 +963,10 @@ describe("Codex app-server provider features", () => {
|
|||||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||||
const { session, appServer } = await createConnectedSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
|
||||||
await session.setFeature?.("service_tier", "priority");
|
await session.applyOptions?.({ service_tier: "priority" });
|
||||||
await session.setModel("gpt-3.5-turbo");
|
await session.setModel("gpt-3.5-turbo");
|
||||||
|
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||||
{
|
|
||||||
type: "toggle",
|
|
||||||
id: "plan_mode",
|
|
||||||
label: "Plan",
|
|
||||||
description: "Switch Codex into planning-only collaboration mode",
|
|
||||||
tooltip: "Toggle plan mode",
|
|
||||||
icon: "list-todo",
|
|
||||||
value: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
@@ -594,7 +977,7 @@ describe("Codex app-server provider features", () => {
|
|||||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||||
const { session, appServer } = await createConnectedSession();
|
const { session, appServer } = await createConnectedSession();
|
||||||
|
|
||||||
await session.setFeature?.("plan_mode", true);
|
await session.applyOptions?.({ plan_mode: true });
|
||||||
await session.startTurn("hello");
|
await session.startTurn("hello");
|
||||||
|
|
||||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||||
|
|||||||
+2
-2
@@ -38,7 +38,7 @@ async function withPendingPlan(
|
|||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
featureValues: { plan_mode: true },
|
providerOptions: { plan_mode: true },
|
||||||
});
|
});
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -526,7 +526,7 @@ test("idle provider exit preserves a synthetic plan approval across reconnect",
|
|||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
modeId: "auto",
|
modeId: "auto",
|
||||||
model: "gpt-5.4",
|
model: "gpt-5.4",
|
||||||
featureValues: { plan_mode: true },
|
providerOptions: { plan_mode: true },
|
||||||
},
|
},
|
||||||
undefined,
|
undefined,
|
||||||
{ workspaceId: undefined },
|
{ workspaceId: undefined },
|
||||||
|
|||||||
@@ -6188,7 +6188,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => {
|
test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: true },
|
providerOptions: { plan_mode: true, service_tier: "fast" },
|
||||||
});
|
});
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -6258,7 +6258,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("does not complete Codex plan timeline cards while plan approval is pending", async () => {
|
test("does not complete Codex plan timeline cards while plan approval is pending", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: true },
|
providerOptions: { plan_mode: true, service_tier: "fast" },
|
||||||
});
|
});
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -6304,7 +6304,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("replaces a pending synthetic plan approval when a later plan completes", async () => {
|
test("replaces a pending synthetic plan approval when a later plan completes", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true },
|
providerOptions: { plan_mode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await asInternals(session).handleNotification("turn/started", {
|
await asInternals(session).handleNotification("turn/started", {
|
||||||
@@ -6337,7 +6337,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
|
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true },
|
providerOptions: { plan_mode: true },
|
||||||
});
|
});
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -6379,7 +6379,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
|
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true },
|
providerOptions: { plan_mode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await asInternals(session).handleNotification("turn/started", {
|
await asInternals(session).handleNotification("turn/started", {
|
||||||
@@ -6433,7 +6433,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
|
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true },
|
providerOptions: { plan_mode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await asInternals(session).handleNotification("turn/started", {
|
await asInternals(session).handleNotification("turn/started", {
|
||||||
@@ -6483,7 +6483,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
|
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true },
|
providerOptions: { plan_mode: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
await asInternals(session).handleNotification("turn/started", {
|
await asInternals(session).handleNotification("turn/started", {
|
||||||
@@ -7107,7 +7107,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: true },
|
providerOptions: { plan_mode: true, service_tier: "fast" },
|
||||||
});
|
});
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -7138,9 +7138,10 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
expect(asInternals(session).serviceTier).toBe("fast");
|
expect(asInternals(session).serviceTier).toBe("fast");
|
||||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||||
expect(asInternals(session).config.featureValues).toEqual({
|
expect(events).toContainEqual({
|
||||||
plan_mode: false,
|
type: "options_changed",
|
||||||
fast_mode: true,
|
provider: "codex",
|
||||||
|
patch: { plan_mode: false },
|
||||||
});
|
});
|
||||||
// The session returns the follow-up prompt instead of calling startTurn directly.
|
// The session returns the follow-up prompt instead of calling startTurn directly.
|
||||||
// The caller (session/agent-manager) is responsible for sending it through streamAgent.
|
// The caller (session/agent-manager) is responsible for sending it through streamAgent.
|
||||||
@@ -7161,7 +7162,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
|
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: false },
|
providerOptions: { plan_mode: true, service_tier: "default" },
|
||||||
});
|
});
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => events.push(event));
|
session.subscribe((event) => events.push(event));
|
||||||
@@ -7190,11 +7191,12 @@ describe("Codex app-server provider", () => {
|
|||||||
selectedActionId: "implement",
|
selectedActionId: "implement",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(asInternals(session).serviceTier).toBeNull();
|
expect(asInternals(session).serviceTier).toBe("default");
|
||||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||||
expect(asInternals(session).config.featureValues).toEqual({
|
expect(events).toContainEqual({
|
||||||
plan_mode: false,
|
type: "options_changed",
|
||||||
fast_mode: false,
|
provider: "codex",
|
||||||
|
patch: { plan_mode: false },
|
||||||
});
|
});
|
||||||
expect(result?.followUpPrompt).toEqual(
|
expect(result?.followUpPrompt).toEqual(
|
||||||
expect.stringContaining("The user approved the plan. Implement it now."),
|
expect.stringContaining("The user approved the plan. Implement it now."),
|
||||||
@@ -7203,7 +7205,7 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => {
|
test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
featureValues: { plan_mode: true, fast_mode: true },
|
providerOptions: { plan_mode: true, service_tier: "fast" },
|
||||||
});
|
});
|
||||||
asInternals(session).collaborationModes = [
|
asInternals(session).collaborationModes = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { validateProviderOptions } from "../provider-options.js";
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { applyProviderOptionsPatch } from "../option-controls.js";
|
||||||
|
import { assertChangesAreLive, validateProviderOptions } from "../provider-options.js";
|
||||||
import {
|
import {
|
||||||
getAgentStreamEventTurnId,
|
getAgentStreamEventTurnId,
|
||||||
type AgentPermissionAction,
|
type AgentPermissionAction,
|
||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentCreateSessionOptions,
|
type AgentCreateSessionOptions,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentResumeSessionOptions,
|
type AgentResumeSessionOptions,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -75,7 +77,7 @@ import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
|||||||
import { spawnProcess } from "../../../utils/spawn.js";
|
import { spawnProcess } from "../../../utils/spawn.js";
|
||||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||||
import {
|
import {
|
||||||
buildCodexFeatures,
|
buildCodexControls,
|
||||||
CodexServiceTierSchema,
|
CodexServiceTierSchema,
|
||||||
readCodexServiceTier,
|
readCodexServiceTier,
|
||||||
type CodexServiceTier,
|
type CodexServiceTier,
|
||||||
@@ -150,15 +152,6 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
|
|||||||
|
|
||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||||
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
|
|
||||||
// Each terminate is bounded by the first constant. A turn start waits for the
|
|
||||||
// interrupted turn's commands to end, at most the interrupt's own bound plus one
|
|
||||||
// terminate's and a margin, so it gives up only when a request outlives its timeout.
|
|
||||||
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
|
|
||||||
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
|
|
||||||
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
|
|
||||||
// Turns whose commands are remembered; older turns are forgotten first.
|
|
||||||
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
|
|
||||||
const CODEX_PROVIDER = "codex" as const;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
// These native provider identities belong to Codex, not to an older custom
|
// These native provider identities belong to Codex, not to an older custom
|
||||||
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
||||||
@@ -3438,7 +3431,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private readonly codexHome: string;
|
private readonly codexHome: string;
|
||||||
private currentMode: string;
|
private currentMode: string;
|
||||||
private hasWorkflowModeOverride: boolean;
|
private hasWorkflowModeOverride: boolean;
|
||||||
private readonly providerOptions: CodexProviderOptions;
|
private providerOptions: CodexProviderOptions;
|
||||||
private resolvedWorkspaceWrite: NonNullable<
|
private resolvedWorkspaceWrite: NonNullable<
|
||||||
CodexProviderOptions["sandbox_workspace_write"]
|
CodexProviderOptions["sandbox_workspace_write"]
|
||||||
> | null = null;
|
> | null = null;
|
||||||
@@ -3462,11 +3455,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private threadRollbackAvailable = true;
|
private threadRollbackAvailable = true;
|
||||||
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
private readonly subscribers = new Set<(event: AgentStreamEvent) => void>();
|
||||||
private startupEvents: AgentStreamEvent[] | null = null;
|
private startupEvents: AgentStreamEvent[] | null = null;
|
||||||
|
/** How the opened session differs from the options it was given; reported at first subscribe. */
|
||||||
|
openOptionsPatch: Record<string, unknown> = {};
|
||||||
private nextTurnOrdinal = 0;
|
private nextTurnOrdinal = 0;
|
||||||
private activeForegroundTurnId: string | null = null;
|
private activeForegroundTurnId: string | null = null;
|
||||||
private activeClientMessageId: string | null = null;
|
private activeClientMessageId: string | null = null;
|
||||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||||
private serviceTier: string | null = null;
|
/** The tier the options ask for; `serviceTier` is what the current model can honor. */
|
||||||
|
private requestedServiceTier: string | null = null;
|
||||||
private speedModels: CodexModel[] = [];
|
private speedModels: CodexModel[] = [];
|
||||||
private planModeEnabled = false;
|
private planModeEnabled = false;
|
||||||
// History read by rewind or an archived resume, held until streamHistory consumes it.
|
// History read by rewind or an archived resume, held until streamHistory consumes it.
|
||||||
@@ -3485,14 +3481,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||||
private pendingAssistantMessageBoundary = false;
|
private pendingAssistantMessageBoundary = false;
|
||||||
private terminalCommandByProcessId = new Map<string, string>();
|
private terminalCommandByProcessId = new Map<string, string>();
|
||||||
// The root thread's still-running command process ids, keyed by the native turn that
|
|
||||||
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
|
|
||||||
// `item/started` is the only record of which turn owns a process.
|
|
||||||
private terminalProcessIdsByTurnId = new Map<
|
|
||||||
string,
|
|
||||||
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
|
|
||||||
>();
|
|
||||||
private interruptedTurnCleanup: Promise<void> | null = null;
|
|
||||||
private pendingUnlabeledTerminalInteractions = new Map<
|
private pendingUnlabeledTerminalInteractions = new Map<
|
||||||
string,
|
string,
|
||||||
Array<{ callId: string; stdin: string | null }>
|
Array<{ callId: string; stdin: string | null }>
|
||||||
@@ -3585,10 +3573,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||||
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||||
this.serviceTier = readCodexServiceTier(this.config.featureValues);
|
this.requestedServiceTier = readCodexServiceTier(this.providerOptions);
|
||||||
if (this.config.featureValues?.plan_mode) {
|
this.planModeEnabled = this.providerOptions.plan_mode === true;
|
||||||
this.planModeEnabled = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.resumeHandle?.sessionId) {
|
if (this.resumeHandle?.sessionId) {
|
||||||
this.currentThreadId = this.resumeHandle.sessionId;
|
this.currentThreadId = this.resumeHandle.sessionId;
|
||||||
@@ -3603,11 +3589,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return this.currentThreadId;
|
return this.currentThreadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
return buildCodexFeatures({
|
return buildCodexControls({
|
||||||
serviceTiers: this.currentServiceTiers(),
|
serviceTiers: this.currentServiceTiers(),
|
||||||
serviceTier: this.serviceTier ?? "default",
|
|
||||||
planModeEnabled: this.planModeEnabled,
|
|
||||||
planModeAvailable: this.hasPlanCollaborationMode(),
|
planModeAvailable: this.hasPlanCollaborationMode(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -3667,7 +3651,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
this.speedModels =
|
this.speedModels =
|
||||||
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
|
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
|
||||||
this.reconcileServiceTier();
|
this.normalizeSpeed();
|
||||||
await this.loadResolvedWorkspaceWrite();
|
await this.loadResolvedWorkspaceWrite();
|
||||||
await this.loadCollaborationModes();
|
await this.loadCollaborationModes();
|
||||||
await this.loadSkills();
|
await this.loadSkills();
|
||||||
@@ -3880,24 +3864,34 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return selectedModel?.serviceTiers ?? [];
|
return selectedModel?.serviceTiers ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
private reconcileServiceTier(): void {
|
/**
|
||||||
const tiers = this.currentServiceTiers();
|
* A saved `service_tier: "fast"` runs as the model's Fast tier, and a tier the opening model
|
||||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
* lacks runs as Normal; the session says so in `openOptionsPatch`, so the options and the
|
||||||
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
|
* control agree (as when the model is switched later).
|
||||||
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
|
*/
|
||||||
}
|
private normalizeSpeed(): void {
|
||||||
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
|
const requested = this.requestedServiceTier;
|
||||||
this.serviceTier = null;
|
const resolved = this.resolveServiceTier(requested);
|
||||||
}
|
if (requested === resolved) return;
|
||||||
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
|
const runs = resolved ?? "default";
|
||||||
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
|
this.providerOptions = { ...this.providerOptions, service_tier: runs };
|
||||||
|
this.requestedServiceTier = runs;
|
||||||
|
this.openOptionsPatch = { service_tier: runs };
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
|
private get serviceTier(): string | null {
|
||||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
|
return this.resolveServiceTier(this.requestedServiceTier);
|
||||||
this.planModeEnabled = value;
|
}
|
||||||
this.refreshResolvedCollaborationMode();
|
|
||||||
this.cachedRuntimeInfo = null;
|
private resolveServiceTier(requested: string | null): string | null {
|
||||||
|
// Until the model list arrives there is nothing to check the request against.
|
||||||
|
if (this.speedModels.length === 0 || requested === "default") return requested;
|
||||||
|
const tiers = this.currentServiceTiers();
|
||||||
|
// `fast` names whichever tier the model calls Fast.
|
||||||
|
if (requested === "fast" && !tiers.some((tier) => tier.id === "fast")) {
|
||||||
|
return tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
|
||||||
|
}
|
||||||
|
return tiers.some((tier) => tier.id === requested) ? requested : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private rememberPlanResult(item: ToolCallTimelineItem): void {
|
private rememberPlanResult(item: ToolCallTimelineItem): void {
|
||||||
@@ -3962,7 +3956,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
const planText =
|
const planText =
|
||||||
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
|
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
|
||||||
|
|
||||||
this.applyFeatureValue("plan_mode", false);
|
this.setPlanMode(false);
|
||||||
|
// The user left plan mode; their options follow so controls and reopened sessions agree.
|
||||||
|
this.emitEvent({
|
||||||
|
type: "options_changed",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
patch: { plan_mode: false },
|
||||||
|
});
|
||||||
|
|
||||||
return buildCodexPlanImplementationPrompt(planText);
|
return buildCodexPlanImplementationPrompt(planText);
|
||||||
}
|
}
|
||||||
@@ -4448,9 +4448,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||||
hasCodexConfig: turnStart.hasCodexConfig,
|
hasCodexConfig: turnStart.hasCodexConfig,
|
||||||
});
|
});
|
||||||
// The interrupted turn's commands end before this turn begins, so none of them
|
|
||||||
// outlives it into the new turn.
|
|
||||||
await this.waitForInterruptedTurnCleanup();
|
|
||||||
if (pendingStart.cancelRequested) {
|
if (pendingStart.cancelRequested) {
|
||||||
throw new Error("Codex turn start was interrupted before reaching Codex");
|
throw new Error("Codex turn start was interrupted before reaching Codex");
|
||||||
}
|
}
|
||||||
@@ -4665,9 +4662,27 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
async setModel(modelId: string | null): Promise<void> {
|
async setModel(modelId: string | null): Promise<void> {
|
||||||
this.config.model = modelId ?? undefined;
|
this.config.model = modelId ?? undefined;
|
||||||
this.reconcileServiceTier();
|
|
||||||
this.refreshResolvedCollaborationMode();
|
this.refreshResolvedCollaborationMode();
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
|
this.dropUnavailableServiceTier();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A speed the new model does not offer turns into Normal and is reported, so choosing a model
|
||||||
|
* that does offer it again does not bring the paid tier back. It is reported as Normal rather
|
||||||
|
* than removed so a configured default cannot fill the old speed back in.
|
||||||
|
*/
|
||||||
|
private dropUnavailableServiceTier(): void {
|
||||||
|
const requested = this.requestedServiceTier;
|
||||||
|
if (requested === null || this.speedModels.length === 0) return;
|
||||||
|
if (this.isServiceTierAvailable(requested)) return;
|
||||||
|
this.requestedServiceTier = "default";
|
||||||
|
this.providerOptions = { ...this.providerOptions, service_tier: "default" };
|
||||||
|
this.emitEvent({
|
||||||
|
type: "options_changed",
|
||||||
|
provider: CODEX_PROVIDER,
|
||||||
|
patch: { service_tier: "default" },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
|
async setThinkingOption(thinkingOptionId: string | null): Promise<void | AgentProviderNotice> {
|
||||||
@@ -4679,35 +4694,53 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
/** Applies only the options the patch sets; the rest of the running options stay as they are. */
|
||||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
|
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||||
if (featureId === "fast_mode") {
|
// The approval policy and sandbox are sent when the thread starts, not with each turn.
|
||||||
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
|
assertChangesAreLive({
|
||||||
if (value && !fast)
|
provider: "codex",
|
||||||
|
patch: options,
|
||||||
|
current: this.providerOptions,
|
||||||
|
live: ["service_tier", "plan_mode"],
|
||||||
|
});
|
||||||
|
const valid =
|
||||||
|
validateProviderOptions(
|
||||||
|
"codex",
|
||||||
|
CodexProviderOptionsSchema,
|
||||||
|
applyProviderOptionsPatch({ options: this.providerOptions, patch: options }),
|
||||||
|
) ?? {};
|
||||||
|
const requested = "service_tier" in options ? readCodexServiceTier(options) : null;
|
||||||
|
let accepted = this.requestedServiceTier;
|
||||||
|
if ("service_tier" in options) {
|
||||||
|
if (requested !== accepted && !this.isServiceTierAvailable(requested)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
|
`Codex speed '${String(requested)}' is not available for model '${this.config.model ?? "default"}'`,
|
||||||
);
|
|
||||||
return this.setFeature("service_tier", value ? fast!.id : "default");
|
|
||||||
}
|
|
||||||
if (featureId === "service_tier") {
|
|
||||||
if (
|
|
||||||
typeof value !== "string" ||
|
|
||||||
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value))
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.serviceTier = value;
|
// The session keeps the catalog's id for the tier, so a later model switch sees what runs.
|
||||||
this.reconcileServiceTier();
|
accepted = requested && (this.resolveServiceTier(requested) ?? requested);
|
||||||
|
}
|
||||||
|
this.providerOptions = { ...valid, ...(accepted ? { service_tier: accepted } : {}) };
|
||||||
|
this.requestedServiceTier = accepted || null;
|
||||||
|
if ("plan_mode" in options) this.setPlanMode(options.plan_mode === true);
|
||||||
this.cachedRuntimeInfo = null;
|
this.cachedRuntimeInfo = null;
|
||||||
return;
|
// The catalog may name the tier differently from the request (`fast` is the Fast tier's id).
|
||||||
|
const tier = this.serviceTier;
|
||||||
|
return tier !== null && requested !== null && tier !== requested ? { service_tier: tier } : {};
|
||||||
}
|
}
|
||||||
if (featureId === "plan_mode") {
|
|
||||||
this.applyFeatureValue("plan_mode", Boolean(value));
|
private isServiceTierAvailable(tier: string | null): boolean {
|
||||||
return;
|
if (tier === null || tier === "default") return true;
|
||||||
|
const tiers = this.currentServiceTiers();
|
||||||
|
const namesFast = tier === "fast" && tiers.some((entry) => entry.name.toLowerCase() === "fast");
|
||||||
|
return namesFast || tiers.some((entry) => entry.id === tier);
|
||||||
}
|
}
|
||||||
throw new Error(`Unknown Codex feature: ${featureId}`);
|
|
||||||
|
private setPlanMode(enabled: boolean): void {
|
||||||
|
if (this.planModeEnabled === enabled) return;
|
||||||
|
this.planModeEnabled = enabled;
|
||||||
|
this.refreshResolvedCollaborationMode();
|
||||||
|
this.cachedRuntimeInfo = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingPermissions(): AgentPermissionRequest[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
@@ -5056,27 +5089,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||||
}
|
}
|
||||||
const client = this.client;
|
try {
|
||||||
const threadId = this.currentThreadId;
|
await this.client.request(
|
||||||
const interruptRequest = client.request(
|
|
||||||
"turn/interrupt",
|
"turn/interrupt",
|
||||||
{
|
{
|
||||||
threadId,
|
threadId: this.currentThreadId,
|
||||||
turnId,
|
turnId,
|
||||||
},
|
},
|
||||||
INTERRUPT_TIMEOUT_MS,
|
INTERRUPT_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
// Once Codex accepts the interrupt, end the commands this turn started. Registered
|
|
||||||
// before the await, so a turn start racing this interrupt waits for it. An already-idle
|
|
||||||
// turn was not interrupted, and what it left running stays.
|
|
||||||
this.trackInterruptedTurnCleanup(
|
|
||||||
interruptRequest.then(
|
|
||||||
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
|
|
||||||
() => undefined,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
await interruptRequest;
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCodexAlreadyIdleInterrupt(error)) {
|
if (!isCodexAlreadyIdleInterrupt(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -5117,137 +5138,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return !this.client.hasPendingRequests();
|
return !this.client.hasPendingRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember which turn started a command, so an interrupt ends that turn's commands and
|
|
||||||
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
|
|
||||||
// A command that starts after its turn's interrupt was accepted is ended as soon as it
|
|
||||||
// is seen.
|
|
||||||
private recordTurnTerminalProcess(
|
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
|
||||||
): void {
|
|
||||||
const processId = parsed.item.processId;
|
|
||||||
if (
|
|
||||||
parsed.source !== "item" ||
|
|
||||||
!parsed.turnId ||
|
|
||||||
!parsed.threadId ||
|
|
||||||
parsed.threadId !== this.currentThreadId ||
|
|
||||||
typeof processId !== "string" ||
|
|
||||||
!processId ||
|
|
||||||
normalizeCodexThreadItemType(
|
|
||||||
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
|
||||||
) !== "commandExecution"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
|
|
||||||
if (parsed.kind === "item_completed") {
|
|
||||||
// Only a completed command has exited. A failed one can still be running:
|
|
||||||
// Codex reports an interrupted turn's live command as failed.
|
|
||||||
if (parsed.item.status === "completed") record?.processIds.delete(processId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
|
|
||||||
current.processIds.add(processId);
|
|
||||||
if (current.interruptAccepted && this.client) {
|
|
||||||
this.trackInterruptedTurnCleanup(
|
|
||||||
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An accepted interrupt needs a record even before any of its turn's commands is seen:
|
|
||||||
// an `item/started` can still be queued behind an earlier notification's conversion.
|
|
||||||
private getTurnTerminalRecord(turnId: string) {
|
|
||||||
const existing = this.terminalProcessIdsByTurnId.get(turnId);
|
|
||||||
if (existing) return existing;
|
|
||||||
const record = {
|
|
||||||
processIds: new Set<string>(),
|
|
||||||
terminatedProcessIds: new Set<string>(),
|
|
||||||
interruptAccepted: false,
|
|
||||||
};
|
|
||||||
this.terminalProcessIdsByTurnId.set(turnId, record);
|
|
||||||
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
|
|
||||||
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
|
|
||||||
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A later sweep joins the pending one, so a turn start waits for both.
|
|
||||||
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
|
|
||||||
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
|
|
||||||
.then(() => undefined)
|
|
||||||
.finally(() => {
|
|
||||||
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
|
|
||||||
});
|
|
||||||
this.interruptedTurnCleanup = tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminate each process the interrupted turn started, by its own id, once each.
|
|
||||||
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
|
|
||||||
// an earlier turn's included. A process already gone answers `terminated: false`. Never
|
|
||||||
// rejects; failures are logged.
|
|
||||||
private async endInterruptedTurnProcesses(
|
|
||||||
client: CodexAppServerClient,
|
|
||||||
threadId: string | null,
|
|
||||||
turnId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!threadId) return;
|
|
||||||
const record = this.getTurnTerminalRecord(turnId);
|
|
||||||
record.interruptAccepted = true;
|
|
||||||
const processIds = [...record.processIds].filter(
|
|
||||||
(processId) => !record.terminatedProcessIds.has(processId),
|
|
||||||
);
|
|
||||||
for (const processId of processIds) record.terminatedProcessIds.add(processId);
|
|
||||||
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
|
|
||||||
await Promise.all(
|
|
||||||
processIds.map(async (processId) => {
|
|
||||||
try {
|
|
||||||
const response = await client.request(
|
|
||||||
"thread/backgroundTerminals/terminate",
|
|
||||||
{ threadId, processId },
|
|
||||||
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
this.logger.info(
|
|
||||||
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
|
|
||||||
"provider.codex.interrupt.terminal_terminated",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
{ ...logContext, processId, err: error },
|
|
||||||
"provider.codex.interrupt.terminal_terminate_failed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new turn starts only after every pending sweep of interrupted turns' commands has
|
|
||||||
// ended, or after the bound, whichever is first.
|
|
||||||
private async waitForInterruptedTurnCleanup(): Promise<void> {
|
|
||||||
if (!this.interruptedTurnCleanup) return;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const timedOut = await Promise.race([
|
|
||||||
(async () => {
|
|
||||||
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
|
|
||||||
return false;
|
|
||||||
})(),
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (timedOut) {
|
|
||||||
this.logger.warn(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
|
|
||||||
},
|
|
||||||
"provider.codex.interrupt.terminal_cleanup_timed_out",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.clearPendingPermissions();
|
this.clearPendingPermissions();
|
||||||
@@ -5503,7 +5393,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async applyDefaultModelAndThinking(): Promise<string> {
|
private async applyDefaultModelAndThinking(): Promise<string> {
|
||||||
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
|
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
|
||||||
this.config.model = model;
|
this.config.model = model;
|
||||||
this.reconcileServiceTier();
|
|
||||||
this.config.thinkingOptionId = thinkingOptionId;
|
this.config.thinkingOptionId = thinkingOptionId;
|
||||||
return model;
|
return model;
|
||||||
}
|
}
|
||||||
@@ -5570,7 +5459,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
|
|
||||||
private buildCodexInnerConfig(): Record<string, unknown> | null {
|
private buildCodexInnerConfig(): Record<string, unknown> | null {
|
||||||
const innerConfig: Record<string, unknown> = {};
|
const innerConfig: Record<string, unknown> = {};
|
||||||
Object.assign(innerConfig, this.providerOptions);
|
const { service_tier: _tier, plan_mode: _plan, ...codexConfig } = this.providerOptions;
|
||||||
|
Object.assign(innerConfig, codexConfig);
|
||||||
if (this.deps.customCodexConfig) {
|
if (this.deps.customCodexConfig) {
|
||||||
Object.assign(innerConfig, this.deps.customCodexConfig);
|
Object.assign(innerConfig, this.deps.customCodexConfig);
|
||||||
}
|
}
|
||||||
@@ -6372,11 +6262,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.completePendingRootCompactions();
|
this.completePendingRootCompactions();
|
||||||
// What a turn that was not interrupted leaves running, it left on purpose; no later
|
|
||||||
// interrupt may end it.
|
|
||||||
if (parsed.status !== "interrupted" && this.currentTurnId) {
|
|
||||||
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
|
|
||||||
}
|
|
||||||
if (parsed.status === "failed") {
|
if (parsed.status === "failed") {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
@@ -6781,7 +6666,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemCompletedNotification(
|
private async handleItemCompletedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.recordTurnTerminalProcess(parsed);
|
|
||||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||||
// channel, but accept a legacy-only child announcement so it can establish
|
// channel, but accept a legacy-only child announcement so it can establish
|
||||||
@@ -6944,7 +6828,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemStartedNotification(
|
private async handleItemStartedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.recordTurnTerminalProcess(parsed);
|
|
||||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
|
import type { AgentOptionControl, AgentOptionToggleControl } from "../agent-sdk-types.js";
|
||||||
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -9,51 +9,47 @@ export const CodexServiceTierSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
|
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
|
||||||
|
|
||||||
function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] {
|
function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[] {
|
||||||
if (tiers.length === 0) return [];
|
if (tiers.length === 0) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
id: "service_tier",
|
key: "service_tier",
|
||||||
label: "Speed",
|
label: "Speed",
|
||||||
description: "Choose processing speed. Faster tiers increase usage.",
|
description: "Choose processing speed. Faster tiers increase usage.",
|
||||||
tooltip: "Select speed",
|
tooltip: "Select speed",
|
||||||
icon: "zap",
|
icon: "zap",
|
||||||
desktopTrigger: "icon",
|
desktopTrigger: "icon",
|
||||||
value,
|
defaultValue: "default",
|
||||||
options: [
|
choices: [
|
||||||
{ id: "default", label: "Normal", isDefault: true },
|
{ value: "default", label: "Normal" },
|
||||||
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
|
...tiers.map((tier) => ({ value: tier.id, label: tier.name })),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = {
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: "plan_mode",
|
key: "plan_mode",
|
||||||
label: "Plan",
|
label: "Plan",
|
||||||
description: "Switch Codex into planning-only collaboration mode",
|
description: "Switch Codex into planning-only collaboration mode",
|
||||||
tooltip: "Toggle plan mode",
|
tooltip: "Toggle plan mode",
|
||||||
icon: "list-todo",
|
icon: "list-todo",
|
||||||
};
|
};
|
||||||
|
|
||||||
export function buildCodexFeatures(input: {
|
export function buildCodexControls(input: {
|
||||||
serviceTiers: CodexServiceTier[];
|
serviceTiers: CodexServiceTier[];
|
||||||
serviceTier: string;
|
|
||||||
planModeEnabled: boolean;
|
|
||||||
planModeAvailable?: boolean;
|
planModeAvailable?: boolean;
|
||||||
}): AgentFeature[] {
|
}): AgentOptionControl[] {
|
||||||
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
|
const controls = buildCodexSpeedControl(input.serviceTiers);
|
||||||
if (input.planModeAvailable !== false) {
|
if (input.planModeAvailable !== false) {
|
||||||
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled });
|
controls.push({ ...CODEX_PLAN_MODE_CONTROL });
|
||||||
}
|
}
|
||||||
return features;
|
return controls;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readCodexServiceTier(values: Record<string, unknown> | undefined): string | null {
|
export function readCodexServiceTier(options: Record<string, unknown> | undefined): string | null {
|
||||||
const tier = values?.service_tier;
|
const tier = options?.service_tier;
|
||||||
if (typeof tier === "string") return tier;
|
return typeof tier === "string" ? tier : null;
|
||||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
|
||||||
return values?.fast_mode ? "fast" : null;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { AgentSession } from "../agent-sdk-types.js";
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
|
||||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
|
||||||
import {
|
|
||||||
createFakeCodexAppServer,
|
|
||||||
type FakeCodexAppServer,
|
|
||||||
waitForNextEvent,
|
|
||||||
waitForTimelineToolCall,
|
|
||||||
} from "./codex/test-utils/fake-app-server.js";
|
|
||||||
|
|
||||||
const THREAD = "thread-1";
|
|
||||||
const TERMINATE = "thread/backgroundTerminals/terminate";
|
|
||||||
|
|
||||||
type Handler = (params: unknown) => unknown;
|
|
||||||
|
|
||||||
function deferred<T = void>() {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((resolvePromise) => {
|
|
||||||
resolve = resolvePromise;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
function paramsOf(value: unknown): Record<string, unknown> {
|
|
||||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessions: AgentSession[] = [];
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const session of sessions) await session.close().catch(() => undefined);
|
|
||||||
sessions = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
|
|
||||||
* turn completion, then the response. `terminate` answers per process.
|
|
||||||
*/
|
|
||||||
function createHarness(
|
|
||||||
options: {
|
|
||||||
terminate?: (processId: string) => unknown;
|
|
||||||
interruptResponse?: (completeTurn: () => void) => unknown;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const handlers: Record<string, Handler> = {
|
|
||||||
"turn/interrupt": () => {
|
|
||||||
const completeTurn = () =>
|
|
||||||
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
|
|
||||||
if (options.interruptResponse) return options.interruptResponse(completeTurn);
|
|
||||||
completeTurn();
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
[TERMINATE]: (params) =>
|
|
||||||
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
|
|
||||||
};
|
|
||||||
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
|
|
||||||
const session = new CodexAppServerAgentSession(
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
cwd: "/workspace/project",
|
|
||||||
modeId: "auto",
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
createTestLogger(),
|
|
||||||
async () => appServer.child,
|
|
||||||
);
|
|
||||||
sessions.push(session);
|
|
||||||
|
|
||||||
function requests(method: string): Record<string, unknown>[] {
|
|
||||||
return appServer
|
|
||||||
.requests()
|
|
||||||
.filter((message) => message.method === method && "id" in message)
|
|
||||||
.map((message) => paramsOf(message.params));
|
|
||||||
}
|
|
||||||
|
|
||||||
function terminated(): string[] {
|
|
||||||
return requests(TERMINATE)
|
|
||||||
.map((params) => String(params.processId))
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starts a turn and lets Codex identify it as `nativeTurnId`.
|
|
||||||
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
|
|
||||||
const before = requests("turn/start").length;
|
|
||||||
const started = session.startTurn(prompt);
|
|
||||||
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
const turnStarted = waitForNextEvent(session, "turn_started");
|
|
||||||
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
|
|
||||||
await started;
|
|
||||||
await turnStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
|
|
||||||
const itemId = `exec-${params.processId}`;
|
|
||||||
const shown = waitForTimelineToolCall(session, itemId);
|
|
||||||
appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: params.turnId,
|
|
||||||
itemId,
|
|
||||||
processId: params.processId,
|
|
||||||
command: `sleep ${params.processId}`,
|
|
||||||
});
|
|
||||||
await shown;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function completesTurn(): Promise<void> {
|
|
||||||
const completed = waitForNextEvent(session, "turn_completed");
|
|
||||||
appServer.completeTurn({ threadId: THREAD });
|
|
||||||
await completed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interrupts the running turn and waits for the session to report it canceled, as a
|
|
||||||
// caller does before it starts the next turn: Codex notifications are handled in order
|
|
||||||
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
|
|
||||||
async function interrupts(): Promise<void> {
|
|
||||||
const canceled = waitForNextEvent(session, "turn_canceled");
|
|
||||||
await session.interrupt();
|
|
||||||
await canceled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
appServer,
|
|
||||||
session,
|
|
||||||
requests,
|
|
||||||
terminated,
|
|
||||||
startTurn,
|
|
||||||
runsCommand,
|
|
||||||
completesTurn,
|
|
||||||
interrupts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turn A leaves a server running on purpose and completes. Turn B starts two
|
|
||||||
// commands, and a subagent on its own thread starts a third, then B is interrupted.
|
|
||||||
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
|
|
||||||
await harness.startTurn("start a server", "native-A");
|
|
||||||
await harness.runsCommand({ turnId: "native-A", processId: "101" });
|
|
||||||
await harness.completesTurn();
|
|
||||||
|
|
||||||
await harness.startTurn("run two commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
// A subagent thread's command shows no row here; the next command's row shows it
|
|
||||||
// was read, because notifications are read in order.
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: "child-thread",
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "exec-209",
|
|
||||||
processId: "209",
|
|
||||||
command: "sleep 209",
|
|
||||||
});
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
|
||||||
await harness.interrupts();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("an interrupted Codex turn's commands end before the next turn", () => {
|
|
||||||
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
|
|
||||||
const harness = createHarness();
|
|
||||||
await interruptTurnB(harness);
|
|
||||||
await harness.startTurn("correction", "native-C");
|
|
||||||
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
|
|
||||||
// `clean` would end every terminal on the thread, an earlier turn's included.
|
|
||||||
expect(
|
|
||||||
harness.appServer
|
|
||||||
.requests()
|
|
||||||
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
|
|
||||||
.map((message) => message.method),
|
|
||||||
).toEqual([TERMINATE, TERMINATE]);
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
|
|
||||||
const acknowledge = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
// Codex ends B, starts a turn of its own (a goal continuation), and only then
|
|
||||||
// answers the interrupt.
|
|
||||||
interruptResponse: (completeTurn) => {
|
|
||||||
completeTurn();
|
|
||||||
return acknowledge.promise.then(() => ({}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await harness.startTurn("run two commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
|
||||||
const interrupting = harness.session.interrupt();
|
|
||||||
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
|
|
||||||
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
|
|
||||||
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
|
|
||||||
await autonomousTurn;
|
|
||||||
await harness.runsCommand({ turnId: "native-C", processId: "301" });
|
|
||||||
|
|
||||||
acknowledge.resolve();
|
|
||||||
await interrupting;
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sends the next turn/start only after the terminates are answered", async () => {
|
|
||||||
const answer = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
terminate: () => answer.promise.then(() => ({ terminated: true })),
|
|
||||||
});
|
|
||||||
await interruptTurnB(harness);
|
|
||||||
const next = harness.startTurn("correction", "native-C");
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(2);
|
|
||||||
|
|
||||||
answer.resolve();
|
|
||||||
await next;
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(["initial", "late"] as const)(
|
|
||||||
"tracks both cleanup sweeps when the %s answer arrives first",
|
|
||||||
async (answeredFirst) => {
|
|
||||||
const first = deferred();
|
|
||||||
const second = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
terminate: (processId) =>
|
|
||||||
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
|
|
||||||
});
|
|
||||||
await harness.startTurn("run commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
await harness.interrupts();
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
|
|
||||||
const next = harness.startTurn("correction", "native-C");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
|
||||||
// The next turn is already waiting when another process is announced.
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "late-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "duplicate-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
(answeredFirst === "initial" ? first : second).resolve();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
|
||||||
(answeredFirst === "initial" ? second : first).resolve();
|
|
||||||
await next;
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
// Accepted state survives the completed sweep and a newer foreground turn.
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "late-203",
|
|
||||||
processId: "203",
|
|
||||||
command: "sleep 203",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
|
|
||||||
const first = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
terminate: (processId) =>
|
|
||||||
processId === "201"
|
|
||||||
? first.promise.then(() => ({ terminated: true }))
|
|
||||||
: { terminated: true },
|
|
||||||
});
|
|
||||||
await harness.startTurn("run commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
await harness.interrupts();
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "late-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
const next = harness.startTurn("correction", "native-C");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
|
||||||
first.resolve();
|
|
||||||
await next;
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forgets completed commands but retains failed commands at interrupt", async () => {
|
|
||||||
const harness = createHarness();
|
|
||||||
await harness.startTurn("run commands", "native-B");
|
|
||||||
for (const processId of ["201", "202"]) {
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId });
|
|
||||||
harness.appServer.child.stdout.write(
|
|
||||||
JSON.stringify({
|
|
||||||
method: "item/completed",
|
|
||||||
params: {
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
item: {
|
|
||||||
id: `exec-${processId}`,
|
|
||||||
type: "commandExecution",
|
|
||||||
processId,
|
|
||||||
command: `sleep ${processId}`,
|
|
||||||
status: processId === "201" ? "completed" : "failed",
|
|
||||||
exitCode: processId === "201" ? 0 : null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) + "\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// A following command's row is a barrier for both completion handlers.
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "203" });
|
|
||||||
await harness.interrupts();
|
|
||||||
await harness.startTurn("correction", "native-C");
|
|
||||||
expect(harness.terminated()).toEqual(["202", "203"]);
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("retains accepted interrupts for only the latest sixteen turns", async () => {
|
|
||||||
const harness = createHarness();
|
|
||||||
for (let index = 0; index < 17; index++) {
|
|
||||||
await harness.startTurn("run a command", `native-${index}`);
|
|
||||||
await harness.interrupts();
|
|
||||||
}
|
|
||||||
await harness.startTurn("correction", "native-current");
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-0",
|
|
||||||
itemId: "evicted-201",
|
|
||||||
processId: "201",
|
|
||||||
command: "sleep 201",
|
|
||||||
});
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-16",
|
|
||||||
itemId: "retained-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
await harness.runsCommand({ turnId: "native-current", processId: "301" });
|
|
||||||
expect(harness.terminated()).toEqual(["202"]);
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
|
|
||||||
const harness = createHarness({
|
|
||||||
// 201 is refused while the next turn waits; 202 is never answered.
|
|
||||||
terminate: (processId) =>
|
|
||||||
processId === "201"
|
|
||||||
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
|
|
||||||
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
|
|
||||||
}))
|
|
||||||
: new Promise(() => {}),
|
|
||||||
});
|
|
||||||
await interruptTurnB(harness);
|
|
||||||
const startedAt = Date.now();
|
|
||||||
await harness.startTurn("correction", "native-C");
|
|
||||||
|
|
||||||
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
}, 15_000);
|
|
||||||
});
|
|
||||||
@@ -37,6 +37,9 @@ const NetworkPolicySchema = z
|
|||||||
// Codex config reference, maintained against Codex CLI 0.143+.
|
// Codex config reference, maintained against Codex CLI 0.143+.
|
||||||
export const CodexProviderOptionsSchema = z
|
export const CodexProviderOptionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
// Composer controls, not Codex config: the provider reads them and keeps them out of `config`.
|
||||||
|
service_tier: z.string().optional(),
|
||||||
|
plan_mode: z.boolean().optional(),
|
||||||
approval_policy: ApprovalPolicySchema.optional(),
|
approval_policy: ApprovalPolicySchema.optional(),
|
||||||
sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(),
|
sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(),
|
||||||
sandbox_workspace_write: z
|
sandbox_workspace_write: z
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ describe("CursorACPAgentClient model discovery", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("exposes Cursor fast mode through provider features", async () => {
|
test("exposes Cursor fast mode through provider controls", async () => {
|
||||||
const client = new TestCursorACPAgentClient({
|
const client = new TestCursorACPAgentClient({
|
||||||
sessionId: "session-1",
|
sessionId: "session-1",
|
||||||
models: null,
|
models: null,
|
||||||
@@ -163,43 +163,29 @@ describe("CursorACPAgentClient model discovery", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures({
|
client.listControls({
|
||||||
provider: "acp",
|
provider: "acp",
|
||||||
cwd: "/tmp/cursor",
|
cwd: "/tmp/cursor",
|
||||||
}),
|
}),
|
||||||
).resolves.toEqual([
|
).resolves.toEqual([
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: "auto_accept",
|
key: "auto_accept",
|
||||||
label: "Auto Accept",
|
label: "Auto Accept",
|
||||||
description: "Automatically approves ACP permission prompts.",
|
description: "Automatically approves ACP permission prompts.",
|
||||||
tooltip: "Auto accept permission prompts",
|
tooltip: "Auto accept permission prompts",
|
||||||
icon: "shield-check",
|
icon: "shield-check",
|
||||||
value: false,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "select",
|
type: "select",
|
||||||
id: CURSOR_FAST_FEATURE_OPTION.id,
|
key: CURSOR_FAST_FEATURE_OPTION.id,
|
||||||
label: "Fast",
|
label: "Fast",
|
||||||
description: "Cursor fast mode",
|
description: "Cursor fast mode",
|
||||||
tooltip: "Select Cursor fast mode",
|
tooltip: "Select Cursor fast mode",
|
||||||
icon: "zap",
|
icon: "zap",
|
||||||
value: "false",
|
choices: [
|
||||||
options: [
|
{ value: "false", label: "Off" },
|
||||||
{
|
{ value: "true", label: "Fast" },
|
||||||
id: "false",
|
|
||||||
label: "Off",
|
|
||||||
isDefault: true,
|
|
||||||
description: undefined,
|
|
||||||
metadata: undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "true",
|
|
||||||
label: "Fast",
|
|
||||||
isDefault: false,
|
|
||||||
description: undefined,
|
|
||||||
metadata: undefined,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -228,7 +214,9 @@ describe("CursorACPAgentClient session start", () => {
|
|||||||
session: {
|
session: {
|
||||||
currentModelId: string;
|
currentModelId: string;
|
||||||
configOptions: SessionConfigOption[];
|
configOptions: SessionConfigOption[];
|
||||||
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>;
|
setSessionConfigOption?: (request: {
|
||||||
|
value: string;
|
||||||
|
}) => Promise<{ configOptions: SessionConfigOption[] }>;
|
||||||
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||||
logger = createTestLogger(),
|
logger = createTestLogger(),
|
||||||
): ACPAgentSession {
|
): ACPAgentSession {
|
||||||
@@ -277,7 +265,7 @@ describe("CursorACPAgentClient session start", () => {
|
|||||||
// still stored after the draft moved to a model that has no fast variant.
|
// still stored after the draft moved to a model that has no fast variant.
|
||||||
const warn = captureWarnings();
|
const warn = captureWarnings();
|
||||||
const session = createCursorSession(
|
const session = createCursorSession(
|
||||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||||
{ currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
{ currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||||
warn.logger,
|
warn.logger,
|
||||||
);
|
);
|
||||||
@@ -295,7 +283,7 @@ describe("CursorACPAgentClient session start", () => {
|
|||||||
// still listed after the model moves to one that has no fast variant.
|
// still listed after the model moves to one that has no fast variant.
|
||||||
const warn = captureWarnings();
|
const warn = captureWarnings();
|
||||||
const session = createCursorSession(
|
const session = createCursorSession(
|
||||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||||
{
|
{
|
||||||
currentModelId: "composer-2.5",
|
currentModelId: "composer-2.5",
|
||||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||||
@@ -319,7 +307,7 @@ describe("CursorACPAgentClient session start", () => {
|
|||||||
|
|
||||||
test("still fails session start when a stored feature write fails for another reason", async () => {
|
test("still fails session start when a stored feature write fails for another reason", async () => {
|
||||||
const session = createCursorSession(
|
const session = createCursorSession(
|
||||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||||
{
|
{
|
||||||
currentModelId: "composer-2.5",
|
currentModelId: "composer-2.5",
|
||||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||||
@@ -334,16 +322,71 @@ describe("CursorACPAgentClient session start", () => {
|
|||||||
const session = createCursorSession({ model: "kimi-k3" });
|
const session = createCursorSession({ model: "kimi-k3" });
|
||||||
await session.initializeNewSession();
|
await session.initializeNewSession();
|
||||||
|
|
||||||
await expect(session.setFeature(CURSOR_FAST_FEATURE_OPTION.id, "true")).rejects.toThrow(
|
await expect(session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" })).rejects.toThrow(
|
||||||
"acp does not expose ACP feature 'fast'",
|
"acp does not expose ACP feature 'fast'",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("removing auto_accept returns the session to asking for permission", async () => {
|
||||||
|
const session = createCursorSession({ model: "composer-2.5" });
|
||||||
|
await session.initializeNewSession();
|
||||||
|
const request = {
|
||||||
|
sessionId: "session-1",
|
||||||
|
toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" },
|
||||||
|
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
await session.applyOptions({ auto_accept: true });
|
||||||
|
await expect(session.requestPermission({ ...request })).resolves.toEqual({
|
||||||
|
outcome: { outcome: "selected", optionId: "allow-always" },
|
||||||
|
});
|
||||||
|
await expect(session.applyOptions({ auto_accept: null })).resolves.toEqual({});
|
||||||
|
|
||||||
|
void session.requestPermission({ ...request });
|
||||||
|
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rejected patch leaves auto_accept as it was", async () => {
|
||||||
|
const session = createCursorSession({ model: "kimi-k3" });
|
||||||
|
await session.initializeNewSession();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
session.applyOptions({ auto_accept: true, [CURSOR_FAST_FEATURE_OPTION.id]: "true" }),
|
||||||
|
).rejects.toThrow("acp does not expose ACP feature 'fast'");
|
||||||
|
|
||||||
|
void session.requestPermission({
|
||||||
|
sessionId: "session-1",
|
||||||
|
toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" },
|
||||||
|
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("applies only the selects the patch sets", async () => {
|
||||||
|
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
|
||||||
|
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
|
||||||
|
}));
|
||||||
|
const session = createCursorSession(
|
||||||
|
{ model: "composer-2.5" },
|
||||||
|
{
|
||||||
|
currentModelId: "composer-2.5",
|
||||||
|
configOptions: [fastConfigOption("false")],
|
||||||
|
setSessionConfigOption,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await session.initializeNewSession();
|
||||||
|
|
||||||
|
await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" });
|
||||||
|
await session.applyOptions({});
|
||||||
|
|
||||||
|
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual(["true"]);
|
||||||
|
});
|
||||||
|
|
||||||
test("still fails session start when the provider rejects a write it should accept", async () => {
|
test("still fails session start when the provider rejects a write it should accept", async () => {
|
||||||
// No model switch, so the session's options came straight from session/new and the
|
// No model switch, so the session's options came straight from session/new and the
|
||||||
// provider disagreeing about a feature it just advertised is a real failure.
|
// provider disagreeing about a feature it just advertised is a real failure.
|
||||||
const session = createCursorSession(
|
const session = createCursorSession(
|
||||||
{ model: "composer-2.5", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||||
{
|
{
|
||||||
currentModelId: "composer-2.5",
|
currentModelId: "composer-2.5",
|
||||||
configOptions: [fastConfigOption("false")],
|
configOptions: [fastConfigOption("false")],
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
provider: "mock",
|
provider: "mock",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "ten-second-stream",
|
model: "ten-second-stream",
|
||||||
featureValues: { mockPromptRejections: 1 },
|
providerOptions: { mockPromptRejections: 1 },
|
||||||
});
|
});
|
||||||
|
|
||||||
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
|
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
|
||||||
@@ -95,7 +95,7 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
provider: "mock",
|
provider: "mock",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "ten-second-stream",
|
model: "ten-second-stream",
|
||||||
featureValues: {
|
providerOptions: {
|
||||||
mockStreamingAssistantResponse: response,
|
mockStreamingAssistantResponse: response,
|
||||||
mockStreamingAssistantIntervalMs: 20,
|
mockStreamingAssistantIntervalMs: 20,
|
||||||
},
|
},
|
||||||
@@ -467,7 +467,7 @@ describe("MockLoadTestAgentClient", () => {
|
|||||||
provider: "mock",
|
provider: "mock",
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
model: "e2e-fast-stream",
|
model: "e2e-fast-stream",
|
||||||
featureValues: { mockAssistantResponses: ["First response", ""] },
|
providerOptions: { mockAssistantResponses: ["First response", ""] },
|
||||||
});
|
});
|
||||||
await session.run("Emit a synthetic turn failure.");
|
await session.run("Emit a synthetic turn failure.");
|
||||||
expect((await session.run("First prompt")).finalText).toBe("First response");
|
expect((await session.run("First prompt")).finalText).toBe("First response");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
|
|||||||
import type {
|
import type {
|
||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentMode,
|
AgentMode,
|
||||||
AgentModelDefinition,
|
AgentModelDefinition,
|
||||||
@@ -787,7 +787,7 @@ function getConfiguredAssistantResponses(value: unknown): string[] {
|
|||||||
export class MockLoadTestAgentSession implements AgentSession {
|
export class MockLoadTestAgentSession implements AgentSession {
|
||||||
readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID;
|
readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID;
|
||||||
readonly capabilities = CAPABILITIES;
|
readonly capabilities = CAPABILITIES;
|
||||||
readonly features: AgentFeature[] = [];
|
readonly controls: AgentOptionControl[] = [];
|
||||||
readonly id: string;
|
readonly id: string;
|
||||||
private readonly usageSessionKey = randomUUID();
|
private readonly usageSessionKey = randomUUID();
|
||||||
|
|
||||||
@@ -812,18 +812,18 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
|
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
|
||||||
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
||||||
this.assistantResponse =
|
this.assistantResponse =
|
||||||
typeof options.config.featureValues?.mockAssistantResponse === "string"
|
typeof options.config.providerOptions?.mockAssistantResponse === "string"
|
||||||
? options.config.featureValues.mockAssistantResponse
|
? options.config.providerOptions.mockAssistantResponse
|
||||||
: null;
|
: null;
|
||||||
this.assistantResponses = getConfiguredAssistantResponses(
|
this.assistantResponses = getConfiguredAssistantResponses(
|
||||||
options.config.featureValues?.mockAssistantResponses,
|
options.config.providerOptions?.mockAssistantResponses,
|
||||||
);
|
);
|
||||||
this.streamingAssistantResponse =
|
this.streamingAssistantResponse =
|
||||||
typeof options.config.featureValues?.mockStreamingAssistantResponse === "string"
|
typeof options.config.providerOptions?.mockStreamingAssistantResponse === "string"
|
||||||
? options.config.featureValues.mockStreamingAssistantResponse
|
? options.config.providerOptions.mockStreamingAssistantResponse
|
||||||
: null;
|
: null;
|
||||||
const requestedStreamingInterval =
|
const requestedStreamingInterval =
|
||||||
options.config.featureValues?.mockStreamingAssistantIntervalMs;
|
options.config.providerOptions?.mockStreamingAssistantIntervalMs;
|
||||||
this.streamingAssistantIntervalMs =
|
this.streamingAssistantIntervalMs =
|
||||||
typeof requestedStreamingInterval === "number" &&
|
typeof requestedStreamingInterval === "number" &&
|
||||||
Number.isFinite(requestedStreamingInterval) &&
|
Number.isFinite(requestedStreamingInterval) &&
|
||||||
@@ -831,10 +831,10 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
? Math.min(requestedStreamingInterval, 1_000)
|
? Math.min(requestedStreamingInterval, 1_000)
|
||||||
: MOCK_LOAD_TEST_INTERVAL_MS;
|
: MOCK_LOAD_TEST_INTERVAL_MS;
|
||||||
this.rewindError =
|
this.rewindError =
|
||||||
typeof options.config.featureValues?.mockRewindError === "string"
|
typeof options.config.providerOptions?.mockRewindError === "string"
|
||||||
? options.config.featureValues.mockRewindError
|
? options.config.providerOptions.mockRewindError
|
||||||
: null;
|
: null;
|
||||||
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
|
const requestedPromptRejections = options.config.providerOptions?.mockPromptRejections;
|
||||||
this.remainingPromptRejections =
|
this.remainingPromptRejections =
|
||||||
typeof requestedPromptRejections === "number" &&
|
typeof requestedPromptRejections === "number" &&
|
||||||
Number.isSafeInteger(requestedPromptRejections) &&
|
Number.isSafeInteger(requestedPromptRejections) &&
|
||||||
@@ -842,7 +842,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
|||||||
? requestedPromptRejections
|
? requestedPromptRejections
|
||||||
: 0;
|
: 0;
|
||||||
this.remainingSteerFailures = getPositiveFeatureInteger(
|
this.remainingSteerFailures = getPositiveFeatureInteger(
|
||||||
options.config.featureValues?.mockSteerAmbiguousFailures,
|
options.config.providerOptions?.mockSteerAmbiguousFailures,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -613,9 +613,9 @@ describe("OMP agent client and session", () => {
|
|||||||
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
|
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
|
||||||
"openrouter/google/gemini-3.8-flash",
|
"openrouter/google/gemini-3.8-flash",
|
||||||
);
|
);
|
||||||
expect(omp.requireSession().features).toEqual([
|
expect(omp.requireSession().controls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
value: true,
|
key: "fast_mode",
|
||||||
description: expect.stringMatching(/does not apply/i),
|
description: expect.stringMatching(/does not apply/i),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
@@ -626,18 +626,90 @@ describe("OMP agent client and session", () => {
|
|||||||
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
|
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
|
||||||
const session = omp.requireSession();
|
const session = omp.requireSession();
|
||||||
omp.runtime().fastModeResult = { enabled: true, active: false };
|
omp.runtime().fastModeResult = { enabled: true, active: false };
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
id: "fast_mode",
|
key: "fast_mode",
|
||||||
value: true,
|
|
||||||
description: expect.stringMatching(/does not apply to this model/i),
|
description: expect.stringMatching(/does not apply to this model/i),
|
||||||
tooltip: expect.stringMatching(/does not apply to this model/i),
|
tooltip: expect.stringMatching(/does not apply to this model/i),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("applies only the options it is given and leaves the runtime alone otherwise", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
await omp.start();
|
||||||
|
const session = omp.requireSession();
|
||||||
|
|
||||||
|
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||||
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
|
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||||
|
|
||||||
|
// A patch without the key leaves the runtime alone.
|
||||||
|
await session.applyOptions?.({});
|
||||||
|
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a live option the creation schema would reject, and sends nothing", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
await omp.start();
|
||||||
|
const session = omp.requireSession();
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).rejects.toThrow(/omp/i);
|
||||||
|
await expect(session.applyOptions?.({ fast_mode: "yes" })).rejects.toThrow(/omp/i);
|
||||||
|
expect(omp.runtime().setFastModeRequests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a removal turns Fast off, and a Fast the runtime keeps is returned", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
await omp.start();
|
||||||
|
const session = omp.requireSession();
|
||||||
|
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||||
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
|
|
||||||
|
omp.runtime().fastModeResult = { enabled: false, active: false };
|
||||||
|
await expect(session.applyOptions?.({ fast_mode: null })).resolves.toEqual({});
|
||||||
|
expect(omp.runtime().setFastModeRequests).toEqual([true, false]);
|
||||||
|
|
||||||
|
omp.runtime().fastModeResult = { enabled: false, active: false };
|
||||||
|
await expect(session.applyOptions?.({ fast_mode: true })).resolves.toEqual({
|
||||||
|
fast_mode: false,
|
||||||
|
});
|
||||||
|
expect(omp.eventTypes()).not.toContain("options_changed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a session opened without the Fast it was given says so in its open patch", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
// The fake runtime answers the open-time Fast request with Fast off.
|
||||||
|
await omp.start({ providerOptions: { fast_mode: true } });
|
||||||
|
|
||||||
|
expect(omp.requireSession().openOptionsPatch).toEqual({ fast_mode: false });
|
||||||
|
expect(omp.eventTypes()).not.toContain("options_changed");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reports a Fast change the runtime made on its own", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
await omp.start();
|
||||||
|
const session = omp.requireSession();
|
||||||
|
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||||
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
|
expect(omp.eventTypes()).not.toContain("options_changed");
|
||||||
|
|
||||||
|
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
|
||||||
|
omp.runtime().queueStateReports([
|
||||||
|
{
|
||||||
|
...omp.runtime().state,
|
||||||
|
model: omp.runtime().setModelResult,
|
||||||
|
fastModeEnabled: false,
|
||||||
|
fastModeActive: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
await session.setModel?.("openrouter/other/model");
|
||||||
|
|
||||||
|
expect(omp.eventTypes().filter((type) => type === "options_changed")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
test("shows Fast only when OMP reports its state fields", async () => {
|
test("shows Fast only when OMP reports its state fields", async () => {
|
||||||
const omp = new OmpHarness();
|
const omp = new OmpHarness();
|
||||||
await omp.start();
|
await omp.start();
|
||||||
@@ -649,11 +721,11 @@ describe("OMP agent client and session", () => {
|
|||||||
} = omp.runtime().state;
|
} = omp.runtime().state;
|
||||||
omp.runtime().state = olderState;
|
omp.runtime().state = olderState;
|
||||||
await session.getRuntimeInfo();
|
await session.getRuntimeInfo();
|
||||||
expect(session.features).toEqual([]);
|
expect(session.controls).toEqual([]);
|
||||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
|
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(/unavailable/i);
|
||||||
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
|
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
|
||||||
await session.getRuntimeInfo();
|
await session.getRuntimeInfo();
|
||||||
expect(session.features).toHaveLength(1);
|
expect(session.controls).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("switching model refreshes whether Fast applies", async () => {
|
test("switching model refreshes whether Fast applies", async () => {
|
||||||
@@ -661,8 +733,7 @@ describe("OMP agent client and session", () => {
|
|||||||
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
|
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
|
||||||
const session = omp.requireSession();
|
const session = omp.requireSession();
|
||||||
omp.runtime().fastModeResult = { enabled: true, active: true };
|
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||||
await session.setFeature?.("fast_mode", true);
|
await session.applyOptions?.({ fast_mode: true });
|
||||||
expect(session.features).toEqual([expect.objectContaining({ value: true })]);
|
|
||||||
|
|
||||||
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
|
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
|
||||||
omp.runtime().queueStateReports([
|
omp.runtime().queueStateReports([
|
||||||
@@ -674,24 +745,79 @@ describe("OMP agent client and session", () => {
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
await session.setModel?.("openrouter/other/model");
|
await session.setModel?.("openrouter/other/model");
|
||||||
expect(session.features).toEqual([
|
expect(session.controls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
value: true,
|
key: "fast_mode",
|
||||||
description: expect.stringMatching(/does not apply/i),
|
description: expect.stringMatching(/does not apply/i),
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a replacement runtime starts with the Fast the session has now, not the one it opened with", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
|
||||||
|
const session = omp.requireSession();
|
||||||
|
omp.runtime().fastModeResult = { enabled: false, active: false };
|
||||||
|
await session.applyOptions?.({ fast_mode: false });
|
||||||
|
|
||||||
|
await omp.setMode("ask");
|
||||||
|
|
||||||
|
// The approval-mode restart starts a second runtime; it must not turn Fast back on.
|
||||||
|
expect(omp.runtimeLaunches()).toHaveLength(2);
|
||||||
|
expect(omp.runtime().setFastModeRequests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a replacement runtime starts with the Fast the session opened with, not the one it was asked for", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
// The fake runtime answers the open-time Fast request with Fast off.
|
||||||
|
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
|
||||||
|
expect(omp.requireSession().openOptionsPatch).toEqual({ fast_mode: false });
|
||||||
|
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||||
|
|
||||||
|
await omp.setMode("ask");
|
||||||
|
|
||||||
|
expect(omp.runtimeLaunches()).toHaveLength(2);
|
||||||
|
expect(omp.runtime().setFastModeRequests).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a replacement runtime that refuses the Fast the session had publishes what it accepted", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
omp.acceptFastOnNextRuntime();
|
||||||
|
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
|
||||||
|
expect(omp.requireSession().openOptionsPatch).toEqual({});
|
||||||
|
|
||||||
|
// The approval-mode restart starts a second runtime, which refuses Fast.
|
||||||
|
await omp.setMode("ask");
|
||||||
|
|
||||||
|
expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a Fast change the runtime reports when a turn completes is published", async () => {
|
||||||
|
const omp = new OmpHarness();
|
||||||
|
omp.acceptFastOnNextRuntime();
|
||||||
|
await omp.start({ modeId: "full", providerOptions: { fast_mode: true } });
|
||||||
|
omp.runtime().state = { ...omp.runtime().state, fastModeEnabled: false, fastModeActive: false };
|
||||||
|
|
||||||
|
const { completion } = await omp.startPromptUntilProviderIdle("hello", "done", {
|
||||||
|
isStreaming: false,
|
||||||
|
isCompacting: false,
|
||||||
|
});
|
||||||
|
await completion;
|
||||||
|
await omp.requireSession().getRuntimeInfo();
|
||||||
|
|
||||||
|
expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]);
|
||||||
|
});
|
||||||
|
|
||||||
test("restores Fast from the initial OMP state on create and resume", async () => {
|
test("restores Fast from the initial OMP state on create and resume", async () => {
|
||||||
const created = new OmpHarness();
|
const created = new OmpHarness();
|
||||||
await created.start({ featureValues: { fast_mode: true } });
|
await created.start({ providerOptions: { fast_mode: true } });
|
||||||
expect(created.runtime().setFastModeRequests).toEqual([true]);
|
expect(created.runtime().setFastModeRequests).toEqual([true]);
|
||||||
expect(created.runtime().getStateRequestCount).toBe(1);
|
expect(created.runtime().getStateRequestCount).toBe(1);
|
||||||
|
|
||||||
const resumed = new OmpHarness();
|
const resumed = new OmpHarness();
|
||||||
await resumed.resume(
|
await resumed.resume(
|
||||||
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
|
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
|
||||||
{ featureValues: { fast_mode: true } },
|
{ providerOptions: { fast_mode: true } },
|
||||||
);
|
);
|
||||||
expect(resumed.runtime().setFastModeRequests).toEqual([true]);
|
expect(resumed.runtime().setFastModeRequests).toEqual([true]);
|
||||||
expect(resumed.runtime().getStateRequestCount).toBe(1);
|
expect(resumed.runtime().getStateRequestCount).toBe(1);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
import { mapCustomMessageToToolCall } from "../custom-message.js";
|
import { mapCustomMessageToToolCall } from "../custom-message.js";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
@@ -9,7 +10,7 @@ import stripAnsi from "strip-ansi";
|
|||||||
import {
|
import {
|
||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -66,9 +67,12 @@ import {
|
|||||||
resolveOmpLaunchMode,
|
resolveOmpLaunchMode,
|
||||||
resolveOmpProviderOptions,
|
resolveOmpProviderOptions,
|
||||||
OMP_MODES,
|
OMP_MODES,
|
||||||
|
OmpProviderOptionsSchema,
|
||||||
type OmpRuntimeOptions,
|
type OmpRuntimeOptions,
|
||||||
} from "./provider-config.js";
|
} from "./provider-config.js";
|
||||||
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
|
export { formatOmpVersionSupport, resolveOmpDiagnosticPaths } from "./provider-config.js";
|
||||||
|
import { applyProviderOptionsPatch } from "../../option-controls.js";
|
||||||
|
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
|
||||||
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
import { OmpSubagentCardTracker } from "./subagent-card-tracker.js";
|
||||||
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
import { ompSkillPromptUserText, shouldDisplayOmpCustomMessage } from "./custom-message.js";
|
||||||
import { getUserMessageText } from "./message-history.js";
|
import { getUserMessageText } from "./message-history.js";
|
||||||
@@ -750,6 +754,10 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
this.restartRuntime = options.restartRuntime;
|
this.restartRuntime = options.restartRuntime;
|
||||||
this.config = options.config;
|
this.config = options.config;
|
||||||
this.state = options.initialState;
|
this.state = options.initialState;
|
||||||
|
this.openOptionsPatch = this.fastModeDifference(options.config.providerOptions?.fast_mode);
|
||||||
|
// A replacement runtime starts from the Fast this one opened with, not the one it was asked for.
|
||||||
|
if (typeof this.openOptionsPatch.fast_mode === "boolean")
|
||||||
|
this.rememberFastMode(this.openOptionsPatch.fast_mode);
|
||||||
this.currentModeId = options.currentModeId ?? null;
|
this.currentModeId = options.currentModeId ?? null;
|
||||||
this.logger = options.logger;
|
this.logger = options.logger;
|
||||||
this.live = options.live ?? true;
|
this.live = options.live ?? true;
|
||||||
@@ -804,15 +812,14 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
return this.state.sessionId;
|
return this.state.sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
if (!hasOmpFastMode(this.state)) return [];
|
if (!hasOmpFastMode(this.state)) return [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: "fast_mode",
|
key: "fast_mode",
|
||||||
label: "Fast",
|
label: "Fast",
|
||||||
icon: "zap",
|
icon: "zap",
|
||||||
value: this.state.fastModeEnabled === true,
|
|
||||||
...(this.state.fastModeEnabled && !this.state.fastModeActive
|
...(this.state.fastModeEnabled && !this.state.fastModeActive
|
||||||
? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
|
? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
|
||||||
: {}),
|
: {}),
|
||||||
@@ -820,18 +827,49 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||||
if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`);
|
assertChangesAreLive({
|
||||||
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
|
provider: "omp",
|
||||||
|
patch: options,
|
||||||
|
current: this.config.providerOptions,
|
||||||
|
live: ["fast_mode"],
|
||||||
|
});
|
||||||
|
const next = validateProviderOptions(
|
||||||
|
"omp",
|
||||||
|
OmpProviderOptionsSchema,
|
||||||
|
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
|
||||||
|
);
|
||||||
|
if (options.fast_mode === undefined) return {};
|
||||||
|
const requested = next?.fast_mode ?? false;
|
||||||
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
|
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
|
||||||
const result = await this.runtimeSession.setFastMode(value);
|
if (requested !== this.state.fastModeEnabled) {
|
||||||
|
const result = await this.runtimeSession.setFastMode(requested);
|
||||||
this.state = {
|
this.state = {
|
||||||
...this.state,
|
...this.state,
|
||||||
fastModeEnabled: result.enabled,
|
fastModeEnabled: result.enabled,
|
||||||
fastModeActive: result.active,
|
fastModeActive: result.active,
|
||||||
};
|
};
|
||||||
this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled };
|
|
||||||
}
|
}
|
||||||
|
if (typeof this.state.fastModeEnabled === "boolean") {
|
||||||
|
this.rememberFastMode(this.state.fastModeEnabled);
|
||||||
|
}
|
||||||
|
return this.fastModeDifference(requested);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A replacement runtime starts from the Fast the session has now, not the one it opened with. */
|
||||||
|
private rememberFastMode(enabled: boolean): void {
|
||||||
|
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The option patch when the runtime's fast mode is not what `given` stands for; unset is off. */
|
||||||
|
private fastModeDifference(given: unknown): Record<string, unknown> {
|
||||||
|
if (!hasOmpFastMode(this.state)) return {};
|
||||||
|
const enabled = this.state.fastModeEnabled === true;
|
||||||
|
return (given ?? false) === enabled ? {} : { fast_mode: enabled };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How the opened session differs from the options it was given. */
|
||||||
|
readonly openOptionsPatch: Record<string, unknown>;
|
||||||
|
|
||||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||||
return runProviderTurn({
|
return runProviderTurn({
|
||||||
@@ -1072,7 +1110,7 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
this.pendingPromptResults.clear();
|
this.pendingPromptResults.clear();
|
||||||
this.runtimeSession = next;
|
this.runtimeSession = next;
|
||||||
this.hostTools = restarted.hostTools;
|
this.hostTools = restarted.hostTools;
|
||||||
this.state = state;
|
this.installState(state);
|
||||||
this.currentModeId = modeId;
|
this.currentModeId = modeId;
|
||||||
this.config.modeId = modeId;
|
this.config.modeId = modeId;
|
||||||
this.runtimeDead = false;
|
this.runtimeDead = false;
|
||||||
@@ -2210,7 +2248,7 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const state = await this.runtimeSession.getState();
|
const state = await this.runtimeSession.getState();
|
||||||
this.state = state;
|
this.installState(state);
|
||||||
if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) {
|
if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) {
|
||||||
// An interrupt settled this turn while the state check was in flight.
|
// An interrupt settled this turn while the state check was in flight.
|
||||||
return;
|
return;
|
||||||
@@ -2227,7 +2265,22 @@ export class OmpAgentSession implements AgentSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async refreshState(): Promise<void> {
|
private async refreshState(): Promise<void> {
|
||||||
this.state = await this.runtimeSession.getState();
|
this.installState(await this.runtimeSession.getState());
|
||||||
|
}
|
||||||
|
|
||||||
|
// The runtime owns Fast: whatever it reports is what the session now runs with, so every state
|
||||||
|
// read from the runtime is installed here and a difference is published once.
|
||||||
|
private installState(state: OmpSessionState): void {
|
||||||
|
const before = this.state.fastModeEnabled;
|
||||||
|
this.state = state;
|
||||||
|
if (this.state.fastModeEnabled !== before && typeof this.state.fastModeEnabled === "boolean") {
|
||||||
|
this.rememberFastMode(this.state.fastModeEnabled);
|
||||||
|
this.emit({
|
||||||
|
type: "options_changed",
|
||||||
|
provider: this.provider,
|
||||||
|
patch: { fast_mode: this.state.fastModeEnabled },
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
|
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
|
||||||
@@ -2295,7 +2348,7 @@ export class OmpAgentClient implements AgentClient {
|
|||||||
config: AgentSessionConfig,
|
config: AgentSessionConfig,
|
||||||
state: OmpSessionState,
|
state: OmpSessionState,
|
||||||
): Promise<OmpSessionState> {
|
): Promise<OmpSessionState> {
|
||||||
const value = config.featureValues?.fast_mode;
|
const value = config.providerOptions?.fast_mode;
|
||||||
if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
|
if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
|
||||||
return state;
|
return state;
|
||||||
const result = await runtimeSession.setFastMode(value);
|
const result = await runtimeSession.setFastMode(value);
|
||||||
@@ -2530,7 +2583,7 @@ export class OmpAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ export { OMP_MODES };
|
|||||||
|
|
||||||
export const OmpProviderOptionsSchema = z
|
export const OmpProviderOptionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
// Composer control, not a runtime option: the session reads it and asks OMP to match.
|
||||||
|
fast_mode: z.boolean().optional(),
|
||||||
sessionDir: z.string().min(1).optional(),
|
sessionDir: z.string().min(1).optional(),
|
||||||
rpcTimeoutMs: z.number().int().positive().optional(),
|
rpcTimeoutMs: z.number().int().positive().optional(),
|
||||||
smolModel: z.string().min(1).optional(),
|
smolModel: z.string().min(1).optional(),
|
||||||
|
|||||||
@@ -467,6 +467,16 @@ export class OmpHarness {
|
|||||||
return this.events.map((event) => event.type);
|
return this.events.map((event) => event.type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
acceptFastOnNextRuntime(): void {
|
||||||
|
this.omp.queueSessionSetup((runtime) => {
|
||||||
|
runtime.fastModeResult = { enabled: true, active: true };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
optionsChanges(): Record<string, unknown>[] {
|
||||||
|
return this.events.flatMap((event) => (event.type === "options_changed" ? [event.patch] : []));
|
||||||
|
}
|
||||||
|
|
||||||
async history(): Promise<AgentTimelineItem[]> {
|
async history(): Promise<AgentTimelineItem[]> {
|
||||||
const items: AgentTimelineItem[] = [];
|
const items: AgentTimelineItem[] = [];
|
||||||
for await (const event of this.requireSession().streamHistory()) {
|
for await (const event of this.requireSession().streamHistory()) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||||
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
import type { AgentStreamEvent } from "../agent-sdk-types.js";
|
||||||
@@ -108,33 +108,22 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
expect(modes).toEqual([]);
|
expect(modes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("lists auto accept as a provider feature", async () => {
|
test("lists auto accept as a provider control", async () => {
|
||||||
const { runtime } = mockOpenCodeClient();
|
const { runtime } = mockOpenCodeClient();
|
||||||
|
|
||||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||||
serverManager: runtime,
|
serverManager: runtime,
|
||||||
createClient: runtime.createClient,
|
createClient: runtime.createClient,
|
||||||
});
|
});
|
||||||
const enabledFeatures = await client.listFeatures({
|
const controls = await client.listControls({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
|
||||||
const legacyFeatures = await client.listFeatures({
|
|
||||||
provider: "opencode",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
modeId: "full-access",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(enabledFeatures).toEqual([
|
expect(controls).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
|
||||||
type: "toggle",
|
|
||||||
id: "auto_accept",
|
|
||||||
label: "Auto Accept",
|
|
||||||
value: true,
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
|
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
|
||||||
@@ -151,7 +140,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(await session.getCurrentMode()).toBe("build");
|
expect(await session.getCurrentMode()).toBe("build");
|
||||||
expect(session.features).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
|
expect(session.controls).toEqual([expect.objectContaining({ key: "auto_accept" })]);
|
||||||
|
|
||||||
await session.run("Implement the change");
|
await session.run("Implement the change");
|
||||||
|
|
||||||
@@ -170,7 +159,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
requestedMode: "full-access",
|
requestedMode: "full-access",
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: false,
|
unattended: false,
|
||||||
availableModes: [
|
availableModes: [
|
||||||
@@ -178,7 +167,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
{ id: "plan", label: "Plan" },
|
{ id: "plan", label: "Plan" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual({ modeId: "build", featureValues: { auto_accept: true } });
|
).toEqual({ modeId: "build", providerOptions: { auto_accept: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("inherits unattended callers as auto accept with OpenCode's default agent", () => {
|
test("inherits unattended callers as auto accept with OpenCode's default agent", () => {
|
||||||
@@ -191,7 +180,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent: {
|
parent: {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
modeId: "bypassPermissions",
|
modeId: "bypassPermissions",
|
||||||
@@ -203,7 +192,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
{ id: "plan", label: "Plan" },
|
{ id: "plan", label: "Plan" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => {
|
test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => {
|
||||||
@@ -213,7 +202,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent: null,
|
parent: null,
|
||||||
unattended: true,
|
unattended: true,
|
||||||
availableModes: [
|
availableModes: [
|
||||||
@@ -221,7 +210,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
{ id: "plan", label: "Plan" },
|
{ id: "plan", label: "Plan" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => {
|
test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => {
|
||||||
@@ -231,7 +220,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
requestedMode: undefined,
|
requestedMode: undefined,
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent: {
|
parent: {
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
modeId: "paseo-custom",
|
modeId: "paseo-custom",
|
||||||
@@ -244,7 +233,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
{ id: "paseo-custom", label: "Paseo Custom" },
|
{ id: "paseo-custom", label: "Paseo Custom" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual({ modeId: "paseo-custom", featureValues: { auto_accept: true } });
|
).toEqual({ modeId: "paseo-custom", providerOptions: { auto_accept: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("inherits auto accept from an OpenCode parent when the child chooses a mode", () => {
|
test("inherits auto accept from an OpenCode parent when the child chooses a mode", () => {
|
||||||
@@ -254,7 +243,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
client.resolveCreateConfig({
|
client.resolveCreateConfig({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
requestedMode: "base",
|
requestedMode: "base",
|
||||||
featureValues: undefined,
|
providerOptions: undefined,
|
||||||
parent: {
|
parent: {
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
modeId: "orchestrator",
|
modeId: "orchestrator",
|
||||||
@@ -267,7 +256,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
{ id: "orchestrator", label: "Orchestrator" },
|
{ id: "orchestrator", label: "Orchestrator" },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
|
).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("auto-approves tool permissions when auto accept is enabled", async () => {
|
test("auto-approves tool permissions when auto accept is enabled", async () => {
|
||||||
@@ -283,7 +272,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
session.subscribe((event) => receivedEvents.push(event));
|
session.subscribe((event) => receivedEvents.push(event));
|
||||||
|
|
||||||
@@ -301,6 +290,72 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
await session.close();
|
await session.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("rejects a live auto_accept the creation schema would reject, and keeps approving", async () => {
|
||||||
|
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||||
|
events: [toolPermissionEvent(), idleEvent()],
|
||||||
|
});
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||||
|
serverManager: runtime,
|
||||||
|
createClient: runtime.createClient,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
providerOptions: { auto_accept: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ auto_accept: "yes" })).rejects.toThrow(/auto_accept/);
|
||||||
|
await session.run("Run verification");
|
||||||
|
|
||||||
|
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a live permission change the running session cannot apply", async () => {
|
||||||
|
const { runtime } = mockOpenCodeClient({ events: [idleEvent()] });
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||||
|
serverManager: runtime,
|
||||||
|
createClient: runtime.createClient,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
providerOptions: { permission: "allow" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
|
||||||
|
/permission.*cannot change on a running session/,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
session.applyOptions?.({ permission: "allow", auto_accept: true }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stops auto-approving once auto_accept is removed", async () => {
|
||||||
|
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||||
|
events: [toolPermissionEvent(), idleEvent()],
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||||
|
serverManager: runtime,
|
||||||
|
createClient: runtime.createClient,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
providerOptions: { auto_accept: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.applyOptions?.({ auto_accept: null });
|
||||||
|
await session.startTurn("Run verification");
|
||||||
|
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
|
||||||
|
|
||||||
|
expect(openCodeClient.calls.permissionReply).toHaveLength(0);
|
||||||
|
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("keeps questions separate from auto accept tool approval", async () => {
|
test("keeps questions separate from auto accept tool approval", async () => {
|
||||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||||
events: [questionEvent(), idleEvent()],
|
events: [questionEvent(), idleEvent()],
|
||||||
@@ -314,7 +369,7 @@ describe("OpenCode auto_accept feature", () => {
|
|||||||
const session = await client.createSession({
|
const session = await client.createSession({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/tmp/project",
|
cwd: "/tmp/project",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
session.subscribe((event) => receivedEvents.push(event));
|
session.subscribe((event) => receivedEvents.push(event));
|
||||||
|
|
||||||
|
|||||||
@@ -5274,7 +5274,7 @@ describe("OpenCode provider subagent contract", () => {
|
|||||||
const parent = await client.createSession({
|
const parent = await client.createSession({
|
||||||
provider: "opencode",
|
provider: "opencode",
|
||||||
cwd: "/workspace/repo",
|
cwd: "/workspace/repo",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
parent.subscribe(() => undefined);
|
parent.subscribe(() => undefined);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { validateProviderOptions } from "../provider-options.js";
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { applyProviderOptionsPatch } from "../option-controls.js";
|
||||||
|
import { assertChangesAreLive, validateProviderOptions } from "../provider-options.js";
|
||||||
import {
|
import {
|
||||||
createOpencodeClient,
|
createOpencodeClient,
|
||||||
type AssistantMessage as OpenCodeAssistantMessage,
|
type AssistantMessage as OpenCodeAssistantMessage,
|
||||||
@@ -25,7 +27,7 @@ import {
|
|||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentCreateSessionOptions,
|
type AgentCreateSessionOptions,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
type AgentModelDefinition,
|
type AgentModelDefinition,
|
||||||
@@ -232,15 +234,15 @@ const DEFAULT_MODES: AgentMode[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||||
return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
|
return config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function withOpenCodeAutoAcceptFeature(
|
function withOpenCodeAutoAccept(
|
||||||
featureValues: Record<string, unknown> | undefined,
|
providerOptions: ProviderOptions | undefined,
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
): Record<string, unknown> {
|
): ProviderOptions {
|
||||||
return {
|
return {
|
||||||
...featureValues,
|
...providerOptions,
|
||||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
|
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -259,25 +261,25 @@ function resolveOpenCodeCreateConfig(
|
|||||||
const requestedMode = legacyFullAccess
|
const requestedMode = legacyFullAccess
|
||||||
? OPENCODE_BUILD_MODE_ID
|
? OPENCODE_BUILD_MODE_ID
|
||||||
: (input.requestedMode ?? inheritedOpenCodeMode);
|
: (input.requestedMode ?? inheritedOpenCodeMode);
|
||||||
const featureValues =
|
const providerOptions =
|
||||||
legacyFullAccess ||
|
legacyFullAccess ||
|
||||||
(isUnattendedCreate && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
(isUnattendedCreate && input.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
||||||
? withOpenCodeAutoAcceptFeature(input.featureValues, true)
|
? withOpenCodeAutoAccept(input.providerOptions, true)
|
||||||
: input.featureValues;
|
: input.providerOptions;
|
||||||
|
|
||||||
if (inheritsUnattended && requestedMode === undefined) {
|
if (inheritsUnattended && requestedMode === undefined) {
|
||||||
// Unattendedness for OpenCode is carried by auto_accept (set above), not
|
// Unattendedness for OpenCode is carried by auto_accept (set above), not
|
||||||
// by any particular agent. Leave the mode unset so OpenCode uses its own
|
// by any particular agent. Leave the mode unset so OpenCode uses its own
|
||||||
// default agent — `build` may not exist in the user's OpenCode config.
|
// default agent — `build` may not exist in the user's OpenCode config.
|
||||||
return { modeId: undefined, featureValues };
|
return { modeId: undefined, providerOptions };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resolved = resolveDefaultAgentCreateConfig({
|
const resolved = resolveDefaultAgentCreateConfig({
|
||||||
...input,
|
...input,
|
||||||
requestedMode,
|
requestedMode,
|
||||||
featureValues,
|
providerOptions,
|
||||||
});
|
});
|
||||||
return { ...resolved, featureValues };
|
return { ...resolved, providerOptions };
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpenCodeCreateConfigUnattended(
|
function isOpenCodeCreateConfigUnattended(
|
||||||
@@ -285,26 +287,18 @@ function isOpenCodeCreateConfigUnattended(
|
|||||||
): boolean {
|
): boolean {
|
||||||
return (
|
return (
|
||||||
isDefaultAgentCreateConfigUnattended(input) ||
|
isDefaultAgentCreateConfigUnattended(input) ||
|
||||||
input.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true ||
|
input.config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true
|
||||||
input.features?.some(
|
|
||||||
(feature) =>
|
|
||||||
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
|
|
||||||
(feature.value === true || feature.value === "true"),
|
|
||||||
) === true
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
const OPENCODE_AUTO_ACCEPT_CONTROL: AgentOptionControl = {
|
||||||
return {
|
|
||||||
type: "toggle",
|
type: "toggle",
|
||||||
id: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
|
key: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
|
||||||
label: "Auto Accept",
|
label: "Auto Accept",
|
||||||
description: "Automatically approves OpenCode tool permission prompts.",
|
description: "Automatically approves OpenCode tool permission prompts.",
|
||||||
tooltip: "Auto accept permission prompts",
|
tooltip: "Auto accept permission prompts",
|
||||||
icon: "shield-check",
|
icon: "shield-check",
|
||||||
value: isOpenCodeAutoAcceptEnabled(config),
|
|
||||||
};
|
};
|
||||||
}
|
|
||||||
|
|
||||||
function buildOpenCodePermissionActions(): AgentPermissionAction[] {
|
function buildOpenCodePermissionActions(): AgentPermissionAction[] {
|
||||||
return [
|
return [
|
||||||
@@ -682,10 +676,7 @@ function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConf
|
|||||||
return {
|
return {
|
||||||
...normalized,
|
...normalized,
|
||||||
modeId: OPENCODE_BUILD_MODE_ID,
|
modeId: OPENCODE_BUILD_MODE_ID,
|
||||||
featureValues: {
|
providerOptions: withOpenCodeAutoAccept(normalized.providerOptions, true),
|
||||||
...normalized.featureValues,
|
|
||||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1679,8 +1670,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
|
this.assertConfig(config);
|
||||||
|
return [OPENCODE_AUTO_ACCEPT_CONTROL];
|
||||||
}
|
}
|
||||||
|
|
||||||
async listImportableSessions(
|
async listImportableSessions(
|
||||||
@@ -3565,8 +3557,8 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
return this.turnState.status === "running" ? this.turnState.turnId : null;
|
return this.turnState.status === "running" ? this.turnState.turnId : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
get features(): AgentFeature[] {
|
get controls(): AgentOptionControl[] {
|
||||||
return [buildOpenCodeAutoAcceptFeature(this.config)];
|
return [OPENCODE_AUTO_ACCEPT_CONTROL];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||||
@@ -4956,7 +4948,15 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
const normalizedModeId = normalizeOpenCodeModeId(modeId);
|
const normalizedModeId = normalizeOpenCodeModeId(modeId);
|
||||||
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
||||||
this.currentMode = OPENCODE_BUILD_MODE_ID;
|
this.currentMode = OPENCODE_BUILD_MODE_ID;
|
||||||
await this.setFeature(OPENCODE_AUTO_ACCEPT_FEATURE_ID, true);
|
this.setAutoAccept(true);
|
||||||
|
this.notifySubscribers(
|
||||||
|
{
|
||||||
|
type: "options_changed",
|
||||||
|
provider: "opencode",
|
||||||
|
patch: { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true },
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4964,17 +4964,26 @@ class OpenCodeAgentSession implements AgentSession {
|
|||||||
this.config.modeId = normalizedModeId ?? undefined;
|
this.config.modeId = normalizedModeId ?? undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||||
if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) {
|
assertChangesAreLive({
|
||||||
throw new Error(`Unsupported OpenCode feature '${featureId}'`);
|
provider: "opencode",
|
||||||
|
patch: options,
|
||||||
|
current: this.config.providerOptions,
|
||||||
|
live: [OPENCODE_AUTO_ACCEPT_FEATURE_ID],
|
||||||
|
});
|
||||||
|
const next = validateProviderOptions(
|
||||||
|
"opencode",
|
||||||
|
OpenCodeProviderOptionsSchema,
|
||||||
|
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
|
||||||
|
);
|
||||||
|
if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) {
|
||||||
|
this.setAutoAccept(next?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const enabled = value === true;
|
private setAutoAccept(enabled: boolean): void {
|
||||||
this.autoAcceptEnabled = enabled;
|
this.autoAcceptEnabled = enabled;
|
||||||
this.config.featureValues = {
|
this.config.providerOptions = withOpenCodeAutoAccept(this.config.providerOptions, enabled);
|
||||||
...this.config.featureValues,
|
|
||||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingPermissions(): AgentPermissionRequest[] {
|
getPendingPermissions(): AgentPermissionRequest[] {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const PermissionRuleSchema = z.union([
|
|||||||
// OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46.
|
// OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46.
|
||||||
export const OpenCodeProviderOptionsSchema = z
|
export const OpenCodeProviderOptionsSchema = z
|
||||||
.object({
|
.object({
|
||||||
|
// Composer control: approves permission prompts without asking.
|
||||||
|
auto_accept: z.boolean().optional(),
|
||||||
permission: z
|
permission: z
|
||||||
.union([
|
.union([
|
||||||
PermissionActionSchema,
|
PermissionActionSchema,
|
||||||
|
|||||||
@@ -100,11 +100,11 @@ test.each([
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
await writeFile(script, initialSource);
|
await writeFile(script, initialSource);
|
||||||
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||||
"Auto Accept",
|
"Auto Accept",
|
||||||
);
|
);
|
||||||
await writeFile(script, 'console.log("opencode v2.0.10")');
|
await writeFile(script, 'console.log("opencode v2.0.10")');
|
||||||
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||||
"Auto-accept",
|
"Auto-accept",
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -202,7 +202,7 @@ test.each([
|
|||||||
const legacy = new OpenCodeAgentClient(logger);
|
const legacy = new OpenCodeAgentClient(logger);
|
||||||
const config = { provider: "opencode", cwd: root };
|
const config = { provider: "opencode", cwd: root };
|
||||||
try {
|
try {
|
||||||
expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config));
|
expect(await client.listControls(config)).toEqual(await legacy.listControls(config));
|
||||||
} finally {
|
} finally {
|
||||||
await client.shutdown();
|
await client.shutdown();
|
||||||
await legacy.shutdown();
|
await legacy.shutdown();
|
||||||
@@ -221,8 +221,8 @@ test("selects v2 without probing the binary when an external server is configure
|
|||||||
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
|
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
|
const controls = await client.listControls({ provider: "opencode", cwd: "/tmp/project" });
|
||||||
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
|
expect(controls.map((control) => control.label)).toEqual(["Auto-accept"]);
|
||||||
} finally {
|
} finally {
|
||||||
await client.shutdown();
|
await client.shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,8 +160,8 @@ export class OpenCodeRuntimeClient implements AgentClient {
|
|||||||
async listCommands(config: AgentSessionConfig) {
|
async listCommands(config: AgentSessionConfig) {
|
||||||
return (await this.client()).listCommands(config);
|
return (await this.client()).listCommands(config);
|
||||||
}
|
}
|
||||||
async listFeatures(config: AgentSessionConfig) {
|
async listControls(config: AgentSessionConfig) {
|
||||||
return (await this.client()).listFeatures(config);
|
return (await this.client()).listControls(config);
|
||||||
}
|
}
|
||||||
async listImportableSessions(options?: ListImportableSessionsOptions) {
|
async listImportableSessions(options?: ListImportableSessionsOptions) {
|
||||||
return (await this.client()).listImportableSessions(options);
|
return (await this.client()).listImportableSessions(options);
|
||||||
|
|||||||
@@ -37,6 +37,119 @@ describe("OpenCode v2 session lifecycle", () => {
|
|||||||
expect(refreshed).toEqual(["/tmp/project"]);
|
expect(refreshed).toEqual(["/tmp/project"]);
|
||||||
await client.shutdown();
|
await client.shutdown();
|
||||||
});
|
});
|
||||||
|
test("rejects a live auto_accept the creation schema would reject", async () => {
|
||||||
|
const harness = new V2Harness();
|
||||||
|
const client = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: harness.runtime,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ auto_accept: "yes" })).rejects.toThrow(/auto_accept/);
|
||||||
|
await expect(session.applyOptions?.({ auto_accept: true })).resolves.toBeUndefined();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a rejected auto_accept leaves the running session asking for permission", async () => {
|
||||||
|
const harness = new V2Harness();
|
||||||
|
const permissions = [
|
||||||
|
{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] },
|
||||||
|
];
|
||||||
|
harness.api.permission.list = async () => permissions;
|
||||||
|
const replies: string[] = [];
|
||||||
|
harness.api.permission.reply = async (input) => {
|
||||||
|
replies.push(input.requestID);
|
||||||
|
throw new Error("upstream refused");
|
||||||
|
};
|
||||||
|
const client = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: harness.runtime,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||||
|
try {
|
||||||
|
await expect(session.applyOptions?.({ auto_accept: true })).rejects.toThrow(
|
||||||
|
"upstream refused",
|
||||||
|
);
|
||||||
|
|
||||||
|
const next = {
|
||||||
|
id: "next",
|
||||||
|
sessionID: "session",
|
||||||
|
action: "shell",
|
||||||
|
resources: ["ls"],
|
||||||
|
};
|
||||||
|
permissions.push(next);
|
||||||
|
harness.push({ id: "asked", created: 2, type: "permission.asked", data: next });
|
||||||
|
|
||||||
|
const pendingIds = () => session.getPendingPermissions().map(({ id }) => id);
|
||||||
|
await expect.poll(pendingIds).toContain("next");
|
||||||
|
expect(replies).not.toContain("next");
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("overlapping auto_accept applies do not undo each other when one fails", async () => {
|
||||||
|
const harness = new V2Harness();
|
||||||
|
const permissions = [
|
||||||
|
{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] },
|
||||||
|
];
|
||||||
|
harness.api.permission.list = async () => permissions;
|
||||||
|
let failFirst!: () => void;
|
||||||
|
let calls = 0;
|
||||||
|
harness.api.permission.reply = async () => {
|
||||||
|
calls += 1;
|
||||||
|
if (calls > 1) return;
|
||||||
|
await new Promise<void>((_, reject) => {
|
||||||
|
failFirst = () => reject(new Error("upstream refused"));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const client = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: harness.runtime,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
||||||
|
try {
|
||||||
|
const first = session.applyOptions?.({ auto_accept: true });
|
||||||
|
const second = session.applyOptions?.({ auto_accept: true });
|
||||||
|
await expect.poll(() => calls).toBe(1);
|
||||||
|
failFirst();
|
||||||
|
|
||||||
|
await expect(first).rejects.toThrow("upstream refused");
|
||||||
|
await expect(second).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
// The accepted second apply is in force: the next permission is approved, not asked.
|
||||||
|
const next = { id: "next", sessionID: "session", action: "shell", resources: ["ls"] };
|
||||||
|
permissions.push(next);
|
||||||
|
harness.push({ id: "asked", created: 2, type: "permission.asked", data: next });
|
||||||
|
await expect.poll(() => calls).toBeGreaterThan(2);
|
||||||
|
const pendingIds = () => session.getPendingPermissions().map(({ id }) => id);
|
||||||
|
expect(pendingIds()).not.toContain("next");
|
||||||
|
} finally {
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects a live permission change the running session cannot apply", async () => {
|
||||||
|
const harness = new V2Harness();
|
||||||
|
const client = new OpenCodeV2AgentClient({
|
||||||
|
logger: createTestLogger(),
|
||||||
|
runtime: harness.runtime,
|
||||||
|
});
|
||||||
|
const session = await client.createSession({
|
||||||
|
provider: "opencode",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
providerOptions: { permission: "allow" },
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(session.applyOptions?.({ permission: "deny" })).rejects.toThrow(
|
||||||
|
/permission.*cannot change on a running session/,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
session.applyOptions?.({ permission: "allow", auto_accept: true }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("completes from execution events without a long-lived HTTP wait", async () => {
|
test("completes from execution events without a long-lived HTTP wait", async () => {
|
||||||
const harness = new V2Harness();
|
const harness = new V2Harness();
|
||||||
harness.prompt = async (input) => {
|
harness.prompt = async (input) => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OpenCodeV2Session } from "./session.js";
|
import { OpenCodeV2Session } from "./session.js";
|
||||||
import { V2_CAPABILITIES } from "./capabilities.js";
|
import { V2_CAPABILITIES } from "./capabilities.js";
|
||||||
import {
|
import {
|
||||||
features,
|
controls,
|
||||||
permissionRules,
|
permissionRules,
|
||||||
applyResumeOverrides,
|
applyResumeOverrides,
|
||||||
requiresDedicatedV2Server,
|
requiresDedicatedV2Server,
|
||||||
@@ -17,7 +17,7 @@ import type {
|
|||||||
AgentCapabilityFlags,
|
AgentCapabilityFlags,
|
||||||
AgentClient,
|
AgentClient,
|
||||||
AgentCreateSessionOptions,
|
AgentCreateSessionOptions,
|
||||||
AgentFeature,
|
AgentOptionControl,
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
AgentPersistenceHandle,
|
AgentPersistenceHandle,
|
||||||
AgentSession,
|
AgentSession,
|
||||||
@@ -222,8 +222,8 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(): Promise<AgentOptionControl[]> {
|
||||||
return features(config);
|
return controls;
|
||||||
}
|
}
|
||||||
async listCommands(config: AgentSessionConfig) {
|
async listCommands(config: AgentSessionConfig) {
|
||||||
const connection = await this.runtime.acquire();
|
const connection = await this.runtime.acquire();
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
|
import { controls, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
|
||||||
|
|
||||||
describe("OpenCode v2 permission rules", () => {
|
describe("OpenCode v2 permission rules", () => {
|
||||||
test("maps authored native permissions and exact injected MCP grants", () => {
|
test("maps authored native permissions and exact injected MCP grants", () => {
|
||||||
@@ -49,17 +49,8 @@ describe("OpenCode v2 helper isolation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("OpenCode v2 features", () => {
|
describe("OpenCode v2 controls", () => {
|
||||||
test("reports the auto-accept toggle from the session value", () => {
|
test("describes the auto-accept toggle over the auto_accept option", () => {
|
||||||
expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([
|
expect(controls).toEqual([{ type: "toggle", key: "auto_accept", label: "Auto-accept" }]);
|
||||||
{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: false },
|
|
||||||
]);
|
|
||||||
expect(
|
|
||||||
features({
|
|
||||||
provider: "opencode",
|
|
||||||
cwd: "/tmp/project",
|
|
||||||
featureValues: { auto_accept: true },
|
|
||||||
}),
|
|
||||||
).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import type { SessionInfo, PermissionRuleset } from "@opencode/client";
|
|||||||
import type { V2Api } from "./api.js";
|
import type { V2Api } from "./api.js";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentFeature,
|
|
||||||
AgentLaunchContext,
|
AgentLaunchContext,
|
||||||
|
AgentOptionControl,
|
||||||
AgentSessionConfig,
|
AgentSessionConfig,
|
||||||
} from "../../../agent-sdk-types.js";
|
} from "../../../agent-sdk-types.js";
|
||||||
|
|
||||||
@@ -11,16 +11,9 @@ import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../
|
|||||||
|
|
||||||
import { modelRef } from "./mapping.js";
|
import { modelRef } from "./mapping.js";
|
||||||
|
|
||||||
export function features(config: AgentSessionConfig): AgentFeature[] {
|
export const controls: AgentOptionControl[] = [
|
||||||
return [
|
{ type: "toggle", key: "auto_accept", label: "Auto-accept" },
|
||||||
{
|
|
||||||
type: "toggle",
|
|
||||||
id: "auto_accept",
|
|
||||||
label: "Auto-accept",
|
|
||||||
value: config.featureValues?.["auto_accept"] === true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
}
|
|
||||||
export function permissionRules(config: AgentSessionConfig): PermissionRuleset {
|
export function permissionRules(config: AgentSessionConfig): PermissionRuleset {
|
||||||
const rules =
|
const rules =
|
||||||
buildOpenCodePermissionRules(
|
buildOpenCodePermissionRules(
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export class SessionPermissions {
|
|||||||
};
|
};
|
||||||
this.pending.set(request.id, pending);
|
this.pending.set(request.id, pending);
|
||||||
this.permissionOwners.set(request.id, sessionID);
|
this.permissionOwners.set(request.id, sessionID);
|
||||||
if (!this.config.toolPolicy && this.config.featureValues?.["auto_accept"] === true)
|
if (!this.config.toolPolicy && this.config.providerOptions?.["auto_accept"] === true)
|
||||||
await this.respondToPermission(request.id, { behavior: "allow" });
|
await this.respondToPermission(request.id, { behavior: "allow" });
|
||||||
else this.emit({ type: "permission_requested", provider: "opencode", request: pending });
|
else this.emit({ type: "permission_requested", provider: "opencode", request: pending });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
|
import { applyProviderOptionsPatch } from "../../../option-controls.js";
|
||||||
|
import { assertChangesAreLive, validateProviderOptions } from "../../../provider-options.js";
|
||||||
|
import { OpenCodeProviderOptionsSchema } from "../options.js";
|
||||||
import { SessionChildren } from "./children.js";
|
import { SessionChildren } from "./children.js";
|
||||||
import { SessionTurns } from "./turns.js";
|
import { SessionTurns } from "./turns.js";
|
||||||
import { V2Timeline } from "./timeline.js";
|
import { V2Timeline } from "./timeline.js";
|
||||||
@@ -31,7 +35,7 @@ import { type V2Connection } from "./runtime.js";
|
|||||||
import { modelRef, modesFromV2 } from "./mapping.js";
|
import { modelRef, modesFromV2 } from "./mapping.js";
|
||||||
|
|
||||||
import { V2_CAPABILITIES } from "./capabilities.js";
|
import { V2_CAPABILITIES } from "./capabilities.js";
|
||||||
import { features } from "./configuration.js";
|
import { controls } from "./configuration.js";
|
||||||
import { commands } from "./commands.js";
|
import { commands } from "./commands.js";
|
||||||
import { messages } from "./history.js";
|
import { messages } from "./history.js";
|
||||||
import { SessionPermissions } from "./permissions.js";
|
import { SessionPermissions } from "./permissions.js";
|
||||||
@@ -118,8 +122,8 @@ export class OpenCodeV2Session implements AgentSession {
|
|||||||
get id() {
|
get id() {
|
||||||
return this.info.id;
|
return this.info.id;
|
||||||
}
|
}
|
||||||
get features() {
|
get controls() {
|
||||||
return features(this.config);
|
return controls;
|
||||||
}
|
}
|
||||||
private get client() {
|
private get client() {
|
||||||
return this.connection.client;
|
return this.connection.client;
|
||||||
@@ -358,14 +362,39 @@ export class OpenCodeV2Session implements AgentSession {
|
|||||||
this.info = await this.client.session.get({ sessionID: this.id });
|
this.info = await this.client.session.get({ sessionID: this.id });
|
||||||
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
|
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
|
||||||
}
|
}
|
||||||
async setFeature(id: string, value: unknown) {
|
/** Overlapping applies run one after another, so a failed one restores exactly what it changed. */
|
||||||
if (id !== "auto_accept" || typeof value !== "boolean")
|
private applying: Promise<unknown> = Promise.resolve();
|
||||||
throw new Error("Unknown OpenCode feature");
|
applyOptions(options: ProviderOptions): Promise<void> {
|
||||||
this.config.featureValues = { ...this.config.featureValues, [id]: value };
|
const run = this.applying.then(() => this.applyOptionsNow(options));
|
||||||
|
this.applying = run.catch(() => undefined);
|
||||||
|
return run;
|
||||||
|
}
|
||||||
|
private async applyOptionsNow(options: ProviderOptions): Promise<void> {
|
||||||
|
assertChangesAreLive({
|
||||||
|
provider: "opencode",
|
||||||
|
patch: options,
|
||||||
|
current: this.config.providerOptions,
|
||||||
|
live: ["auto_accept"],
|
||||||
|
});
|
||||||
|
const next = validateProviderOptions(
|
||||||
|
"opencode",
|
||||||
|
OpenCodeProviderOptionsSchema,
|
||||||
|
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
|
||||||
|
);
|
||||||
|
if (!("auto_accept" in options)) return;
|
||||||
|
const value = next?.auto_accept === true;
|
||||||
|
const previous = this.config.providerOptions;
|
||||||
|
this.config.providerOptions = { ...previous, auto_accept: value };
|
||||||
|
try {
|
||||||
if (value && !this.config.toolPolicy)
|
if (value && !this.config.toolPolicy)
|
||||||
for (const request of this.permissions.list())
|
for (const request of this.permissions.list())
|
||||||
if (request.kind !== "question")
|
if (request.kind !== "question")
|
||||||
await this.respondToPermission(request.id, { behavior: "allow" });
|
await this.respondToPermission(request.id, { behavior: "allow" });
|
||||||
|
} catch (error) {
|
||||||
|
// A rejected apply stores nothing, so the running session keeps asking as it did.
|
||||||
|
this.config.providerOptions = previous;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
async listCommands() {
|
async listCommands() {
|
||||||
await this.reconnectIfExited();
|
await this.reconnectIfExited();
|
||||||
|
|||||||
@@ -2961,12 +2961,12 @@ describe("PiRpcAgentClient", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("lists no draft features without starting a Pi session", async () => {
|
test("lists no draft controls without starting a Pi session", async () => {
|
||||||
const pi = new FakePi();
|
const pi = new FakePi();
|
||||||
const client = createClient(pi);
|
const client = createClient(pi);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
client.listFeatures(createConfig({ model: "openrouter/test/model" })),
|
client.listControls(createConfig({ model: "openrouter/test/model" })),
|
||||||
).resolves.toEqual([]);
|
).resolves.toEqual([]);
|
||||||
|
|
||||||
expect(pi.recordedLaunches).toHaveLength(0);
|
expect(pi.recordedLaunches).toHaveLength(0);
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
type AgentCapabilityFlags,
|
type AgentCapabilityFlags,
|
||||||
type AgentClient,
|
type AgentClient,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentLaunchContext,
|
type AgentLaunchContext,
|
||||||
type AgentMetadata,
|
type AgentMetadata,
|
||||||
type AgentMode,
|
type AgentMode,
|
||||||
@@ -2828,7 +2828,7 @@ export class PiRpcAgentClient implements AgentClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
|
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// A stdio ACP agent for tests. argv[2] is a JSON scenario:
|
||||||
|
// options: the session's config options
|
||||||
|
// answers: { [configId]: value } the agent ends up with instead of the requested value
|
||||||
|
// onWrite: "notify-first" sends config_option_update before answering the write;
|
||||||
|
// "notify-then-reject" does the same and then fails the write
|
||||||
|
// "slow-reject" moves the select and announces it for a non-empty value, then refuses
|
||||||
|
// after a delay; an empty value is refused without moving anything
|
||||||
|
// moves: { [configId]: { [id]: value } } writes that also move another select, announced
|
||||||
|
// by a config_option_update after the write is answered
|
||||||
|
// adds: { [configId]: option[] } writes that make more selects appear, announced the same way
|
||||||
|
import { Readable, Writable } from "node:stream";
|
||||||
|
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
|
||||||
|
|
||||||
|
const scenario = JSON.parse(process.argv[2] ?? "{}");
|
||||||
|
let options = scenario.options ?? [];
|
||||||
|
|
||||||
|
function set(id, value) {
|
||||||
|
options = options.map((option) =>
|
||||||
|
option.id === id ? { ...option, currentValue: value } : option,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = ndJsonStream(Writable.toWeb(process.stdout), Readable.toWeb(process.stdin));
|
||||||
|
export const connection = new AgentSideConnection(
|
||||||
|
(agentConnection) => ({
|
||||||
|
async initialize() {
|
||||||
|
return { protocolVersion: 1, agentCapabilities: {} };
|
||||||
|
},
|
||||||
|
async newSession() {
|
||||||
|
return { sessionId: "session-1", modes: null, models: null, configOptions: options };
|
||||||
|
},
|
||||||
|
async setSessionConfigOption({ sessionId, configId, value }) {
|
||||||
|
if (scenario.onWrite === "slow-reject" && !value) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
||||||
|
throw new Error("write refused");
|
||||||
|
}
|
||||||
|
set(configId, scenario.answers?.[configId] ?? value);
|
||||||
|
const announce = () =>
|
||||||
|
agentConnection.sessionUpdate({
|
||||||
|
sessionId,
|
||||||
|
update: { sessionUpdate: "config_option_update", configOptions: options },
|
||||||
|
});
|
||||||
|
if (scenario.onWrite === "slow-reject") {
|
||||||
|
await announce();
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
throw new Error("write refused");
|
||||||
|
}
|
||||||
|
if (scenario.onWrite === "notify-first" || scenario.onWrite === "notify-then-reject") {
|
||||||
|
await announce();
|
||||||
|
if (scenario.onWrite === "notify-then-reject") throw new Error("write refused");
|
||||||
|
return { configOptions: options };
|
||||||
|
}
|
||||||
|
const moves = scenario.moves?.[configId];
|
||||||
|
const adds = scenario.adds?.[configId];
|
||||||
|
if (moves || adds) {
|
||||||
|
for (const [id, moved] of Object.entries(moves ?? {})) set(id, moved);
|
||||||
|
options = [...options, ...(adds ?? [])];
|
||||||
|
setTimeout(() => void announce(), 20);
|
||||||
|
}
|
||||||
|
return { configOptions: options };
|
||||||
|
},
|
||||||
|
async authenticate() {
|
||||||
|
return {};
|
||||||
|
},
|
||||||
|
async prompt() {
|
||||||
|
return { stopReason: "end_turn" };
|
||||||
|
},
|
||||||
|
async cancel() {},
|
||||||
|
}),
|
||||||
|
stream,
|
||||||
|
);
|
||||||
@@ -18,6 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-
|
|||||||
import type { AgentManager } from "../agent-manager.js";
|
import type { AgentManager } from "../agent-manager.js";
|
||||||
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
|
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
|
||||||
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||||
|
import { featuresFromControls } from "../option-controls.js";
|
||||||
import {
|
import {
|
||||||
AgentFeatureSchema,
|
AgentFeatureSchema,
|
||||||
AgentPermissionRequestPayloadSchema,
|
AgentPermissionRequestPayloadSchema,
|
||||||
@@ -939,9 +940,6 @@ function createToolCatalog(
|
|||||||
...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions
|
...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions
|
||||||
? { providerOptions: callerAgent.config.providerOptions }
|
? { providerOptions: callerAgent.config.providerOptions }
|
||||||
: {}),
|
: {}),
|
||||||
...(callerAgent.config.featureValues
|
|
||||||
? { featureValues: callerAgent.config.featureValues }
|
|
||||||
: {}),
|
|
||||||
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
|
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
|
||||||
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
|
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
|
||||||
};
|
};
|
||||||
@@ -1638,7 +1636,7 @@ function createToolCatalog(
|
|||||||
cwd: resolvedArgs.cwd,
|
cwd: resolvedArgs.cwd,
|
||||||
workspaceId: resolvedArgs.workspaceId,
|
workspaceId: resolvedArgs.workspaceId,
|
||||||
thinking: parsedArgs.settings?.thinkingOptionId,
|
thinking: parsedArgs.settings?.thinkingOptionId,
|
||||||
features: parsedArgs.settings?.features,
|
providerOptions: parsedArgs.settings?.features,
|
||||||
labels: {
|
labels: {
|
||||||
...parsedArgs.labels,
|
...parsedArgs.labels,
|
||||||
...(callerAgentId && !resolvedArgs.detached && options.serverId
|
...(callerAgentId && !resolvedArgs.detached && options.serverId
|
||||||
@@ -2290,9 +2288,7 @@ function createToolCatalog(
|
|||||||
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
|
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
|
||||||
}
|
}
|
||||||
if (settings?.features) {
|
if (settings?.features) {
|
||||||
for (const [featureId, value] of Object.entries(settings.features)) {
|
await agentManager.setAgentOptions(agentId, settings.features);
|
||||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await updateAgentCommand({ agentManager }, { agentId, name, labels });
|
await updateAgentCommand({ agentManager }, { agentId, name, labels });
|
||||||
@@ -3070,8 +3066,8 @@ function createToolCatalog(
|
|||||||
"List agent profiles: named provider/model/mode bundles a human configured for specific " +
|
"List agent profiles: named provider/model/mode bundles a human configured for specific " +
|
||||||
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
|
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
|
||||||
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
|
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
|
||||||
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " +
|
"`options` into create_agent as `settings.features` (there is no `profile` parameter). " +
|
||||||
"list if none are configured.",
|
"Returns an empty list if none are configured.",
|
||||||
inputSchema: {},
|
inputSchema: {},
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
profiles: z.array(AgentProfileSchema),
|
profiles: z.array(AgentProfileSchema),
|
||||||
@@ -3124,13 +3120,13 @@ function createToolCatalog(
|
|||||||
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
|
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
|
||||||
}
|
}
|
||||||
const selectedModel = settings?.model ?? resolvedProviderModel.model;
|
const selectedModel = settings?.model ?? resolvedProviderModel.model;
|
||||||
const features = await agentManager.listDraftFeatures({
|
const { controls, options: effectiveOptions } = await agentManager.listDraftControls({
|
||||||
provider: providerId,
|
provider: providerId,
|
||||||
cwd: resolvedCwd,
|
cwd: resolvedCwd,
|
||||||
...(settings?.modeId ? { modeId: settings.modeId } : {}),
|
...(settings?.modeId ? { modeId: settings.modeId } : {}),
|
||||||
...(selectedModel ? { model: selectedModel } : {}),
|
...(selectedModel ? { model: selectedModel } : {}),
|
||||||
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
|
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
|
||||||
...(settings?.features ? { featureValues: settings.features } : {}),
|
...(settings?.features ? { providerOptions: settings.features } : {}),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
content: [],
|
content: [],
|
||||||
@@ -3142,7 +3138,7 @@ function createToolCatalog(
|
|||||||
status: summary.status,
|
status: summary.status,
|
||||||
modes: summary.modes,
|
modes: summary.modes,
|
||||||
selectedModel: selectedModel ?? null,
|
selectedModel: selectedModel ?? null,
|
||||||
features,
|
features: featuresFromControls({ controls, options: effectiveOptions }),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ const INBOUND_PERMISSION = {
|
|||||||
list_available_providers_request: "daemon.read",
|
list_available_providers_request: "daemon.read",
|
||||||
list_commands_request: "daemon.read",
|
list_commands_request: "daemon.read",
|
||||||
list_provider_features_request: "daemon.read",
|
list_provider_features_request: "daemon.read",
|
||||||
|
"provider.controls.list.request": "daemon.read",
|
||||||
list_provider_models_request: "daemon.read",
|
list_provider_models_request: "daemon.read",
|
||||||
list_provider_modes_request: "daemon.read",
|
list_provider_modes_request: "daemon.read",
|
||||||
list_terminals_request: "workspace.read",
|
list_terminals_request: "workspace.read",
|
||||||
@@ -356,6 +357,7 @@ const OUTBOUND_PERMISSION = {
|
|||||||
list_available_providers_response: "daemon.read",
|
list_available_providers_response: "daemon.read",
|
||||||
list_commands_response: "daemon.read",
|
list_commands_response: "daemon.read",
|
||||||
list_provider_features_response: "daemon.read",
|
list_provider_features_response: "daemon.read",
|
||||||
|
"provider.controls.list.response": "daemon.read",
|
||||||
list_provider_models_response: "daemon.read",
|
list_provider_models_response: "daemon.read",
|
||||||
list_provider_modes_response: "daemon.read",
|
list_provider_modes_response: "daemon.read",
|
||||||
list_terminals_response: "workspace.read",
|
list_terminals_response: "workspace.read",
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
|
|||||||
await paseo.dispose();
|
await paseo.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
|
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
|
||||||
const b = await daemon();
|
const b = await daemon();
|
||||||
const a = await daemon();
|
const a = await daemon();
|
||||||
const owner = await api(a);
|
const owner = await api(a);
|
||||||
@@ -210,16 +210,33 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
|
|||||||
code: "permission_denied",
|
code: "permission_denied",
|
||||||
});
|
});
|
||||||
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
||||||
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
|
await remote.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("SDK host clients preserve ordinary permission RPC errors", async () => {
|
||||||
|
const b = await daemon();
|
||||||
|
const a = await daemon();
|
||||||
|
const owner = await api(a);
|
||||||
|
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||||
|
const serverId = (await online(owner.paseo)).hosts[0]!.serverId!;
|
||||||
|
const clients = [a, b].map((host) =>
|
||||||
|
createHostClient(`127.0.0.1:${host.port}`, {
|
||||||
clientId: crypto.randomUUID(),
|
clientId: crypto.randomUUID(),
|
||||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||||
reconnect: { enabled: false },
|
reconnect: { enabled: false },
|
||||||
});
|
}),
|
||||||
await directClient.connect();
|
);
|
||||||
const direct = createPaseoApi(directClient);
|
for (const client of clients) {
|
||||||
|
cleanup.push(() => client.close());
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
const origin = createPaseoApi(clients[0]!);
|
||||||
|
const direct = createPaseoApi(clients[1]!);
|
||||||
|
const remote = await origin.hosts.client(serverId);
|
||||||
cleanup.push(async () => {
|
cleanup.push(async () => {
|
||||||
|
await remote.dispose();
|
||||||
await direct.dispose();
|
await direct.dispose();
|
||||||
await directClient.close();
|
await origin.dispose();
|
||||||
});
|
});
|
||||||
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
||||||
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
||||||
@@ -230,7 +247,6 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
|
|||||||
};
|
};
|
||||||
expect(directError.code).toBe("access_denied");
|
expect(directError.code).toBe("access_denied");
|
||||||
expect(remoteError.code).toBe(directError.code);
|
expect(remoteError.code).toBe(directError.code);
|
||||||
await remote.dispose();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
||||||
@@ -768,211 +784,6 @@ test("old capabilities fail before execution and authentication errors recover t
|
|||||||
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
|
||||||
});
|
});
|
||||||
|
|
||||||
async function updatableDestination() {
|
|
||||||
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
|
|
||||||
let holding = false;
|
|
||||||
const held: (() => void)[] = [];
|
|
||||||
let connections = 0;
|
|
||||||
const http = createServer();
|
|
||||||
const ws = new WebSocketServer({ server: http });
|
|
||||||
ws.on("connection", (socket) => {
|
|
||||||
connections++;
|
|
||||||
socket.on("message", (data) => {
|
|
||||||
const frame = JSON.parse(data.toString());
|
|
||||||
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
|
|
||||||
if (frame.type !== "hello") return;
|
|
||||||
const reply = () =>
|
|
||||||
socket.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "session",
|
|
||||||
message: {
|
|
||||||
type: "status",
|
|
||||||
payload: {
|
|
||||||
status: "server_info",
|
|
||||||
serverId: "srv_updated",
|
|
||||||
hostname: "updated",
|
|
||||||
version: "0.12.0",
|
|
||||||
...(features ? { features } : {}),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
if (holding) held.push(reply);
|
|
||||||
else reply();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
http.listen(0, "127.0.0.1");
|
|
||||||
await once(http, "listening");
|
|
||||||
cleanup.push(async () => {
|
|
||||||
for (const socket of ws.clients) socket.terminate();
|
|
||||||
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
|
||||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
|
||||||
});
|
|
||||||
const address = http.address();
|
|
||||||
if (!address || typeof address === "string") throw new Error("Missing destination port");
|
|
||||||
return {
|
|
||||||
port: address.port,
|
|
||||||
connections: () => connections,
|
|
||||||
hold: () => {
|
|
||||||
holding = true;
|
|
||||||
},
|
|
||||||
update: () => {
|
|
||||||
features = { connectedHosts: true, hostMultiplexing: true };
|
|
||||||
holding = false;
|
|
||||||
for (const reply of held.splice(0)) reply();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
|
|
||||||
let retryAt = "";
|
|
||||||
await expect
|
|
||||||
.poll(
|
|
||||||
async () => {
|
|
||||||
const row = (await paseo.hosts.list()).hosts[0];
|
|
||||||
retryAt = row?.nextRetryAt ?? "";
|
|
||||||
return row;
|
|
||||||
},
|
|
||||||
{ timeout: 5000 },
|
|
||||||
)
|
|
||||||
.toMatchObject({
|
|
||||||
status: "offline",
|
|
||||||
nextRetryAt: expect.any(String),
|
|
||||||
error: { code: "unsupported_capability" },
|
|
||||||
});
|
|
||||||
return Date.parse(retryAt);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
|
|
||||||
const destination = await updatableDestination();
|
|
||||||
const a = await daemon();
|
|
||||||
const { paseo } = await api(a);
|
|
||||||
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
|
||||||
await capabilityRetry(paseo);
|
|
||||||
destination.hold();
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
|
|
||||||
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
|
|
||||||
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
|
|
||||||
code: "unsupported_capability",
|
|
||||||
});
|
|
||||||
destination.update();
|
|
||||||
expect((await online(paseo)).hosts[0]).toMatchObject({
|
|
||||||
registrationId: host.registrationId,
|
|
||||||
serverId: "srv_updated",
|
|
||||||
nextRetryAt: null,
|
|
||||||
error: null,
|
|
||||||
});
|
|
||||||
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
|
|
||||||
await expect
|
|
||||||
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
|
|
||||||
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("disabling or removing a host cancels its pending capability retry", async () => {
|
|
||||||
const destination = await updatableDestination();
|
|
||||||
const a = await daemon();
|
|
||||||
const { paseo } = await api(a);
|
|
||||||
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
|
|
||||||
let retryAt = await capabilityRetry(paseo);
|
|
||||||
let connections = destination.connections();
|
|
||||||
await paseo.hosts.update(host.registrationId, { enabled: false });
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
|
||||||
expect(destination.connections()).toBe(connections);
|
|
||||||
await paseo.hosts.update(host.registrationId, { enabled: true });
|
|
||||||
retryAt = await capabilityRetry(paseo);
|
|
||||||
connections = destination.connections();
|
|
||||||
await paseo.hosts.remove(host.registrationId);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
|
|
||||||
expect(destination.connections()).toBe(connections);
|
|
||||||
});
|
|
||||||
|
|
||||||
import {
|
|
||||||
createDaemonChannel,
|
|
||||||
exportPublicKey,
|
|
||||||
generateKeyPair,
|
|
||||||
openDaemonSecureChannel,
|
|
||||||
type Transport,
|
|
||||||
} from "@getpaseo/relay/e2ee";
|
|
||||||
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
|
|
||||||
const keys = generateKeyPair();
|
|
||||||
const legacyReceived: unknown[] = [];
|
|
||||||
let connections = 0;
|
|
||||||
let retryClosed = false;
|
|
||||||
const http = createServer();
|
|
||||||
const ws = new WebSocketServer({ server: http });
|
|
||||||
ws.on("connection", (socket) => {
|
|
||||||
connections++;
|
|
||||||
const wire: Transport = {
|
|
||||||
send: (data) => socket.send(data),
|
|
||||||
close: (code, reason) => socket.close(code, reason),
|
|
||||||
onmessage: null,
|
|
||||||
onclose: null,
|
|
||||||
onerror: null,
|
|
||||||
};
|
|
||||||
socket.on("message", (data, isBinary) => {
|
|
||||||
const bytes = Buffer.from(data as Buffer);
|
|
||||||
wire.onmessage?.({
|
|
||||||
data: isBinary
|
|
||||||
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
|
|
||||||
: bytes.toString(),
|
|
||||||
isBinary,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
|
|
||||||
if (connections > 1) {
|
|
||||||
socket.on("close", () => {
|
|
||||||
retryClosed = true;
|
|
||||||
});
|
|
||||||
void createDaemonChannel(wire, keys, {
|
|
||||||
onmessage: (data) => legacyReceived.push(data),
|
|
||||||
}).catch(() => {});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const channel = openDaemonSecureChannel(wire, {
|
|
||||||
daemonKeyPair: keys,
|
|
||||||
events: {
|
|
||||||
onmessage: (data) => {
|
|
||||||
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
|
|
||||||
void channel.then((secure) =>
|
|
||||||
secure.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "session",
|
|
||||||
message: {
|
|
||||||
type: "status",
|
|
||||||
payload: {
|
|
||||||
status: "server_info",
|
|
||||||
serverId: "srv_relayed",
|
|
||||||
hostname: "relayed",
|
|
||||||
version: "0.12.0",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
void channel.catch(() => {});
|
|
||||||
});
|
|
||||||
http.listen(0, "127.0.0.1");
|
|
||||||
await once(http, "listening");
|
|
||||||
cleanup.push(async () => {
|
|
||||||
for (const socket of ws.clients) socket.terminate();
|
|
||||||
await new Promise<void>((resolve) => ws.close(() => resolve()));
|
|
||||||
await new Promise<void>((resolve) => http.close(() => resolve()));
|
|
||||||
});
|
|
||||||
const address = http.address();
|
|
||||||
if (!address || typeof address === "string") throw new Error("Missing relay port");
|
|
||||||
const a = await daemon();
|
|
||||||
const { paseo } = await api(a);
|
|
||||||
await paseo.hosts.add({
|
|
||||||
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
|
|
||||||
});
|
|
||||||
await capabilityRetry(paseo);
|
|
||||||
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
|
|
||||||
expect(legacyReceived).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
|
||||||
const reservation = createServer();
|
const reservation = createServer();
|
||||||
reservation.listen(0, "127.0.0.1");
|
reservation.listen(0, "127.0.0.1");
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ import { z } from "zod";
|
|||||||
import {
|
import {
|
||||||
createHostClient,
|
createHostClient,
|
||||||
createHostChannelTransport,
|
createHostChannelTransport,
|
||||||
createHostTransportFactory,
|
|
||||||
parseHostTarget,
|
parseHostTarget,
|
||||||
HostTransportBudget,
|
HostTransportBudget,
|
||||||
} from "@getpaseo/client/node";
|
} from "@getpaseo/client/node";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
|
|
||||||
import {
|
import {
|
||||||
HostSummarySchema,
|
HostSummarySchema,
|
||||||
HostAddSchema,
|
HostAddSchema,
|
||||||
@@ -237,30 +235,23 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === id)
|
if (route.registrationId === id)
|
||||||
route.close(code, "Host access was disabled, replaced or removed");
|
route.close(code, "Host access was disabled, replaced or removed");
|
||||||
}
|
}
|
||||||
private start(
|
private start(row: Saved): void {
|
||||||
row: Saved,
|
|
||||||
resume?: { failures: number; transports: DaemonTransportFactory },
|
|
||||||
): void {
|
|
||||||
if (!row.enabled || this.closed) return;
|
if (!row.enabled || this.closed) return;
|
||||||
const transports =
|
|
||||||
resume?.transports ??
|
|
||||||
createHostTransportFactory(row.connection, this.budget, {
|
|
||||||
webSocketAgent: webSocketProxyAgent,
|
|
||||||
});
|
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
let attempts = resume?.failures ?? 0;
|
const client = createHostClient(
|
||||||
let retry: ReturnType<typeof setTimeout> | undefined;
|
row.connection,
|
||||||
const client = createHostClient(row.connection, {
|
{
|
||||||
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
|
||||||
connectTimeoutMs: 10000,
|
connectTimeoutMs: 10000,
|
||||||
transportFactory: transports,
|
webSocketAgent: webSocketProxyAgent,
|
||||||
});
|
},
|
||||||
|
this.budget,
|
||||||
|
);
|
||||||
const live: Live = {
|
const live: Live = {
|
||||||
client,
|
client,
|
||||||
accepted: false,
|
accepted: false,
|
||||||
stop: () => {
|
stop: () => {
|
||||||
stopped = true;
|
stopped = true;
|
||||||
clearTimeout(retry);
|
|
||||||
void client.close();
|
void client.close();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -294,7 +285,7 @@ export class ConnectedHosts {
|
|||||||
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
|
||||||
throw new HostError(
|
throw new HostError(
|
||||||
"unsupported_capability",
|
"unsupported_capability",
|
||||||
"Update the destination daemon to use connected hosts; retrying automatically.",
|
"Update the destination daemon to use connected hosts.",
|
||||||
);
|
);
|
||||||
// Pin and persist under the same writer as management to prevent discovery races.
|
// Pin and persist under the same writer as management to prevent discovery races.
|
||||||
await this.mutate(async () => {
|
await this.mutate(async () => {
|
||||||
@@ -313,22 +304,16 @@ export class ConnectedHosts {
|
|||||||
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
|
||||||
Object.assign(row, next);
|
Object.assign(row, next);
|
||||||
live.accepted = true;
|
live.accepted = true;
|
||||||
attempts = 0;
|
|
||||||
this.changed();
|
this.changed();
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (stopped) return;
|
if (stopped) return;
|
||||||
|
row.status = "error";
|
||||||
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
|
||||||
code: "host_offline",
|
code: "host_offline",
|
||||||
message: "Host connection failed",
|
message: "Host connection failed",
|
||||||
};
|
};
|
||||||
if (terminalFailures.has(row.error.code)) {
|
|
||||||
row.status = "error";
|
|
||||||
row.nextRetryAt = null;
|
row.nextRetryAt = null;
|
||||||
} else {
|
|
||||||
stopped = true;
|
|
||||||
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
|
|
||||||
}
|
|
||||||
this.changed();
|
this.changed();
|
||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
@@ -344,8 +329,9 @@ export class ConnectedHosts {
|
|||||||
if (route.registrationId === row.registrationId) route.close();
|
if (route.registrationId === row.registrationId) route.close();
|
||||||
row.status = state.status === "connecting" ? "connecting" : "offline";
|
row.status = state.status === "connecting" ? "connecting" : "offline";
|
||||||
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
|
||||||
if (state.status === "disconnected")
|
row.error =
|
||||||
row.error = connectionFailure(
|
state.status === "disconnected"
|
||||||
|
? (connectionFailure(
|
||||||
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
|
||||||
code: state.failureCode,
|
code: state.failureCode,
|
||||||
}),
|
}),
|
||||||
@@ -353,8 +339,8 @@ export class ConnectedHosts {
|
|||||||
) ?? {
|
) ?? {
|
||||||
code: "host_offline",
|
code: "host_offline",
|
||||||
message: "Cannot reach host. Check the connection; retrying automatically.",
|
message: "Cannot reach host. Check the connection; retrying automatically.",
|
||||||
};
|
})
|
||||||
else if (row.error?.code !== "unsupported_capability") row.error = null;
|
: null;
|
||||||
if (row.error?.code === "authentication_failed") {
|
if (row.error?.code === "authentication_failed") {
|
||||||
row.status = "error";
|
row.status = "error";
|
||||||
row.nextRetryAt = null;
|
row.nextRetryAt = null;
|
||||||
@@ -365,18 +351,6 @@ export class ConnectedHosts {
|
|||||||
});
|
});
|
||||||
void client.connect().catch(() => {});
|
void client.connect().catch(() => {});
|
||||||
}
|
}
|
||||||
private scheduleAcceptanceRetry(
|
|
||||||
row: Saved,
|
|
||||||
failures: number,
|
|
||||||
transports: DaemonTransportFactory,
|
|
||||||
): ReturnType<typeof setTimeout> {
|
|
||||||
const delay = acceptanceRetryDelayMs(failures);
|
|
||||||
row.status = "offline";
|
|
||||||
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
|
|
||||||
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
|
|
||||||
retry.unref();
|
|
||||||
return retry;
|
|
||||||
}
|
|
||||||
async openRoute(input: {
|
async openRoute(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
@@ -557,20 +531,6 @@ export class ConnectedHosts {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCEPTANCE_RETRY_BASE_MS = 1500;
|
|
||||||
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
|
|
||||||
|
|
||||||
function acceptanceRetryDelayMs(failures: number): number {
|
|
||||||
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
const terminalFailures = new Set([
|
|
||||||
"server_id_mismatch",
|
|
||||||
"self_host",
|
|
||||||
"duplicate_host",
|
|
||||||
"authentication_failed",
|
|
||||||
]);
|
|
||||||
|
|
||||||
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
|
||||||
if (error instanceof HostError) return { code: error.code, message: error.message };
|
if (error instanceof HostError) return { code: error.code, message: error.message };
|
||||||
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
if (error instanceof Error && "code" in error && error.code === "authentication_failed")
|
||||||
|
|||||||
@@ -180,6 +180,56 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => {
|
||||||
|
const clients = createTestAgentClients();
|
||||||
|
const codex = clients.codex as AgentClient;
|
||||||
|
const applied: unknown[] = [];
|
||||||
|
// Records what reaches the provider session; behavior is the fake client's.
|
||||||
|
const recordingCodex: AgentClient = Object.create(codex, {
|
||||||
|
createSession: {
|
||||||
|
value: async (...args: Parameters<AgentClient["createSession"]>) => {
|
||||||
|
const session = await codex.createSession(...args);
|
||||||
|
const apply = session.applyOptions?.bind(session);
|
||||||
|
session.applyOptions = async (options) => {
|
||||||
|
applied.push(options);
|
||||||
|
await apply?.(options);
|
||||||
|
};
|
||||||
|
return session;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const daemon = await createTestPaseoDaemon({
|
||||||
|
agentClients: { ...clients, codex: recordingCodex },
|
||||||
|
});
|
||||||
|
const client = new DaemonClient({
|
||||||
|
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||||
|
appVersion: "0.1.82",
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.connect();
|
||||||
|
await client.fetchAgents({ subscribe: {} });
|
||||||
|
const agent = await client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
cwd: tmpCwd(),
|
||||||
|
title: "Options agent",
|
||||||
|
modeId: "full-access",
|
||||||
|
model: "gpt-5.4-mini",
|
||||||
|
});
|
||||||
|
expect(agent.options?.sandbox_mode).toBeUndefined();
|
||||||
|
|
||||||
|
await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } });
|
||||||
|
|
||||||
|
expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]);
|
||||||
|
const next = await client.fetchAgent(agent.id);
|
||||||
|
expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" });
|
||||||
|
expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode");
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
|
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
|
||||||
const daemon = await createTestPaseoDaemon();
|
const daemon = await createTestPaseoDaemon();
|
||||||
const client = new DaemonClient({
|
const client = new DaemonClient({
|
||||||
|
|||||||
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterEach, describe, expect, test } from "vitest";
|
import { afterEach, describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
import {
|
||||||
|
DaemonConfigStore,
|
||||||
|
applyMutableProviderConfigToOverrides,
|
||||||
|
toWireDaemonConfig,
|
||||||
|
} from "./daemon-config-store.js";
|
||||||
import { loadConfig } from "./config.js";
|
import { loadConfig } from "./config.js";
|
||||||
import { loadPersistedConfig } from "./persisted-config.js";
|
import { loadPersistedConfig } from "./persisted-config.js";
|
||||||
import { readSpeechSettings } from "./speech/speech-settings.js";
|
import { readSpeechSettings } from "./speech/speech-settings.js";
|
||||||
@@ -196,13 +200,312 @@ describe("DaemonConfigStore", () => {
|
|||||||
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: "Use for components, layout and design tokens.",
|
notes: "Use for components, layout and design tokens.",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(store.get().agentProfiles).toHaveLength(1);
|
expect(store.get().agentProfiles).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a profile written with featureValues is stored as options, and the config API answers with both", async () => {
|
||||||
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
|
tempDirs.push(paseoHome);
|
||||||
|
const store = new DaemonConfigStore(paseoHome, {
|
||||||
|
relay: { enabled: false },
|
||||||
|
mcp: { injectIntoAgents: false },
|
||||||
|
browserTools: { enabled: false },
|
||||||
|
providers: {},
|
||||||
|
metadataGeneration: { providers: [] },
|
||||||
|
autoArchiveAfterMerge: false,
|
||||||
|
enableTerminalAgentHooks: false,
|
||||||
|
appendSystemPrompt: "",
|
||||||
|
});
|
||||||
|
// The unchanged app sends back the `options` it read plus the `featureValues` it edited.
|
||||||
|
await store.patch({
|
||||||
|
agentProfiles: [
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
provider: "claude",
|
||||||
|
options: { fast_mode: false },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
|
||||||
|
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
|
||||||
|
]);
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
|
||||||
|
]);
|
||||||
|
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
provider: "claude",
|
||||||
|
options: { fast_mode: true },
|
||||||
|
featureValues: { fast_mode: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a profile edited by the unchanged app", () => {
|
||||||
|
function storeWithProfile(): DaemonConfigStore {
|
||||||
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
|
tempDirs.push(paseoHome);
|
||||||
|
return new DaemonConfigStore(paseoHome, {
|
||||||
|
relay: { enabled: false },
|
||||||
|
mcp: { injectIntoAgents: false },
|
||||||
|
browserTools: { enabled: false },
|
||||||
|
providers: {},
|
||||||
|
metadataGeneration: { providers: [] },
|
||||||
|
autoArchiveAfterMerge: false,
|
||||||
|
enableTerminalAgentHooks: false,
|
||||||
|
appendSystemPrompt: "",
|
||||||
|
agentProfiles: [
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
provider: "codex",
|
||||||
|
options: { sandbox_mode: "read-only", plan_mode: false },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("keeps the options its form never shows when it changes a control", async () => {
|
||||||
|
const store = storeWithProfile();
|
||||||
|
|
||||||
|
await store.patch({
|
||||||
|
agentProfiles: [
|
||||||
|
{ id: "p", name: "Renamed", provider: "codex", featureValues: { plan_mode: true } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "Renamed",
|
||||||
|
provider: "codex",
|
||||||
|
options: { sandbox_mode: "read-only", plan_mode: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps every option when it sends no featureValues at all", async () => {
|
||||||
|
const store = storeWithProfile();
|
||||||
|
|
||||||
|
await store.patch({ agentProfiles: [{ id: "p", name: "Renamed", provider: "codex" }] });
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "Renamed",
|
||||||
|
provider: "codex",
|
||||||
|
options: { sandbox_mode: "read-only", plan_mode: false },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("starts from no options when it moves the profile to another provider", async () => {
|
||||||
|
const store = storeWithProfile();
|
||||||
|
|
||||||
|
await store.patch({
|
||||||
|
agentProfiles: [
|
||||||
|
{ id: "p", name: "P", provider: "claude", featureValues: { fast_mode: true } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("drops the old provider's options when it moves the profile with no featureValues", async () => {
|
||||||
|
const store = storeWithProfile();
|
||||||
|
|
||||||
|
await store.patch({ agentProfiles: [{ id: "p", name: "P", provider: "claude" }] });
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([{ id: "p", name: "P", provider: "claude" }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("folds featureValues over the options a write carries, not over the stored ones", async () => {
|
||||||
|
const store = storeWithProfile();
|
||||||
|
|
||||||
|
await store.patch({
|
||||||
|
agentProfiles: [
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
provider: "codex",
|
||||||
|
options: { sandbox_mode: "workspace-write" },
|
||||||
|
featureValues: { plan_mode: true },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
provider: "codex",
|
||||||
|
options: { sandbox_mode: "workspace-write", plan_mode: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("converts a Codex profile's legacy fast_mode to service_tier", async () => {
|
||||||
|
const store = storeWithProfile();
|
||||||
|
|
||||||
|
await store.patch({
|
||||||
|
agentProfiles: [
|
||||||
|
{ id: "p", name: "P", provider: "codex", featureValues: { fast_mode: true } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
name: "P",
|
||||||
|
provider: "codex",
|
||||||
|
options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("removes an option whose featureValues entry is null", async () => {
|
||||||
|
const store = storeWithProfile();
|
||||||
|
|
||||||
|
await store.patch({
|
||||||
|
agentProfiles: [
|
||||||
|
{ id: "p", name: "P", provider: "codex", featureValues: { plan_mode: null } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{ id: "p", name: "P", provider: "codex", options: { sandbox_mode: "read-only" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a profile stored with featureValues loads as options", () => {
|
||||||
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
|
tempDirs.push(paseoHome);
|
||||||
|
const store = new DaemonConfigStore(paseoHome, {
|
||||||
|
relay: { enabled: false },
|
||||||
|
mcp: { injectIntoAgents: false },
|
||||||
|
browserTools: { enabled: false },
|
||||||
|
providers: {},
|
||||||
|
metadataGeneration: { providers: [] },
|
||||||
|
autoArchiveAfterMerge: false,
|
||||||
|
enableTerminalAgentHooks: false,
|
||||||
|
appendSystemPrompt: "",
|
||||||
|
agentProfiles: [
|
||||||
|
{
|
||||||
|
id: "legacy",
|
||||||
|
name: "Legacy",
|
||||||
|
provider: "codex",
|
||||||
|
featureValues: { service_tier: "fast" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a profile holding Codex's legacy fast_mode", () => {
|
||||||
|
const legacyProfiles = ["codex", "my-codex"].map((provider) => ({
|
||||||
|
id: provider,
|
||||||
|
name: provider,
|
||||||
|
provider,
|
||||||
|
options: { fast_mode: true },
|
||||||
|
}));
|
||||||
|
|
||||||
|
function storeWithLegacyProfiles(agentProfiles: unknown[] = legacyProfiles): DaemonConfigStore {
|
||||||
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
|
tempDirs.push(paseoHome);
|
||||||
|
return new DaemonConfigStore(paseoHome, {
|
||||||
|
relay: { enabled: false },
|
||||||
|
mcp: { injectIntoAgents: false },
|
||||||
|
browserTools: { enabled: false },
|
||||||
|
providers: { "my-codex": { extends: "codex" } },
|
||||||
|
metadataGeneration: { providers: [] },
|
||||||
|
autoArchiveAfterMerge: false,
|
||||||
|
enableTerminalAgentHooks: false,
|
||||||
|
appendSystemPrompt: "",
|
||||||
|
agentProfiles,
|
||||||
|
} as MutableDaemonConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("reads the same for codex and a provider that extends it", () => {
|
||||||
|
const store = storeWithLegacyProfiles();
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{ id: "codex", name: "codex", provider: "codex", options: { service_tier: "fast" } },
|
||||||
|
{
|
||||||
|
id: "my-codex",
|
||||||
|
name: "my-codex",
|
||||||
|
provider: "my-codex",
|
||||||
|
options: { service_tier: "fast" },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
|
||||||
|
expect.objectContaining({ featureValues: { service_tier: "fast" } }),
|
||||||
|
expect.objectContaining({ featureValues: { service_tier: "fast" } }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test.each(["codex", "my-codex"])("resetting %s's speed removes what was shown", async (id) => {
|
||||||
|
const store = storeWithLegacyProfiles();
|
||||||
|
|
||||||
|
await store.patch({
|
||||||
|
agentProfiles: [{ id, name: id, provider: id, featureValues: { service_tier: null } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([{ id, name: id, provider: id, options: {} }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a provider edit in the same write does not hide which adapter a profile runs on", async () => {
|
||||||
|
const store = storeWithLegacyProfiles();
|
||||||
|
|
||||||
|
await store.patch({
|
||||||
|
providers: { "my-codex": { label: "Renamed" } },
|
||||||
|
agentProfiles: [
|
||||||
|
{ id: "p", name: "P", provider: "my-codex", featureValues: { fast_mode: false } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{ id: "p", name: "P", provider: "my-codex", options: { service_tier: "default" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a malformed value rejects the write and leaves config reads working", async () => {
|
||||||
|
const store = storeWithLegacyProfiles();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
store.patch({
|
||||||
|
agentProfiles: [
|
||||||
|
{ id: "p", name: "P", provider: "my-codex", options: { fast_mode: "false" } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/fast_mode must be true, false or null/);
|
||||||
|
|
||||||
|
expect(toWireDaemonConfig(store.get()).agentProfiles).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a malformed value in a stored profile does not stop the config from loading", () => {
|
||||||
|
const store = storeWithLegacyProfiles([
|
||||||
|
{ id: "p", name: "P", provider: "codex", options: { fast_mode: "false" } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles?.[0]?.options).toEqual({ fast_mode: "false" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("patch replaces the whole agent profile list rather than merging entries", async () => {
|
test("patch replaces the whole agent profile list rather than merging entries", async () => {
|
||||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||||
tempDirs.push(paseoHome);
|
tempDirs.push(paseoHome);
|
||||||
@@ -1274,6 +1577,35 @@ describe("DaemonConfigStore reload", () => {
|
|||||||
expect(store.get().agentProfiles).toBeUndefined();
|
expect(store.get().agentProfiles).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("reload folds a profile file written with featureValues into options, and saving it persists options", async () => {
|
||||||
|
const { paseoHome, store, persisted } = createReloadableStore();
|
||||||
|
writeConfig(paseoHome, {
|
||||||
|
...persisted,
|
||||||
|
daemon: {
|
||||||
|
...persisted.daemon,
|
||||||
|
agentProfiles: [
|
||||||
|
{
|
||||||
|
id: "legacy",
|
||||||
|
name: "Legacy",
|
||||||
|
provider: "codex",
|
||||||
|
featureValues: { service_tier: "fast" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await store.reload();
|
||||||
|
|
||||||
|
expect(store.get().agentProfiles).toEqual([
|
||||||
|
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
|
||||||
|
]);
|
||||||
|
// The app reads the profile and writes the list back on its next edit.
|
||||||
|
await store.patch({ agentProfiles: store.get().agentProfiles });
|
||||||
|
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
|
||||||
|
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("reports a launch-controlled edit without changing live state", async () => {
|
test("reports a launch-controlled edit without changing live state", async () => {
|
||||||
const { paseoHome, store, persisted } = createReloadableStore({
|
const { paseoHome, store, persisted } = createReloadableStore({
|
||||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ import {
|
|||||||
type PersistedConfig,
|
type PersistedConfig,
|
||||||
} from "./persisted-config.js";
|
} from "./persisted-config.js";
|
||||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||||
|
import {
|
||||||
|
convertLegacyOptions,
|
||||||
|
foldProfileFeatureValues,
|
||||||
|
resolveAdapter,
|
||||||
|
withLegacyProfileFeatureValues,
|
||||||
|
} from "./agent/option-controls.js";
|
||||||
|
import type { AgentProfile } from "@getpaseo/protocol/agent-profile";
|
||||||
import {
|
import {
|
||||||
MutableDaemonConfigSchema,
|
MutableDaemonConfigSchema,
|
||||||
MutableDaemonConfigPatchSchema,
|
MutableDaemonConfigPatchSchema,
|
||||||
@@ -168,6 +175,54 @@ function omitProvidersFromOverrides(
|
|||||||
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
|
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profiles are held and persisted with `options` only, in the current keys: a profile is old
|
||||||
|
* data entering, so it is converted for the adapter its provider runs on, which `config.json`'s
|
||||||
|
* `providers` section names through `extends`.
|
||||||
|
*/
|
||||||
|
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(input: {
|
||||||
|
config: T;
|
||||||
|
providers: Record<string, ProviderOverride> | undefined;
|
||||||
|
source: "stored" | "client write";
|
||||||
|
held?: AgentProfile[];
|
||||||
|
}): T {
|
||||||
|
const { config, providers, source, held = [] } = input;
|
||||||
|
if (!config.agentProfiles) return config;
|
||||||
|
const convert = (provider: string, options: Record<string, unknown> | undefined) =>
|
||||||
|
convertLegacyOptions({
|
||||||
|
provider: resolveAdapter({ provider, extendsOf: (id) => providers?.[id]?.extends }),
|
||||||
|
options,
|
||||||
|
});
|
||||||
|
const identity = (_provider: string, options: Record<string, unknown> | undefined) => options;
|
||||||
|
const foldWith = (profile: AgentProfile, convertWith: typeof convert) =>
|
||||||
|
foldProfileFeatureValues({
|
||||||
|
profile,
|
||||||
|
stored: held.find((candidate) => candidate.id === profile.id),
|
||||||
|
source,
|
||||||
|
convert: convertWith,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
...config,
|
||||||
|
agentProfiles: config.agentProfiles.map((profile) => {
|
||||||
|
// A malformed value rejects a client write before anything is committed.
|
||||||
|
if (source === "client write") return foldWith(profile, convert);
|
||||||
|
try {
|
||||||
|
return foldWith(profile, convert);
|
||||||
|
} catch {
|
||||||
|
// A hand-edited `config.json` value that cannot convert must not stop the daemon from
|
||||||
|
// starting; it keeps its keys and is rejected when an agent is created from it.
|
||||||
|
return foldWith(profile, identity);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
|
||||||
|
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
|
||||||
|
if (!config.agentProfiles) return config;
|
||||||
|
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
|
||||||
|
}
|
||||||
|
|
||||||
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
|
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
|
||||||
return path
|
return path
|
||||||
.split(".")
|
.split(".")
|
||||||
@@ -380,7 +435,7 @@ export class DaemonConfigStore {
|
|||||||
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
||||||
this.lastKnownPersisted = this.startupPersisted;
|
this.lastKnownPersisted = this.startupPersisted;
|
||||||
this.current = MutableDaemonConfigSchema.parse({
|
this.current = MutableDaemonConfigSchema.parse({
|
||||||
...initial,
|
...foldConfigProfiles({ config: initial, providers: initial.providers, source: "stored" }),
|
||||||
relay: initial.relay ?? { enabled: true },
|
relay: initial.relay ?? { enabled: true },
|
||||||
...this.readSpeech(this.startupPersisted),
|
...this.readSpeech(this.startupPersisted),
|
||||||
});
|
});
|
||||||
@@ -410,6 +465,18 @@ export class DaemonConfigStore {
|
|||||||
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
|
return this.enqueue(() => this.applySupportedPatch({ skills: { selection } }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private foldPatchProfiles<
|
||||||
|
T extends { agentProfiles?: AgentProfile[]; providers?: Record<string, ProviderOverride> },
|
||||||
|
>(submittedPatch: T): T {
|
||||||
|
return foldConfigProfiles({
|
||||||
|
config: submittedPatch,
|
||||||
|
// The provider graph this request will commit, so `extends` survives a partial edit.
|
||||||
|
providers: deepMerge(this.current.providers ?? {}, submittedPatch.providers ?? {}),
|
||||||
|
source: "client write",
|
||||||
|
held: this.current.agentProfiles,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private async applySupportedPatch(
|
private async applySupportedPatch(
|
||||||
parsedPatch: SupportedMutableConfigPatch,
|
parsedPatch: SupportedMutableConfigPatch,
|
||||||
): Promise<MutableDaemonConfig> {
|
): Promise<MutableDaemonConfig> {
|
||||||
@@ -418,7 +485,8 @@ export class DaemonConfigStore {
|
|||||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const { removeProviders = [], ...patch } = parsedPatch;
|
const { removeProviders = [], ...submittedPatch } = parsedPatch;
|
||||||
|
const patch = this.foldPatchProfiles(submittedPatch);
|
||||||
const { features: speechPatch, ...configPatch } = patch;
|
const { features: speechPatch, ...configPatch } = patch;
|
||||||
const removedProviders = Array.from(new Set(removeProviders));
|
const removedProviders = Array.from(new Set(removeProviders));
|
||||||
const merged = deepMerge(this.current, configPatch);
|
const merged = deepMerge(this.current, configPatch);
|
||||||
@@ -437,7 +505,16 @@ export class DaemonConfigStore {
|
|||||||
const configChanged = !isEqualValue(this.current, next);
|
const configChanged = !isEqualValue(this.current, next);
|
||||||
|
|
||||||
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
|
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
|
||||||
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
|
// Profiles held folded in memory may still be on disk with `featureValues`; saving them writes `options`.
|
||||||
|
const profilesStaleOnDisk =
|
||||||
|
patch.agentProfiles !== undefined &&
|
||||||
|
!isEqualValue(patch.agentProfiles, this.lastKnownPersisted.daemon?.agentProfiles);
|
||||||
|
if (
|
||||||
|
!configChanged &&
|
||||||
|
removedProviders.length === 0 &&
|
||||||
|
speechPatch === undefined &&
|
||||||
|
!profilesStaleOnDisk
|
||||||
|
) {
|
||||||
return this.current;
|
return this.current;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,7 +572,11 @@ export class DaemonConfigStore {
|
|||||||
// Plugin source changes require the plugin lifecycle operation or a daemon
|
// Plugin source changes require the plugin lifecycle operation or a daemon
|
||||||
// restart. The global switch is independently reloadable.
|
// restart. The global switch is independently reloadable.
|
||||||
const desired = MutableDaemonConfigSchema.parse({
|
const desired = MutableDaemonConfigSchema.parse({
|
||||||
...resolved.mutable,
|
...foldConfigProfiles({
|
||||||
|
config: resolved.mutable,
|
||||||
|
providers: resolved.mutable.providers,
|
||||||
|
source: "stored",
|
||||||
|
}),
|
||||||
plugins: this.current.plugins,
|
plugins: this.current.plugins,
|
||||||
...this.readSpeech(persisted),
|
...this.readSpeech(persisted),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -88,9 +88,13 @@ test.each(["uncommitted", "base", "commit"] as const)(
|
|||||||
60_000,
|
60_000,
|
||||||
);
|
);
|
||||||
|
|
||||||
test.each(["single", "batch", "rename"] as const)(
|
test.each(
|
||||||
"%s diff includes hunks from a repository subdirectory before and after commit",
|
(["uncommitted", "base"] as const).flatMap((mode) =>
|
||||||
async (shape) => {
|
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
|
||||||
|
),
|
||||||
|
)(
|
||||||
|
"$mode $shape diff includes hunks from a repository subdirectory",
|
||||||
|
async ({ mode, shape }) => {
|
||||||
const ctx = await createDaemonTestContext();
|
const ctx = await createDaemonTestContext();
|
||||||
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
|
||||||
|
|
||||||
@@ -108,7 +112,9 @@ test.each(["single", "batch", "rename"] as const)(
|
|||||||
}
|
}
|
||||||
git(cwd, "add", ".");
|
git(cwd, "add", ".");
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
|
||||||
|
if (mode === "base") {
|
||||||
git(cwd, "checkout", "-b", "feature");
|
git(cwd, "checkout", "-b", "feature");
|
||||||
|
}
|
||||||
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
|
||||||
if (shape === "rename") {
|
if (shape === "rename") {
|
||||||
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
|
||||||
@@ -117,7 +123,11 @@ test.each(["single", "batch", "rename"] as const)(
|
|||||||
if (shape === "batch") {
|
if (shape === "batch") {
|
||||||
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
|
||||||
}
|
}
|
||||||
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
|
if (mode === "base") {
|
||||||
|
git(cwd, "add", ".");
|
||||||
|
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
||||||
|
}
|
||||||
|
|
||||||
const compare = { mode, baseRef: "main" };
|
const compare = { mode, baseRef: "main" };
|
||||||
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
|
||||||
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
|
||||||
@@ -143,12 +153,6 @@ test.each(["single", "batch", "rename"] as const)(
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
|
||||||
}
|
|
||||||
|
|
||||||
await expectSubdirectoryDiff("uncommitted");
|
|
||||||
git(cwd, "add", ".");
|
|
||||||
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
|
|
||||||
await expectSubdirectoryDiff("base");
|
|
||||||
} finally {
|
} finally {
|
||||||
await ctx.cleanup();
|
await ctx.cleanup();
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -102,22 +102,7 @@ describe("daemon E2E", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("downloads ASCII and Unicode filenames through one workspace", async () => {
|
test.each([
|
||||||
const cwd = tmpCwd();
|
|
||||||
try {
|
|
||||||
const agent = await ctx.client.createAgent({
|
|
||||||
provider: "codex",
|
|
||||||
model: CODEX_TEST_MODEL,
|
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
|
||||||
cwd,
|
|
||||||
title: "Download Token Test Agent",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(agent.id).toMatch(
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const { fileName, disposition } of [
|
|
||||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||||
{
|
{
|
||||||
fileName: "café.txt",
|
fileName: "café.txt",
|
||||||
@@ -143,11 +128,27 @@ describe("daemon E2E", () => {
|
|||||||
disposition:
|
disposition:
|
||||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||||
},
|
},
|
||||||
]) {
|
])(
|
||||||
|
"issues token over WS and downloads $fileName via HTTP",
|
||||||
|
async ({ fileName, disposition }) => {
|
||||||
|
const cwd = tmpCwd();
|
||||||
|
try {
|
||||||
const filePath = path.join(cwd, fileName);
|
const filePath = path.join(cwd, fileName);
|
||||||
const fileContents = `download test payload: ${fileName}`;
|
const fileContents = "download test payload";
|
||||||
writeFileSync(filePath, fileContents, "utf-8");
|
writeFileSync(filePath, fileContents, "utf-8");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
|
cwd,
|
||||||
|
title: "Download Token Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||||
|
|
||||||
expect(tokenResponse.error).toBeNull();
|
expect(tokenResponse.error).toBeNull();
|
||||||
@@ -166,11 +167,12 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
expect(body).toBe(fileContents);
|
expect(body).toBe(fileContents);
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
}, 60000);
|
},
|
||||||
|
60000,
|
||||||
|
);
|
||||||
|
|
||||||
test("rejects invalid token", async () => {
|
test("rejects invalid token", async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { FormInfo } from "@opencode/client";
|
|
||||||
import { expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
|
||||||
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
|
|
||||||
import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js";
|
|
||||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
|
||||||
|
|
||||||
async function createQuestionHarness() {
|
|
||||||
const artifacts = path.resolve(".artifacts");
|
|
||||||
await mkdir(artifacts, { recursive: true });
|
|
||||||
const root = await mkdtemp(path.join(artifacts, "peer-permissions-"));
|
|
||||||
const runtime = new V2Harness();
|
|
||||||
runtime.autoComplete = false;
|
|
||||||
runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] });
|
|
||||||
runtime.api.provider.list = async () => ({
|
|
||||||
location: runtime.info.location,
|
|
||||||
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
|
|
||||||
});
|
|
||||||
let form: FormInfo | null = null;
|
|
||||||
const answers: Array<Record<string, unknown>> = [];
|
|
||||||
runtime.api.session.form.list = async () => (form ? [form] : []);
|
|
||||||
runtime.api.session.form.cancel = async () => {
|
|
||||||
form = null;
|
|
||||||
runtime.finishExecution("interrupted");
|
|
||||||
};
|
|
||||||
runtime.api.session.form.reply = async ({ answer }) => {
|
|
||||||
answers.push(answer);
|
|
||||||
form = null;
|
|
||||||
runtime.finishExecution();
|
|
||||||
};
|
|
||||||
// A steer is admitted to the existing execution, rather than starting another one.
|
|
||||||
runtime.prompt = async (input) => {
|
|
||||||
runtime.prompts.push(input.text);
|
|
||||||
if (runtime.prompts.length === 1) runtime.startExecution();
|
|
||||||
};
|
|
||||||
const provider = new OpenCodeV2AgentClient({
|
|
||||||
logger: createTestLogger(),
|
|
||||||
runtime: runtime.runtime,
|
|
||||||
});
|
|
||||||
const daemon = await createTestPaseoDaemon({
|
|
||||||
agentClients: { ...createTestAgentClients(), opencode: provider },
|
|
||||||
paseoHomeRoot: path.join(root, "daemon"),
|
|
||||||
staticDir: path.join(root, "static"),
|
|
||||||
cleanup: false,
|
|
||||||
});
|
|
||||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
await client.fetchAgents({ subscribe: {} });
|
|
||||||
const recipient = await client.createAgent({
|
|
||||||
provider: "opencode",
|
|
||||||
cwd: root,
|
|
||||||
title: "Waiting for a human answer",
|
|
||||||
});
|
|
||||||
const sender = await client.createAgent({ provider: "claude", cwd: root });
|
|
||||||
await client.sendMessage(recipient.id, "Ask which color to use");
|
|
||||||
await expect.poll(() => runtime.prompts).toHaveLength(1);
|
|
||||||
form = {
|
|
||||||
id: "color-question",
|
|
||||||
sessionID: "session",
|
|
||||||
title: "Color",
|
|
||||||
fields: [{ key: "color", title: "Color", type: "string" }],
|
|
||||||
};
|
|
||||||
runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } });
|
|
||||||
await client.waitForAgentUpsert(recipient.id, (agent) =>
|
|
||||||
(agent.pendingPermissions ?? []).some((request) => request.id === "color-question"),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
client,
|
|
||||||
recipient,
|
|
||||||
sender,
|
|
||||||
answers,
|
|
||||||
async close() {
|
|
||||||
await client.close();
|
|
||||||
await daemon.close();
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await client.close();
|
|
||||||
await daemon.close();
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("an agent-sourced steer leaves the question pending and answerable", async () => {
|
|
||||||
const { client, recipient, sender, answers, close } = await createQuestionHarness();
|
|
||||||
try {
|
|
||||||
await client.sendAgentMessage(recipient.id, "Peer status check", {
|
|
||||||
sourceAgentId: sender.id,
|
|
||||||
});
|
|
||||||
// The public wait includes admitted message delivery before reporting permission status.
|
|
||||||
const waiting = await client.waitForFinish(recipient.id);
|
|
||||||
expect(waiting.status).toBe("permission");
|
|
||||||
expect(waiting.final?.pendingPermissions).toMatchObject([
|
|
||||||
{ id: "color-question", kind: "question" },
|
|
||||||
]);
|
|
||||||
await client.respondToPermission(recipient.id, "color-question", {
|
|
||||||
behavior: "allow",
|
|
||||||
updatedInput: { answers: { Color: "Blue" } },
|
|
||||||
});
|
|
||||||
await client.waitForAgentUpsert(
|
|
||||||
recipient.id,
|
|
||||||
(agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle",
|
|
||||||
);
|
|
||||||
expect(answers).toEqual([{ color: "Blue" }]);
|
|
||||||
} finally {
|
|
||||||
await close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a human steer still dismisses the question", async () => {
|
|
||||||
const { client, recipient, answers, close } = await createQuestionHarness();
|
|
||||||
try {
|
|
||||||
await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" });
|
|
||||||
const finished = await client.waitForFinish(recipient.id);
|
|
||||||
expect(finished.status).toBe("idle");
|
|
||||||
expect(finished.final?.pendingPermissions ?? []).toEqual([]);
|
|
||||||
expect(answers).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
await close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -86,7 +86,10 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
|
|||||||
cwd: response.payload.agent.cwd,
|
cwd: response.payload.agent.cwd,
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(response.payload.agent)).not.toContain(bearer);
|
expect(JSON.stringify(response.payload.agent)).not.toContain(bearer);
|
||||||
expect(JSON.stringify(response.payload.agent)).not.toContain("private-build");
|
// Provider options are not secret and appear in the snapshot's `options`.
|
||||||
|
expect(response.payload.agent.options).toMatchObject({
|
||||||
|
sandbox_workspace_write: { writable_roots: ["/var/cache/private-build"] },
|
||||||
|
});
|
||||||
expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution");
|
expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution");
|
||||||
|
|
||||||
const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update");
|
const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update");
|
||||||
@@ -104,7 +107,6 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
|
|||||||
cwd: update.payload.agent.cwd,
|
cwd: update.payload.agent.cwd,
|
||||||
});
|
});
|
||||||
expect(JSON.stringify(update.payload.agent)).not.toContain(bearer);
|
expect(JSON.stringify(update.payload.agent)).not.toContain(bearer);
|
||||||
expect(JSON.stringify(update.payload.agent)).not.toContain("private-build");
|
|
||||||
expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution");
|
expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export interface HubExecutionAgentCreateInput {
|
|||||||
model?: string;
|
model?: string;
|
||||||
modeId?: string;
|
modeId?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
featureValues?: Record<string, unknown>;
|
|
||||||
providerOptions?: ProviderOptions;
|
providerOptions?: ProviderOptions;
|
||||||
toolPolicy?: ToolPolicy;
|
toolPolicy?: ToolPolicy;
|
||||||
env?: Record<string, string>;
|
env?: Record<string, string>;
|
||||||
@@ -193,7 +192,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
|||||||
cwd: input.cwd,
|
cwd: input.cwd,
|
||||||
mode: input.modeId,
|
mode: input.modeId,
|
||||||
thinking: input.thinkingOptionId,
|
thinking: input.thinkingOptionId,
|
||||||
features: input.featureValues,
|
|
||||||
env: input.env,
|
env: input.env,
|
||||||
...(input.mcpServers || input.providerOptions || input.toolPolicy
|
...(input.mcpServers || input.providerOptions || input.toolPolicy
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
} from "../agent/provider-options.js";
|
} from "../agent/provider-options.js";
|
||||||
|
|
||||||
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
|
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
|
||||||
|
import { foldFeatureValues } from "../agent/option-controls.js";
|
||||||
|
|
||||||
interface HubExecutionControllerOptions {
|
interface HubExecutionControllerOptions {
|
||||||
agents: HubExecutionAgents;
|
agents: HubExecutionAgents;
|
||||||
@@ -167,8 +168,8 @@ export class HubExecutionController {
|
|||||||
model: message.model,
|
model: message.model,
|
||||||
modeId: message.modeId,
|
modeId: message.modeId,
|
||||||
thinkingOptionId: message.thinkingOptionId,
|
thinkingOptionId: message.thinkingOptionId,
|
||||||
featureValues: message.featureValues,
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
providerOptions: message.providerOptions,
|
providerOptions: foldFeatureValues(message).providerOptions,
|
||||||
toolPolicy: message.toolPolicy,
|
toolPolicy: message.toolPolicy,
|
||||||
env: message.env,
|
env: message.env,
|
||||||
mcpServers: message.mcpServers,
|
mcpServers: message.mcpServers,
|
||||||
|
|||||||
@@ -440,7 +440,7 @@ type AcceptedCreate = Omit<
|
|||||||
|
|
||||||
const providerCatalog = {
|
const providerCatalog = {
|
||||||
async resolveCreateConfig(input) {
|
async resolveCreateConfig(input) {
|
||||||
return { modeId: input.requestedMode, featureValues: input.featureValues };
|
return { modeId: input.requestedMode, providerOptions: input.providerOptions };
|
||||||
},
|
},
|
||||||
} satisfies CreateAgentCommandDependencies["providerSnapshotManager"];
|
} satisfies CreateAgentCommandDependencies["providerSnapshotManager"];
|
||||||
|
|
||||||
|
|||||||
@@ -985,6 +985,45 @@ test("config changes require their own event demand and cannot escape to an idle
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a config change push carries profiles with the legacy featureValues the unchanged app reads", async () => {
|
||||||
|
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||||
|
const admin = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws`, appVersion: "0.8.0" });
|
||||||
|
let observer: SubscriptionPeer | undefined;
|
||||||
|
try {
|
||||||
|
await admin.connect();
|
||||||
|
await admin.patchDaemonConfig({
|
||||||
|
agentProfiles: [
|
||||||
|
{ id: "p", name: "P", provider: "codex", options: {}, featureValues: { plan_mode: true } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
observer = await SubscriptionPeer.connect(daemon.port, "config-push-session");
|
||||||
|
await observer.request({
|
||||||
|
type: "session.events.set_subscription.request",
|
||||||
|
requestId: "config-feed",
|
||||||
|
events: ["status.daemon_config_changed"],
|
||||||
|
});
|
||||||
|
|
||||||
|
await admin.patchDaemonConfig({ autoArchiveAfterMerge: true });
|
||||||
|
|
||||||
|
const peer = observer;
|
||||||
|
await expect
|
||||||
|
.poll(() =>
|
||||||
|
peer.frames.flatMap((frame) =>
|
||||||
|
frame.type === "session" &&
|
||||||
|
frame.message.type === "status" &&
|
||||||
|
frame.message.payload.status === "daemon_config_changed"
|
||||||
|
? [frame.message.payload.config?.agentProfiles?.[0]?.featureValues]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toEqual([{ plan_mode: true }]);
|
||||||
|
} finally {
|
||||||
|
observer?.close();
|
||||||
|
await admin.close();
|
||||||
|
await daemon.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("status-shaped operation replies use actual source request provenance", async () => {
|
test("status-shaped operation replies use actual source request provenance", async () => {
|
||||||
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
const daemon = await createTestPaseoDaemon({ mcpEnabled: false });
|
||||||
const peers: SubscriptionPeer[] = [];
|
const peers: SubscriptionPeer[] = [];
|
||||||
|
|||||||
@@ -73,7 +73,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
|||||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||||
model: record.config?.model ?? undefined,
|
model: record.config?.model ?? undefined,
|
||||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||||
featureValues: record.config?.featureValues ?? undefined,
|
|
||||||
providerOptions: record.config?.providerOptions ?? undefined,
|
providerOptions: record.config?.providerOptions ?? undefined,
|
||||||
toolPolicy: record.config?.toolPolicy ?? undefined,
|
toolPolicy: record.config?.toolPolicy ?? undefined,
|
||||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||||
@@ -95,7 +94,6 @@ export function buildSessionConfig(
|
|||||||
modeId: overrides.modeId,
|
modeId: overrides.modeId,
|
||||||
model: overrides.model,
|
model: overrides.model,
|
||||||
thinkingOptionId: overrides.thinkingOptionId,
|
thinkingOptionId: overrides.thinkingOptionId,
|
||||||
featureValues: overrides.featureValues,
|
|
||||||
providerOptions: overrides.providerOptions,
|
providerOptions: overrides.providerOptions,
|
||||||
toolPolicy: overrides.toolPolicy,
|
toolPolicy: overrides.toolPolicy,
|
||||||
systemPrompt: overrides.systemPrompt,
|
systemPrompt: overrides.systemPrompt,
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
|
|||||||
import { PluginService } from "./index.js";
|
import { PluginService } from "./index.js";
|
||||||
import { BuiltinPluginLoader } from "./builtin/index.js";
|
import { BuiltinPluginLoader } from "./builtin/index.js";
|
||||||
import { ManagedPluginSources } from "./managed-source.js";
|
import { ManagedPluginSources } from "./managed-source.js";
|
||||||
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
|
|
||||||
import {
|
import {
|
||||||
startNpmRegistry,
|
startNpmRegistry,
|
||||||
npmPluginPackages,
|
npmPluginPackages,
|
||||||
@@ -180,55 +179,6 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
|
|||||||
return { runtime, started, releaseStart, starts };
|
return { runtime, started, releaseStart, starts };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
|
|
||||||
const gates = new Map<string, PromiseWithResolvers<void>>();
|
|
||||||
const gate = (pluginId: string) => {
|
|
||||||
const existing = gates.get(pluginId);
|
|
||||||
if (existing) return existing;
|
|
||||||
const created = Promise.withResolvers<void>();
|
|
||||||
gates.set(pluginId, created);
|
|
||||||
return created;
|
|
||||||
};
|
|
||||||
const begun: string[] = [];
|
|
||||||
const running = new Set<string>();
|
|
||||||
const runtime: TestPluginRuntime = {
|
|
||||||
...emptyUsageRuntime,
|
|
||||||
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
|
|
||||||
invoke: async () => undefined,
|
|
||||||
getLogs: () => [],
|
|
||||||
clearLogs: () => undefined,
|
|
||||||
getProviderRegistrations: (pluginId) =>
|
|
||||||
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
|
|
||||||
startPlugin: async (pluginId) => {
|
|
||||||
begun.push(pluginId);
|
|
||||||
await gate(pluginId).promise;
|
|
||||||
running.add(pluginId);
|
|
||||||
},
|
|
||||||
stopPluginById: async (pluginId) => running.delete(pluginId),
|
|
||||||
stopAll: async () => {
|
|
||||||
running.clear();
|
|
||||||
},
|
|
||||||
subscribe: () => () => undefined,
|
|
||||||
bindPaseoSessionHost: () => undefined,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
runtime,
|
|
||||||
begun,
|
|
||||||
finish: (pluginId: string) => gate(pluginId).resolve(),
|
|
||||||
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
const unsubscribe = service.subscribe((notified) => {
|
|
||||||
if (notified !== pluginId) return;
|
|
||||||
unsubscribe();
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("PluginService", () => {
|
describe("PluginService", () => {
|
||||||
it.each([
|
it.each([
|
||||||
{ shadow: false, runtime: "subprocess" },
|
{ shadow: false, runtime: "subprocess" },
|
||||||
@@ -422,127 +372,15 @@ describe("PluginService", () => {
|
|||||||
"startup-first": { source: "directory", path: first },
|
"startup-first": { source: "directory", path: first },
|
||||||
"startup-second": { source: "directory", path: second },
|
"startup-second": { source: "directory", path: second },
|
||||||
});
|
});
|
||||||
const notifications: { pluginId: string; running: boolean }[] = [];
|
const snapshots: string[][] = [];
|
||||||
service.subscribe((pluginId) =>
|
service.subscribe(() => snapshots.push(catalogIds(service)));
|
||||||
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
|
|
||||||
);
|
|
||||||
|
|
||||||
await service.start();
|
await service.start();
|
||||||
|
|
||||||
expect(notifications).toEqual([
|
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
|
||||||
{ pluginId: "startup-first", running: true },
|
|
||||||
{ pluginId: "startup-second", running: true },
|
|
||||||
]);
|
|
||||||
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
|
|
||||||
await service.stopAllPlugins();
|
await service.stopAllPlugins();
|
||||||
}, 20_000);
|
}, 20_000);
|
||||||
|
|
||||||
it("starts configured plugins concurrently and publishes them in config order", async () => {
|
|
||||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
|
||||||
roots.push(home);
|
|
||||||
const gated = createGatedRuntime();
|
|
||||||
const service = createService(
|
|
||||||
home,
|
|
||||||
{
|
|
||||||
alpha: { source: "directory", path: "/plugins/alpha" },
|
|
||||||
beta: { source: "directory", path: "/plugins/beta" },
|
|
||||||
gamma: { source: "directory", path: "/plugins/gamma" },
|
|
||||||
},
|
|
||||||
{ runtime: gated.runtime },
|
|
||||||
);
|
|
||||||
const notified: string[] = [];
|
|
||||||
service.subscribe((pluginId) => notified.push(pluginId));
|
|
||||||
let started = false;
|
|
||||||
const starting = (async () => {
|
|
||||||
await service.start();
|
|
||||||
started = true;
|
|
||||||
})();
|
|
||||||
|
|
||||||
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
|
|
||||||
|
|
||||||
gated.finish("gamma");
|
|
||||||
const alphaPublished = nextNotification(service, "alpha");
|
|
||||||
gated.finish("alpha");
|
|
||||||
await alphaPublished;
|
|
||||||
expect(notified).toEqual(["alpha"]);
|
|
||||||
expect(started).toBe(false);
|
|
||||||
|
|
||||||
gated.finish("beta");
|
|
||||||
await starting;
|
|
||||||
expect(notified).toEqual(["alpha", "beta", "gamma"]);
|
|
||||||
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
|
|
||||||
await service.stopAllPlugins();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("starts the other configured plugins when one fails while they are still starting", async () => {
|
|
||||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
|
||||||
roots.push(home);
|
|
||||||
const gated = createGatedRuntime();
|
|
||||||
const service = createService(
|
|
||||||
home,
|
|
||||||
{
|
|
||||||
broken: { source: "directory", path: "/plugins/broken" },
|
|
||||||
healthy: { source: "directory", path: "/plugins/healthy" },
|
|
||||||
},
|
|
||||||
{ runtime: gated.runtime },
|
|
||||||
);
|
|
||||||
const notified: string[] = [];
|
|
||||||
service.subscribe((pluginId) => notified.push(pluginId));
|
|
||||||
const starting = service.start();
|
|
||||||
|
|
||||||
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
|
|
||||||
const brokenPublished = nextNotification(service, "broken");
|
|
||||||
gated.fail("broken", new Error("startup exploded"));
|
|
||||||
await brokenPublished;
|
|
||||||
gated.finish("healthy");
|
|
||||||
await starting;
|
|
||||||
|
|
||||||
expect(notified).toEqual(["broken", "healthy"]);
|
|
||||||
expect(catalogIds(service)).toEqual(["healthy"]);
|
|
||||||
expect(await service.listPlugins()).toMatchObject([
|
|
||||||
{ id: "broken", status: "failed", error: "startup exploded" },
|
|
||||||
{ id: "healthy", status: "running" },
|
|
||||||
]);
|
|
||||||
await service.stopAllPlugins();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
|
|
||||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
|
||||||
roots.push(home);
|
|
||||||
const gated = createGatedRuntime({
|
|
||||||
first: [{ id: "shared-agent", label: "First agent" }],
|
|
||||||
second: [{ id: "shared-agent", label: "Second agent" }],
|
|
||||||
});
|
|
||||||
const service = createService(
|
|
||||||
home,
|
|
||||||
{
|
|
||||||
first: { source: "directory", path: "/plugins/first" },
|
|
||||||
second: { source: "directory", path: "/plugins/second" },
|
|
||||||
},
|
|
||||||
{ runtime: gated.runtime },
|
|
||||||
);
|
|
||||||
const starting = service.start();
|
|
||||||
|
|
||||||
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
|
|
||||||
gated.finish("second");
|
|
||||||
gated.finish("first");
|
|
||||||
await starting;
|
|
||||||
|
|
||||||
expect(service.getProviderRegistrations()).toMatchObject([
|
|
||||||
{ id: "shared-agent", label: "First agent" },
|
|
||||||
]);
|
|
||||||
expect(catalogIds(service)).toEqual(["first"]);
|
|
||||||
expect(await service.listPlugins()).toMatchObject([
|
|
||||||
{ id: "first", status: "running" },
|
|
||||||
{
|
|
||||||
id: "second",
|
|
||||||
status: "failed",
|
|
||||||
error: 'Plugin second cannot register provider ID "shared-agent" twice',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
await service.stopAllPlugins();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
|
||||||
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
|
||||||
roots.push(home);
|
roots.push(home);
|
||||||
|
|||||||
@@ -230,18 +230,11 @@ export class PluginService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (config.pluginsEnabled === true) {
|
if (config.pluginsEnabled === true) {
|
||||||
let earlierPublished: Promise<unknown> = Promise.resolve();
|
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
|
||||||
await Promise.all(
|
if (source.enabled === false) continue;
|
||||||
Object.entries(config.plugins ?? {})
|
await this.startConfigured(pluginId);
|
||||||
.filter(([, source]) => source.enabled !== false)
|
|
||||||
.map(async ([pluginId]) => {
|
|
||||||
const publishTurn = earlierPublished;
|
|
||||||
const starting = this.startConfigured(pluginId, publishTurn);
|
|
||||||
earlierPublished = Promise.all([publishTurn, starting]);
|
|
||||||
await starting;
|
|
||||||
this.notify(pluginId);
|
this.notify(pluginId);
|
||||||
}),
|
}
|
||||||
);
|
|
||||||
}
|
}
|
||||||
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
this.configStore.onFieldChange("pluginsEnabled", (value) => {
|
||||||
this.handleGlobalSwitch(value === true);
|
this.handleGlobalSwitch(value === true);
|
||||||
@@ -567,12 +560,12 @@ export class PluginService {
|
|||||||
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
|
private async startConfigured(pluginId: string): Promise<void> {
|
||||||
const source = this.configStore.get().plugins?.[pluginId];
|
const source = this.configStore.get().plugins?.[pluginId];
|
||||||
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
|
||||||
this.errors.delete(pluginId);
|
this.errors.delete(pluginId);
|
||||||
try {
|
try {
|
||||||
await this.startPlugin(pluginId, source.path, publishTurn);
|
await this.startPlugin(pluginId, source.path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
|
||||||
}
|
}
|
||||||
@@ -601,13 +594,8 @@ export class PluginService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async startPlugin(
|
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
|
||||||
pluginId: string,
|
|
||||||
sourcePath: string,
|
|
||||||
publishTurn?: Promise<unknown>,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
|
||||||
await publishTurn;
|
|
||||||
try {
|
try {
|
||||||
await this.publishProviderRegistrations(pluginId, sourcePath);
|
await this.publishProviderRegistrations(pluginId, sourcePath);
|
||||||
this.publishUsageSources(pluginId);
|
this.publishUsageSources(pluginId);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
|
|||||||
async resolveCreateConfig(input) {
|
async resolveCreateConfig(input) {
|
||||||
return {
|
return {
|
||||||
modeId: input.unattended ? input.requestedMode : "interactive",
|
modeId: input.unattended ? input.requestedMode : "interactive",
|
||||||
featureValues: input.featureValues,
|
providerOptions: input.providerOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -1911,7 +1911,7 @@ describe("ScheduleService", () => {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
modeId: input.unattended ? "bypassPermissions" : "interactive",
|
modeId: input.unattended ? "bypassPermissions" : "interactive",
|
||||||
featureValues: input.featureValues,
|
providerOptions: input.providerOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1980,9 +1980,9 @@ describe("ScheduleService", () => {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
modeId: input.unattended ? "build" : "interactive",
|
modeId: input.unattended ? "build" : "interactive",
|
||||||
featureValues: input.unattended
|
providerOptions: input.unattended
|
||||||
? { ...input.featureValues, auto_accept: true }
|
? { ...input.providerOptions, auto_accept: true }
|
||||||
: input.featureValues,
|
: input.providerOptions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2010,7 +2010,7 @@ describe("ScheduleService", () => {
|
|||||||
expect(inspected.runs[0]?.error).toBeNull();
|
expect(inspected.runs[0]?.error).toBeNull();
|
||||||
expect(createdConfigs[0]).toMatchObject({
|
expect(createdConfigs[0]).toMatchObject({
|
||||||
modeId: "build",
|
modeId: "build",
|
||||||
featureValues: { auto_accept: true },
|
providerOptions: { auto_accept: true },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2052,7 +2052,7 @@ describe("ScheduleService", () => {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
modeId: input.requestedMode,
|
modeId: input.requestedMode,
|
||||||
featureValues: { ...input.featureValues, resolved: true },
|
providerOptions: { ...input.providerOptions, resolved: true },
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2099,11 +2099,13 @@ describe("ScheduleService", () => {
|
|||||||
model: "test-model",
|
model: "test-model",
|
||||||
modeId: "stored-mode",
|
modeId: "stored-mode",
|
||||||
thinkingOptionId: "think-hard",
|
thinkingOptionId: "think-hard",
|
||||||
|
// Stored schedules written before options carry `featureValues`; launch folds them.
|
||||||
providerOptions: {
|
providerOptions: {
|
||||||
allowedTools: ["Read"],
|
allowedTools: ["Read"],
|
||||||
sandbox: { enabled: true, network: { allowLocalBinding: true } },
|
sandbox: { enabled: true, network: { allowLocalBinding: true } },
|
||||||
|
auto_accept: true,
|
||||||
|
resolved: true,
|
||||||
},
|
},
|
||||||
featureValues: { auto_accept: true, resolved: true },
|
|
||||||
systemPrompt: "Stay concise.",
|
systemPrompt: "Stay concise.",
|
||||||
mcpServers: {
|
mcpServers: {
|
||||||
docs: {
|
docs: {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
|||||||
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
|
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
|
||||||
import { ScheduleStore } from "./store.js";
|
import { ScheduleStore } from "./store.js";
|
||||||
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
||||||
|
import { foldFeatureValues } from "../agent/option-controls.js";
|
||||||
import type {
|
import type {
|
||||||
CreateScheduleInput,
|
CreateScheduleInput,
|
||||||
ScheduleExecutionResult,
|
ScheduleExecutionResult,
|
||||||
@@ -998,7 +999,6 @@ export class ScheduleService {
|
|||||||
},
|
},
|
||||||
mode: config.modeId,
|
mode: config.modeId,
|
||||||
thinking: config.thinkingOptionId,
|
thinking: config.thinkingOptionId,
|
||||||
features: config.featureValues,
|
|
||||||
unattended: true,
|
unattended: true,
|
||||||
promptFailure: "return-error",
|
promptFailure: "return-error",
|
||||||
notifyOnFinish: false,
|
notifyOnFinish: false,
|
||||||
@@ -1098,8 +1098,8 @@ function buildScheduleAgentConfig(
|
|||||||
model: config.model,
|
model: config.model,
|
||||||
thinkingOptionId: config.thinkingOptionId,
|
thinkingOptionId: config.thinkingOptionId,
|
||||||
title: config.title,
|
title: config.title,
|
||||||
providerOptions: config.providerOptions,
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
featureValues: config.featureValues,
|
providerOptions: foldFeatureValues(config).providerOptions,
|
||||||
systemPrompt: config.systemPrompt,
|
systemPrompt: config.systemPrompt,
|
||||||
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
|
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5816,7 +5816,7 @@ describe("agent config setters", () => {
|
|||||||
const messages: SessionOutboundMessage[] = [];
|
const messages: SessionOutboundMessage[] = [];
|
||||||
const session = createSessionForTest({
|
const session = createSessionForTest({
|
||||||
messages,
|
messages,
|
||||||
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }),
|
agentManager: liveAgentManager({ setAgentOptions: vi.fn().mockResolvedValue(undefined) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
await session.handleMessage({
|
await session.handleMessage({
|
||||||
@@ -5840,7 +5840,7 @@ describe("agent config setters", () => {
|
|||||||
const session = createSessionForTest({
|
const session = createSessionForTest({
|
||||||
messages,
|
messages,
|
||||||
agentManager: liveAgentManager({
|
agentManager: liveAgentManager({
|
||||||
setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")),
|
setAgentOptions: vi.fn().mockRejectedValue(new Error("feature boom")),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ import {
|
|||||||
createWorkspaceScriptsService,
|
createWorkspaceScriptsService,
|
||||||
type WorkspaceScriptsService,
|
type WorkspaceScriptsService,
|
||||||
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
||||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js";
|
||||||
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||||
@@ -1036,7 +1036,7 @@ export class Session {
|
|||||||
supportsProviderSnapshotReferences: () =>
|
supportsProviderSnapshotReferences: () =>
|
||||||
this.supports(CLIENT_CAPS.providerSnapshotReferences),
|
this.supports(CLIENT_CAPS.providerSnapshotReferences),
|
||||||
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
||||||
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
|
listDraftControls: (config) => this.agentManager.listDraftControls(config),
|
||||||
},
|
},
|
||||||
providerSnapshotManager,
|
providerSnapshotManager,
|
||||||
logger: this.sessionLogger,
|
logger: this.sessionLogger,
|
||||||
@@ -1066,8 +1066,7 @@ export class Session {
|
|||||||
setMode: async (agentId, modeId) =>
|
setMode: async (agentId, modeId) =>
|
||||||
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
|
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
|
||||||
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
|
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
|
||||||
setFeature: (agentId, featureId, value) =>
|
setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
|
||||||
agentManager.setAgentFeature(agentId, featureId, value),
|
|
||||||
setThinking: (agentId, thinkingOptionId) =>
|
setThinking: (agentId, thinkingOptionId) =>
|
||||||
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
|
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
|
||||||
},
|
},
|
||||||
@@ -2950,7 +2949,10 @@ export class Session {
|
|||||||
case "get_daemon_config_request":
|
case "get_daemon_config_request":
|
||||||
this.emit({
|
this.emit({
|
||||||
type: "get_daemon_config_response",
|
type: "get_daemon_config_response",
|
||||||
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
|
payload: {
|
||||||
|
requestId: msg.requestId,
|
||||||
|
config: toWireDaemonConfig(this.daemonConfigStore.get()),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
return undefined;
|
return undefined;
|
||||||
case "daemon.get_status.request":
|
case "daemon.get_status.request":
|
||||||
@@ -2974,7 +2976,7 @@ export class Session {
|
|||||||
type: "set_daemon_config_response",
|
type: "set_daemon_config_response",
|
||||||
payload: {
|
payload: {
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
config,
|
config: toWireDaemonConfig(config),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -3181,6 +3183,8 @@ export class Session {
|
|||||||
return this.providerCatalogSession.handleListProviderModesRequest(msg);
|
return this.providerCatalogSession.handleListProviderModesRequest(msg);
|
||||||
case "list_provider_features_request":
|
case "list_provider_features_request":
|
||||||
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
|
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
|
||||||
|
case "provider.controls.list.request":
|
||||||
|
return this.providerCatalogSession.handleProviderControlsListRequest(msg);
|
||||||
case "list_available_providers_request":
|
case "list_available_providers_request":
|
||||||
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
|
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
|
||||||
case "get_providers_snapshot_request":
|
case "get_providers_snapshot_request":
|
||||||
@@ -8318,7 +8322,7 @@ export class Session {
|
|||||||
},
|
},
|
||||||
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
||||||
sessionMode: msg.sessionMode,
|
sessionMode: msg.sessionMode,
|
||||||
clearPendingPermissions: false,
|
clearPendingPermissions: true,
|
||||||
});
|
});
|
||||||
deliveryStatus = "queued";
|
deliveryStatus = "queued";
|
||||||
queued();
|
queued();
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
|
|||||||
readonly loadedAgentIds: string[] = [];
|
readonly loadedAgentIds: string[] = [];
|
||||||
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
|
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
|
||||||
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
|
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
|
||||||
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
|
readonly optionsCalls: Array<{ agentId: string; patch: Record<string, unknown> }> = [];
|
||||||
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
|
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
|
||||||
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
|
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
|
||||||
readonly callLog: string[] = [];
|
readonly callLog: string[] = [];
|
||||||
@@ -39,9 +39,9 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
|
|||||||
if (this.failWith) throw this.failWith;
|
if (this.failWith) throw this.failWith;
|
||||||
}
|
}
|
||||||
|
|
||||||
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
async setOptions(agentId: string, patch: Record<string, unknown>): Promise<void> {
|
||||||
this.featureCalls.push({ agentId, featureId, value });
|
this.optionsCalls.push({ agentId, patch });
|
||||||
this.callLog.push(`feature:${featureId}`);
|
this.callLog.push("options");
|
||||||
if (this.failWith) throw this.failWith;
|
if (this.failWith) throw this.failWith;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +206,7 @@ describe("AgentConfigSession", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("set feature: forwards the feature value and emits an accepted response with no notice", async () => {
|
test("set feature: maps the feature value to a one-key options patch and emits an accepted response with no notice", async () => {
|
||||||
const { subsystem, emitted, operations } = makeSubsystem();
|
const { subsystem, emitted, operations } = makeSubsystem();
|
||||||
|
|
||||||
await subsystem.handleSetAgentFeatureRequest({
|
await subsystem.handleSetAgentFeatureRequest({
|
||||||
@@ -217,9 +217,7 @@ describe("AgentConfigSession", () => {
|
|||||||
requestId: "req-1",
|
requestId: "req-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(operations.featureCalls).toEqual([
|
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]);
|
||||||
{ agentId: "agent-1", featureId: "web_search", value: true },
|
|
||||||
]);
|
|
||||||
expect(emitted).toEqual([
|
expect(emitted).toEqual([
|
||||||
{
|
{
|
||||||
type: "set_agent_feature_response",
|
type: "set_agent_feature_response",
|
||||||
@@ -309,7 +307,7 @@ describe("AgentConfigSession", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("apply: sets model before thinking and features last, in one accepted response", async () => {
|
test("apply: sets model before thinking and options last, in one accepted response", async () => {
|
||||||
const { subsystem, emitted, operations } = makeSubsystem();
|
const { subsystem, emitted, operations } = makeSubsystem();
|
||||||
|
|
||||||
await subsystem.handleAgentConfigApplyRequest({
|
await subsystem.handleAgentConfigApplyRequest({
|
||||||
@@ -319,18 +317,16 @@ describe("AgentConfigSession", () => {
|
|||||||
modelId: "opus-5",
|
modelId: "opus-5",
|
||||||
modeId: "plan",
|
modeId: "plan",
|
||||||
thinkingOptionId: "high",
|
thinkingOptionId: "high",
|
||||||
featureValues: { webSearch: true },
|
options: { webSearch: true },
|
||||||
},
|
},
|
||||||
requestId: "req-1",
|
requestId: "req-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]);
|
expect(operations.callLog).toEqual(["model", "mode", "thinking", "options"]);
|
||||||
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
|
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
|
||||||
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
|
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
|
||||||
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
|
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
|
||||||
expect(operations.featureCalls).toEqual([
|
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { webSearch: true } }]);
|
||||||
{ agentId: "agent-1", featureId: "webSearch", value: true },
|
|
||||||
]);
|
|
||||||
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
|
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
|
||||||
expect(emitted).toEqual([
|
expect(emitted).toEqual([
|
||||||
{
|
{
|
||||||
@@ -346,6 +342,39 @@ describe("AgentConfigSession", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("apply: passes an options patch through, null included, in one call", async () => {
|
||||||
|
const { subsystem, operations } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleAgentConfigApplyRequest({
|
||||||
|
type: "agent.config.apply.request",
|
||||||
|
agentId: "agent-1",
|
||||||
|
config: { options: { fast_mode: true, service_tier: null } },
|
||||||
|
requestId: "req-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(operations.optionsCalls).toEqual([
|
||||||
|
{ agentId: "agent-1", patch: { fast_mode: true, service_tier: null } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apply: folds legacy featureValues into the options patch, options winning", async () => {
|
||||||
|
const { subsystem, operations } = makeSubsystem();
|
||||||
|
|
||||||
|
await subsystem.handleAgentConfigApplyRequest({
|
||||||
|
type: "agent.config.apply.request",
|
||||||
|
agentId: "agent-1",
|
||||||
|
config: {
|
||||||
|
featureValues: { fast_mode: true, service_tier: "fast" },
|
||||||
|
options: { fast_mode: false },
|
||||||
|
},
|
||||||
|
requestId: "req-1",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(operations.optionsCalls).toEqual([
|
||||||
|
{ agentId: "agent-1", patch: { fast_mode: false, service_tier: "fast" } },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
test("apply: leaves omitted fields alone", async () => {
|
test("apply: leaves omitted fields alone", async () => {
|
||||||
const { subsystem, operations } = makeSubsystem();
|
const { subsystem, operations } = makeSubsystem();
|
||||||
|
|
||||||
@@ -381,11 +410,11 @@ describe("AgentConfigSession", () => {
|
|||||||
await subsystem.handleAgentConfigApplyRequest({
|
await subsystem.handleAgentConfigApplyRequest({
|
||||||
type: "agent.config.apply.request",
|
type: "agent.config.apply.request",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } },
|
config: { modeId: "plan", thinkingOptionId: "high", options: { webSearch: true } },
|
||||||
requestId: "req-1",
|
requestId: "req-1",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]);
|
expect(operations.callLog).toEqual(["mode", "thinking", "options"]);
|
||||||
expect(emitted).toEqual([
|
expect(emitted).toEqual([
|
||||||
{
|
{
|
||||||
type: "agent.config.apply.response",
|
type: "agent.config.apply.response",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from "uuid";
|
|||||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||||
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
|
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
|
||||||
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
|
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
|
||||||
|
import type { ProviderOptionsPatch } from "../../agent/option-controls.js";
|
||||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,13 +23,13 @@ export interface AgentConfigSessionHost {
|
|||||||
* The per-agent config mutations this subsystem drives. The shell adapts these
|
* The per-agent config mutations this subsystem drives. The shell adapts these
|
||||||
* onto the AgentManager and loads a closed agent before mutation (mode still
|
* onto the AgentManager and loads a closed agent before mutation (mode still
|
||||||
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
|
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
|
||||||
* thinking yield a provider notice; model and feature do not.
|
* thinking yield a provider notice; model and options do not.
|
||||||
*/
|
*/
|
||||||
export interface AgentConfigOperations {
|
export interface AgentConfigOperations {
|
||||||
ensureLoaded(agentId: string): Promise<void>;
|
ensureLoaded(agentId: string): Promise<void>;
|
||||||
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
|
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
|
||||||
setModel(agentId: string, modelId: string | null): Promise<void>;
|
setModel(agentId: string, modelId: string | null): Promise<void>;
|
||||||
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>;
|
setOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void>;
|
||||||
setThinking(
|
setThinking(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
thinkingOptionId: string | null,
|
thinkingOptionId: string | null,
|
||||||
@@ -112,8 +113,9 @@ export class AgentConfigSession {
|
|||||||
logLabel: "set_agent_feature_request",
|
logLabel: "set_agent_feature_request",
|
||||||
logFields: { agentId, featureId, value, requestId },
|
logFields: { agentId, featureId, value, requestId },
|
||||||
failureText: "Failed to set agent feature",
|
failureText: "Failed to set agent feature",
|
||||||
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A feature is a one-key options patch.
|
||||||
run: async () => {
|
run: async () => {
|
||||||
await this.operations.setFeature(agentId, featureId, value);
|
await this.operations.setOptions(agentId, { [featureId]: value });
|
||||||
return undefined;
|
return undefined;
|
||||||
},
|
},
|
||||||
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
|
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
|
||||||
@@ -152,7 +154,7 @@ export class AgentConfigSession {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Model first, because a thinking option only means anything against the model
|
* Model first, because a thinking option only means anything against the model
|
||||||
* that offers it; features last so they settle on top of the rest. An omitted
|
* that offers it; options last so they settle on top of the rest. An omitted
|
||||||
* field is left alone. The first notice produced is the one the client sees —
|
* field is left alone. The first notice produced is the one the client sees —
|
||||||
* later steps still run, so a notice never truncates the bundle.
|
* later steps still run, so a notice never truncates the bundle.
|
||||||
*
|
*
|
||||||
@@ -189,8 +191,10 @@ export class AgentConfigSession {
|
|||||||
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
|
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
|
||||||
notice ??= thinkingNotice;
|
notice ??= thinkingNotice;
|
||||||
}
|
}
|
||||||
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) {
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch.
|
||||||
await this.operations.setFeature(agentId, featureId, value);
|
const optionsPatch = { ...config.featureValues, ...config.options };
|
||||||
|
if (Object.keys(optionsPatch).length > 0) {
|
||||||
|
await this.operations.setOptions(agentId, optionsPatch);
|
||||||
}
|
}
|
||||||
|
|
||||||
return notice;
|
return notice;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
type ProviderSnapshot,
|
type ProviderSnapshot,
|
||||||
type ProviderSnapshotTransition,
|
type ProviderSnapshotTransition,
|
||||||
} from "../../agent/provider-snapshot-manager.js";
|
} from "../../agent/provider-snapshot-manager.js";
|
||||||
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
|
import type { AgentSessionConfig, ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
|
||||||
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||||
|
|
||||||
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
|
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
|
||||||
@@ -58,7 +58,7 @@ function makeSubsystem(options: MakeOptions = {}) {
|
|||||||
},
|
},
|
||||||
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
|
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
|
||||||
listProviderAvailability: async () => [],
|
listProviderAvailability: async () => [],
|
||||||
listDraftFeatures: async () => [],
|
listDraftControls: async () => ({ controls: [], options: {} }),
|
||||||
...options.host,
|
...options.host,
|
||||||
};
|
};
|
||||||
const providerSnapshotManager = createStub<ProviderSnapshotManager>({
|
const providerSnapshotManager = createStub<ProviderSnapshotManager>({
|
||||||
@@ -296,10 +296,100 @@ describe("ProviderCatalogSession", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("lists controls and effective options for a draft config", async () => {
|
||||||
|
const drafts: AgentSessionConfig[] = [];
|
||||||
|
const { subsystem, emitted } = makeSubsystem({
|
||||||
|
host: {
|
||||||
|
listDraftControls: async (config) => {
|
||||||
|
drafts.push(config);
|
||||||
|
return {
|
||||||
|
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||||
|
options: { fast_mode: true },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await subsystem.handleProviderControlsListRequest({
|
||||||
|
type: "provider.controls.list.request",
|
||||||
|
requestId: "c1",
|
||||||
|
draftConfig: {
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
providerOptions: { fast_mode: true },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drafts).toEqual([
|
||||||
|
{
|
||||||
|
provider: "claude",
|
||||||
|
cwd: "/tmp/project",
|
||||||
|
model: "claude-opus-4-8",
|
||||||
|
providerOptions: { fast_mode: true },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(findByType(emitted, "provider.controls.list.response")?.payload).toEqual({
|
||||||
|
provider: "claude",
|
||||||
|
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||||
|
options: { fast_mode: true },
|
||||||
|
error: null,
|
||||||
|
fetchedAt: expect.any(String),
|
||||||
|
requestId: "c1",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("surfaces a controls failure inline, not as an rpc_error", async () => {
|
||||||
|
const { subsystem, emitted } = makeSubsystem({
|
||||||
|
host: {
|
||||||
|
listDraftControls: async () => {
|
||||||
|
throw new Error("controls probe failed");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await subsystem.handleProviderControlsListRequest({
|
||||||
|
type: "provider.controls.list.request",
|
||||||
|
requestId: "c2",
|
||||||
|
draftConfig: { provider: "codex", cwd: "/tmp/project" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||||
|
expect(findByType(emitted, "provider.controls.list.response")?.payload.error).toBe(
|
||||||
|
"controls probe failed",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers list_provider_features_request from controls, folding legacy featureValues", async () => {
|
||||||
|
const drafts: AgentSessionConfig[] = [];
|
||||||
|
const { subsystem, emitted } = makeSubsystem({
|
||||||
|
host: {
|
||||||
|
listDraftControls: async (config) => {
|
||||||
|
drafts.push(config);
|
||||||
|
return {
|
||||||
|
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||||
|
options: { fast_mode: true },
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await subsystem.handleListProviderFeaturesRequest({
|
||||||
|
type: "list_provider_features_request",
|
||||||
|
requestId: "f0",
|
||||||
|
draftConfig: { provider: "claude", cwd: "/tmp/project", featureValues: { fast_mode: true } },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(drafts[0]?.providerOptions).toEqual({ fast_mode: true });
|
||||||
|
expect(findByType(emitted, "list_provider_features_response")?.payload.features).toEqual([
|
||||||
|
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces a feature-list failure inline, not as an rpc_error", async () => {
|
it("surfaces a feature-list failure inline, not as an rpc_error", async () => {
|
||||||
const { subsystem, emitted } = makeSubsystem({
|
const { subsystem, emitted } = makeSubsystem({
|
||||||
host: {
|
host: {
|
||||||
listDraftFeatures: async () => {
|
listDraftControls: async () => {
|
||||||
throw new Error("feature probe failed");
|
throw new Error("feature probe failed");
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -379,7 +469,7 @@ it("announces shared content without retransmitting models or hashing discovery
|
|||||||
},
|
},
|
||||||
supportsProviderSnapshotReferences: () => references,
|
supportsProviderSnapshotReferences: () => references,
|
||||||
listProviderAvailability: async () => [],
|
listProviderAvailability: async () => [],
|
||||||
listDraftFeatures: async () => [],
|
listDraftControls: async () => ({ controls: [], options: {} }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const emitted: SessionOutboundMessage[] = [];
|
const emitted: SessionOutboundMessage[] = [];
|
||||||
|
|||||||
@@ -13,12 +13,14 @@ import {
|
|||||||
} from "../../agent/provider-snapshot-manager.js";
|
} from "../../agent/provider-snapshot-manager.js";
|
||||||
import {
|
import {
|
||||||
filterSelectableAgentModels,
|
filterSelectableAgentModels,
|
||||||
type AgentFeature,
|
type AgentOptionControl,
|
||||||
type AgentProvider,
|
type AgentProvider,
|
||||||
type AgentSessionConfig,
|
type AgentSessionConfig,
|
||||||
type ProviderSnapshotEntry,
|
type ProviderSnapshotEntry,
|
||||||
} from "../../agent/agent-sdk-types.js";
|
} from "../../agent/agent-sdk-types.js";
|
||||||
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
||||||
|
import { featuresFromControls, foldFeatureValues } from "../../agent/option-controls.js";
|
||||||
|
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||||
import { expandTilde } from "../../../utils/path.js";
|
import { expandTilde } from "../../../utils/path.js";
|
||||||
|
|
||||||
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
|
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
|
||||||
@@ -49,7 +51,9 @@ export interface ProviderCatalogSessionHost {
|
|||||||
supportsProviderSnapshotReferences(): boolean;
|
supportsProviderSnapshotReferences(): boolean;
|
||||||
publishSnapshot(project: () => SessionOutboundMessage | null): void;
|
publishSnapshot(project: () => SessionOutboundMessage | null): void;
|
||||||
listProviderAvailability(): Promise<ProviderAvailability[]>;
|
listProviderAvailability(): Promise<ProviderAvailability[]>;
|
||||||
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
listDraftControls(
|
||||||
|
config: AgentSessionConfig,
|
||||||
|
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderCatalogSessionOptions {
|
export interface ProviderCatalogSessionOptions {
|
||||||
@@ -340,29 +344,59 @@ export class ProviderCatalogSession {
|
|||||||
model?: string;
|
model?: string;
|
||||||
thinkingOptionId?: string;
|
thinkingOptionId?: string;
|
||||||
featureValues?: Record<string, unknown>;
|
featureValues?: Record<string, unknown>;
|
||||||
|
providerOptions?: ProviderOptions;
|
||||||
}): AgentSessionConfig {
|
}): AgentSessionConfig {
|
||||||
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||||
|
const { providerOptions } = foldFeatureValues(draftConfig);
|
||||||
return {
|
return {
|
||||||
provider: draftConfig.provider,
|
provider: draftConfig.provider,
|
||||||
cwd: expandTilde(draftConfig.cwd),
|
cwd: expandTilde(draftConfig.cwd),
|
||||||
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
|
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
|
||||||
...(draftConfig.model ? { model: draftConfig.model } : {}),
|
...(draftConfig.model ? { model: draftConfig.model } : {}),
|
||||||
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
|
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
|
||||||
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
|
...(providerOptions ? { providerOptions } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleProviderControlsListRequest(
|
||||||
|
msg: Extract<SessionInboundMessage, { type: "provider.controls.list.request" }>,
|
||||||
|
): Promise<void> {
|
||||||
|
const fetchedAt = new Date().toISOString();
|
||||||
|
const provider = msg.draftConfig.provider;
|
||||||
|
try {
|
||||||
|
const { controls, options } = await this.host.listDraftControls(
|
||||||
|
this.buildDraftAgentSessionConfig(msg.draftConfig),
|
||||||
|
);
|
||||||
|
this.host.emit({
|
||||||
|
type: "provider.controls.list.response",
|
||||||
|
payload: { provider, controls, options, error: null, fetchedAt, requestId: msg.requestId },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
{ err: error, provider, draftConfig: msg.draftConfig },
|
||||||
|
`Failed to list controls for ${provider}`,
|
||||||
|
);
|
||||||
|
this.host.emit({
|
||||||
|
type: "provider.controls.list.response",
|
||||||
|
payload: { provider, error: getErrorMessage(error), fetchedAt, requestId: msg.requestId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Answered from controls.
|
||||||
async handleListProviderFeaturesRequest(
|
async handleListProviderFeaturesRequest(
|
||||||
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
|
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const fetchedAt = new Date().toISOString();
|
const fetchedAt = new Date().toISOString();
|
||||||
try {
|
try {
|
||||||
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
|
const { controls, options } = await this.host.listDraftControls(
|
||||||
const features = await this.host.listDraftFeatures(sessionConfig);
|
this.buildDraftAgentSessionConfig(msg.draftConfig),
|
||||||
|
);
|
||||||
this.host.emit({
|
this.host.emit({
|
||||||
type: "list_provider_features_response",
|
type: "list_provider_features_response",
|
||||||
payload: {
|
payload: {
|
||||||
provider: msg.draftConfig.provider,
|
provider: msg.draftConfig.provider,
|
||||||
features,
|
features: featuresFromControls({ controls, options }),
|
||||||
error: null,
|
error: null,
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
requestId: msg.requestId,
|
requestId: msg.requestId,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user