mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
003ce4e248 | ||
|
|
a208ccda17 | ||
|
|
c5aeca3fc4 | ||
|
|
fa89738e3a |
@@ -135,32 +135,6 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Backport electron-builder#10172 to the pinned build tool only.
|
||||
# Remove this step when app-builder-lib is upgraded to a release containing it.
|
||||
- name: Repair temporary keychain password in signing tool
|
||||
shell: bash
|
||||
run: |
|
||||
node <<'KEYCHAIN_FIX'
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const manifestPath = require.resolve('app-builder-lib/package.json');
|
||||
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
|
||||
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
|
||||
}
|
||||
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
|
||||
let source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const replacements = [
|
||||
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
|
||||
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
|
||||
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
|
||||
];
|
||||
for (const [before, after] of replacements) {
|
||||
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
|
||||
source = source.replace(before, after);
|
||||
}
|
||||
fs.writeFileSync(sourcePath, source);
|
||||
KEYCHAIN_FIX
|
||||
|
||||
- name: Set desktop package version from tag
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -127,7 +127,7 @@ buildNpmPackage {
|
||||
# The Nix output is not a distributable DMG, so leave it unsigned and
|
||||
# disable the hardened runtime that requires a matching signature.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY=false \
|
||||
../../node_modules/.bin/electron-builder \
|
||||
npm exec --offline -- electron-builder \
|
||||
--config electron-builder.yml \
|
||||
--dir \
|
||||
--mac \
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
|
||||
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
|
||||
|
||||
Generated
+1018
-1295
File diff suppressed because it is too large
Load Diff
@@ -3,7 +3,7 @@ import {
|
||||
composerLocator,
|
||||
expectComposerDraft,
|
||||
expectComposerVisible,
|
||||
submitMessage,
|
||||
fillComposerDraft,
|
||||
typeIntoFocusedComposer,
|
||||
} from "../support/helpers/composer";
|
||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
|
||||
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) {
|
||||
await submitMessage(page, text);
|
||||
await fillComposerDraft(page, text);
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||
await expectComposerDraft(page, "");
|
||||
}
|
||||
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
|
||||
await pressInComposer(page, "ArrowUp");
|
||||
await expectComposerDraft(page, "add tests for login");
|
||||
|
||||
await pressInComposer(page, "Enter");
|
||||
await sendDraftWhenReady(page);
|
||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||
} finally {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.8.1",
|
||||
"electron-builder": "26.18.0",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
||||
@@ -21,10 +21,7 @@ export const AgentProfileSchema = z
|
||||
model: z.string().optional(),
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read as `options`.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Provider options applied when the profile creates an agent. */
|
||||
options: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -154,47 +154,6 @@ export interface AgentFeatureSelect {
|
||||
|
||||
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||
|
||||
export interface AgentOptionChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Other stored option values that select this choice. A saved value the provider has since
|
||||
* renamed still reads back as this choice.
|
||||
*/
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
interface AgentOptionControlBase {
|
||||
/** Top-level provider option key this control reads and writes. */
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export interface AgentOptionToggleControl extends AgentOptionControlBase {
|
||||
type: "toggle";
|
||||
/** What an unset option stands for; omitted means off. */
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentOptionSelectControl extends AgentOptionControlBase {
|
||||
type: "select";
|
||||
/** Desktop toolbar presentation; omitted means labeled. Mobile selectors stay labeled. */
|
||||
desktopTrigger?: "icon" | "label";
|
||||
/** The choice an unset option stands for. */
|
||||
defaultValue?: string;
|
||||
choices: AgentOptionChoice[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = AgentOptionToggleControl | AgentOptionSelectControl;
|
||||
|
||||
export interface AgentCapabilityFlags {
|
||||
[capability: string]: boolean | undefined;
|
||||
supportsStreaming: boolean;
|
||||
@@ -564,10 +523,6 @@ export interface AgentSessionConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
/**
|
||||
* @deprecated COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Folded into `providerOptions` at the daemon's edge; the daemon never reads it internally.
|
||||
*/
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
providerOptions?: ProviderOptions;
|
||||
|
||||
@@ -361,40 +361,6 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
|
||||
AgentFeatureSelectSchema,
|
||||
]);
|
||||
|
||||
export const AgentOptionChoiceSchema = z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
aliases: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const AgentOptionToggleControlSchema = z.object({
|
||||
type: z.literal("toggle"),
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
defaultValue: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const AgentOptionSelectControlSchema = z.object({
|
||||
type: z.literal("select"),
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
desktopTrigger: z.enum(["icon", "label"]).optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
choices: z.array(AgentOptionChoiceSchema),
|
||||
});
|
||||
|
||||
export const AgentOptionControlSchema = z.discriminatedUnion("type", [
|
||||
AgentOptionToggleControlSchema,
|
||||
AgentOptionSelectControlSchema,
|
||||
]);
|
||||
|
||||
const AgentModelDefinitionSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
id: z.string(),
|
||||
@@ -524,7 +490,6 @@ const AgentSessionConfigSchema = z.object({
|
||||
modeId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
@@ -901,11 +866,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
workspaceId: z.string().optional(),
|
||||
model: z.string().nullable(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Built from controls + options.
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
/** Effective provider options: `config.json` defaults deep-merged with the agent's own. */
|
||||
options: ProviderOptionsSchema.optional(),
|
||||
controls: z.array(AgentOptionControlSchema).optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||
createdAt: z.string(),
|
||||
@@ -2094,10 +2055,7 @@ export const AgentConfigApplySchema = z.object({
|
||||
modelId: z.string().nullable().optional(),
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Maps to an options patch.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Patch of the agent's own options: replaces top-level keys; `null` removes the key. */
|
||||
options: ProviderOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
|
||||
@@ -3013,9 +2971,7 @@ const ListCommandsDraftConfigSchema = z.object({
|
||||
modeId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||
@@ -3024,12 +2980,6 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderControlsListRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.controls.list.request"),
|
||||
draftConfig: ListCommandsDraftConfigSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ListCommandsRequestSchema = z.object({
|
||||
type: z.literal("list_commands_request"),
|
||||
agentId: z.string(),
|
||||
@@ -3207,7 +3157,6 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
toolPolicy: ToolPolicySchema.optional(),
|
||||
@@ -3408,7 +3357,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ListProviderModesRequestMessageSchema,
|
||||
ListProviderFeaturesRequestMessageSchema,
|
||||
ProviderControlsListRequestMessageSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
@@ -3888,7 +3836,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
selectiveAgentTimeline: z.boolean().optional(),
|
||||
explicitEventSubscriptions: z.boolean().optional(),
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
providerOptionControls: z.boolean().optional(),
|
||||
binaryChunkingV1: z.boolean().optional(),
|
||||
workspaceSetupObservation: z.boolean().optional(),
|
||||
workspaceSortTimes: z.boolean().optional(),
|
||||
@@ -6356,19 +6303,6 @@ export const ListProviderModesResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderControlsListResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.controls.list.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
controls: z.array(AgentOptionControlSchema).optional(),
|
||||
/** Effective options for the draft: `config.json` defaults merged with the draft's own. */
|
||||
options: ProviderOptionsSchema.optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
fetchedAt: z.string(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ListProviderFeaturesResponseMessageSchema = z.object({
|
||||
type: z.literal("list_provider_features_response"),
|
||||
payload: z.object({
|
||||
@@ -7261,7 +7195,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListProviderModesResponseMessageSchema,
|
||||
ListProviderFeaturesResponseMessageSchema,
|
||||
ProviderControlsListResponseMessageSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
GetProvidersSnapshotResponseMessageSchema,
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
@@ -7516,12 +7449,6 @@ export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
>;
|
||||
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
|
||||
export type ProviderControlsListRequestMessage = z.infer<
|
||||
typeof ProviderControlsListRequestMessageSchema
|
||||
>;
|
||||
export type ProviderControlsListResponseMessage = z.infer<
|
||||
typeof ProviderControlsListResponseMessageSchema
|
||||
>;
|
||||
export type ListProviderFeaturesRequestMessage = z.infer<
|
||||
typeof ListProviderFeaturesRequestMessageSchema
|
||||
>;
|
||||
|
||||
@@ -34,7 +34,6 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
|
||||
isolation: z.enum(["local", "worktree"]).optional(),
|
||||
title: z.string().trim().min(1).nullable().optional(),
|
||||
providerOptions: z.record(z.string(), z.unknown()).optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
mcpServers: z.record(z.string(), z.unknown()).optional(),
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mergeProviderOptions } from "./provider-options.js";
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import {
|
||||
prepareAgentMessage,
|
||||
@@ -7,7 +6,7 @@ import {
|
||||
} from "./agent-messages/index.js";
|
||||
import { expect, test, vi, onTestFinished } from "vitest";
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -24,7 +23,6 @@ import { AgentStorage } from "./agent-storage.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { startAgentRun } from "./agent-prompt.js";
|
||||
@@ -40,7 +38,7 @@ import type {
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
@@ -184,8 +182,13 @@ class RecordingTimelineStore implements AgentTimelineStore {
|
||||
}
|
||||
}
|
||||
|
||||
function createControl(args: { key: string; label: string }): AgentOptionControl {
|
||||
return { type: "toggle", key: args.key, label: args.label };
|
||||
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: args.id,
|
||||
label: args.label,
|
||||
value: args.value,
|
||||
};
|
||||
}
|
||||
|
||||
function expectArchivedAgentRecord(
|
||||
@@ -2329,7 +2332,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
|
||||
]);
|
||||
});
|
||||
|
||||
test("listDraftControls does not start a fallback session without a model", async () => {
|
||||
test("listDraftFeatures does not start a fallback session without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -2362,21 +2365,18 @@ test("listDraftControls does not start a fallback session without a model", asyn
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.listDraftControls({ provider: "codex", cwd: workdir })).resolves.toEqual({
|
||||
controls: [],
|
||||
options: {},
|
||||
});
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.availabilityCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("listDraftControls uses client feature listing without a model", async () => {
|
||||
test("listDraftFeatures uses client feature listing without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const draftControl = createControl({ key: "auto_accept", label: "Auto Accept" });
|
||||
const draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false });
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
@@ -2398,9 +2398,9 @@ test("listDraftControls uses client feature listing without a model", async () =
|
||||
return await super.createSession(config);
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
this.featureConfigs.push(config);
|
||||
return [draftControl];
|
||||
return [draftFeature];
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
@@ -2412,13 +2412,9 @@ test("listDraftControls uses client feature listing without a model", async () =
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.listDraftControls({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true },
|
||||
}),
|
||||
).resolves.toEqual({ controls: [draftControl], options: { auto_accept: true } });
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
|
||||
draftFeature,
|
||||
]);
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
@@ -2427,28 +2423,15 @@ test("listDraftControls uses client feature listing without a model", async () =
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true },
|
||||
},
|
||||
]);
|
||||
|
||||
// Options without a control come back too.
|
||||
await expect(
|
||||
manager.listDraftControls({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true, sandbox_mode: "workspace-write" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
controls: [draftControl],
|
||||
options: { auto_accept: true, sandbox_mode: "workspace-write" },
|
||||
});
|
||||
});
|
||||
|
||||
test("listDraftControls uses explicit model config without default model fetching", async () => {
|
||||
test("listDraftFeatures uses explicit model config without default model fetching", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const draftControl = createControl({ key: "fast_mode", label: "Fast mode" });
|
||||
const draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false });
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
@@ -2464,9 +2447,9 @@ test("listDraftControls uses explicit model config without default model fetchin
|
||||
return await super.createSession(config);
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
this.featureConfigs.push(config);
|
||||
return [draftControl];
|
||||
return [draftFeature];
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
@@ -2478,13 +2461,13 @@ test("listDraftControls uses explicit model config without default model fetchin
|
||||
logger,
|
||||
});
|
||||
|
||||
const draft = await manager.listDraftControls({
|
||||
const features = await manager.listDraftFeatures({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(draft).toEqual({ controls: [draftControl], options: {} });
|
||||
expect(features).toEqual([draftFeature]);
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.featureConfigs).toEqual([
|
||||
@@ -10001,13 +9984,16 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
expect(client.resumedModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("respondToPermission applies provider option changes and refreshes runtime info after provider-managed plan approval", async () => {
|
||||
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class RefreshingPermissionSession extends TestAgentSession {
|
||||
private planMode = true;
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
||||
];
|
||||
private modeId = "auto";
|
||||
private pending = [
|
||||
{
|
||||
@@ -10019,11 +10005,8 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
},
|
||||
];
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
createControl({ key: "fast_mode", label: "Fast" }),
|
||||
createControl({ key: "plan_mode", label: "Plan" }),
|
||||
];
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
@@ -10032,7 +10015,7 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
sessionId: this.id,
|
||||
model: "gpt-5.4",
|
||||
modeId: this.modeId,
|
||||
extra: { collaborationMode: this.planMode ? "Plan" : "Code" },
|
||||
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10047,12 +10030,10 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.modeId = "auto";
|
||||
this.pending = [];
|
||||
this.planMode = false;
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { plan_mode: false },
|
||||
});
|
||||
this.featureState = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10075,7 +10056,6 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { fast_mode: true, plan_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
@@ -10100,14 +10080,20 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.pendingPermissions.size).toBe(0);
|
||||
expect(updated?.effectiveOptions).toEqual({ fast_mode: true, plan_mode: false });
|
||||
expect(updated?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
expect(updated?.runtimeInfo).toMatchObject({
|
||||
model: "gpt-5.4",
|
||||
extra: { collaborationMode: "Code" },
|
||||
});
|
||||
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true, plan_mode: false });
|
||||
expect(persisted?.features).toEqual([
|
||||
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 () => {
|
||||
@@ -10116,6 +10102,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class OrderedPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
];
|
||||
private modeId = "plan";
|
||||
private pending = [
|
||||
{
|
||||
@@ -10127,8 +10116,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
},
|
||||
];
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [createControl({ key: "fast_mode", label: "Fast" })];
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
@@ -10149,11 +10138,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
}
|
||||
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { fast_mode: false },
|
||||
});
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: this.provider,
|
||||
@@ -10161,6 +10145,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
this.modeId = "acceptEdits";
|
||||
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
||||
this.pending = [];
|
||||
}
|
||||
}
|
||||
@@ -10184,7 +10169,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
@@ -10196,8 +10180,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
return;
|
||||
}
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
||||
seen.push(
|
||||
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.fast_mode)}`,
|
||||
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -12710,605 +12695,3 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
|
||||
expect(h.commands).toEqual(["/maintenance"]);
|
||||
expect(h.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
describe("agent options", () => {
|
||||
class OptionsSession extends TestAgentSession {
|
||||
readonly applied: ProviderOptions[] = [];
|
||||
rejectWith: Error | null = null;
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
if (this.rejectWith) throw this.rejectWith;
|
||||
this.applied.push(options);
|
||||
}
|
||||
}
|
||||
|
||||
class OptionsClient extends TestAgentClient {
|
||||
session: OptionsSession | null = null;
|
||||
|
||||
// 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);
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
|
||||
async function createOptionsAgent(providerOptions?: ProviderOptions) {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new OptionsClient();
|
||||
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({ fast_mode: true });
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
|
||||
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true });
|
||||
});
|
||||
|
||||
test("setAgentOptions on a running agent replaces top-level keys and applies the effective options", async () => {
|
||||
const { manager, client, storage, agentId } = await createOptionsAgent({ fast_mode: true });
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: "fast" });
|
||||
|
||||
expect(client.session?.applied).toEqual([{ service_tier: "fast", fast_mode: true }]);
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toEqual({ fast_mode: true, service_tier: "fast" });
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", fast_mode: true });
|
||||
await manager.flush();
|
||||
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
|
||||
fast_mode: true,
|
||||
service_tier: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
test("setAgentOptions removes a key with null so the config.json default applies again", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent({ service_tier: "fast" });
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: null });
|
||||
|
||||
// The provider is handed the agent's own options, so the removed key is absent and the
|
||||
// running provider keeps its setting; the default applies when the session is next opened.
|
||||
expect(client.session?.applied).toEqual([{}]);
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toBeUndefined();
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
|
||||
|
||||
// An unrelated patch afterwards still leaves the removed key to the running provider.
|
||||
await manager.setAgentOptions(agentId, { plan_mode: true });
|
||||
expect(client.session?.applied).toEqual([{}, { plan_mode: true }]);
|
||||
});
|
||||
|
||||
test("the snapshot reports the configured options and resolves aliases through controls", async () => {
|
||||
class AliasSession extends OptionsSession {
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
class AliasClient extends OptionsClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new AliasSession(config);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new AliasClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { service_tier: "fast" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
const payload = toAgentPayload(manager.getAgent(created.id)!);
|
||||
expect(payload.options).toEqual({ service_tier: "fast" });
|
||||
expect(payload.features).toEqual([
|
||||
expect.objectContaining({ id: "service_tier", value: "priority" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("after a removal the snapshot shows the live setting, not the configured default", async () => {
|
||||
class LiveSession extends OptionsSession {
|
||||
private tier = "priority";
|
||||
override async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
await super.applyOptions(options);
|
||||
if (typeof options.service_tier === "string") this.tier = options.service_tier;
|
||||
}
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: this.tier,
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
class LiveClient extends OptionsClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new LiveSession(config);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new LiveClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { service_tier: "priority" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
await manager.setAgentOptions(created.id, { service_tier: null });
|
||||
|
||||
const agent = manager.getAgent(created.id)!;
|
||||
expect(agent.config.providerOptions).toBeUndefined();
|
||||
expect(toAgentPayload(agent).options).toEqual({ service_tier: "priority" });
|
||||
expect(toAgentPayload(agent).features).toEqual([
|
||||
expect.objectContaining({ id: "service_tier", value: "priority" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("a supplied option is resolved against the configured defaults before it reaches the provider", async () => {
|
||||
class DefaultsClient extends OptionsClient {
|
||||
override effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
|
||||
return (
|
||||
mergeProviderOptions({ sandbox: { enabled: true, failIfUnavailable: true } }, own) ?? {}
|
||||
);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const client = new DefaultsClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
await manager.setAgentOptions(created.id, { sandbox: { network: { allowed: ["a.example"] } } });
|
||||
|
||||
expect(client.session?.applied).toEqual([
|
||||
{
|
||||
sandbox: { enabled: true, failIfUnavailable: true, network: { allowed: ["a.example"] } },
|
||||
},
|
||||
]);
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
||||
sandbox: { network: { allowed: ["a.example"] } },
|
||||
});
|
||||
});
|
||||
|
||||
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("a provider that rejects an option leaves the stored options unchanged", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false });
|
||||
client.session!.rejectWith = new Error("fast mode unavailable");
|
||||
|
||||
await expect(manager.setAgentOptions(agentId, { fast_mode: true })).rejects.toThrow(
|
||||
"fast mode unavailable",
|
||||
);
|
||||
|
||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: 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: { fast_mode: true } },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ fast_mode: 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, fast_mode: true },
|
||||
},
|
||||
}
|
||||
: request,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
||||
fast_mode: 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: { fast_mode: false, plan_mode: true } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
||||
fast_mode: false,
|
||||
plan_mode: false,
|
||||
});
|
||||
});
|
||||
|
||||
async function optionsAfterCreateHook(
|
||||
edit: (config: AgentSessionConfig) => AgentSessionConfig,
|
||||
): Promise<ProviderOptions | undefined> {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new OptionsClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
pluginLifecycle: {
|
||||
emit: () => {},
|
||||
before: async (name, request) =>
|
||||
name === "agent.create.before" ? { ...request, config: edit(request.config) } : request,
|
||||
},
|
||||
});
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { fast_mode: true, plan_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
return manager.getAgent(agent.id)?.config.providerOptions;
|
||||
}
|
||||
|
||||
function withoutFastMode(values: Record<string, unknown> | null | undefined) {
|
||||
const { fast_mode: _removed, ...rest } = values ?? {};
|
||||
return rest;
|
||||
}
|
||||
|
||||
test("agent.create.before hook clearing providerOptions leaves the agent without options", async () => {
|
||||
const options = await optionsAfterCreateHook((config) => ({ ...config, providerOptions: {} }));
|
||||
expect(options).toEqual({});
|
||||
});
|
||||
|
||||
test("agent.create.before hook deleting one providerOptions key keeps the others", async () => {
|
||||
const options = await optionsAfterCreateHook((config) => ({
|
||||
...config,
|
||||
providerOptions: withoutFastMode(config.providerOptions),
|
||||
}));
|
||||
expect(options).toEqual({ plan_mode: true });
|
||||
});
|
||||
|
||||
test("agent.create.before hook clearing featureValues leaves the agent without options", async () => {
|
||||
const options = await optionsAfterCreateHook((config) => ({ ...config, featureValues: {} }));
|
||||
expect(options).toEqual({});
|
||||
});
|
||||
|
||||
test("agent.create.before hook omitting featureValues leaves the agent without options", async () => {
|
||||
const options = await optionsAfterCreateHook((config) => {
|
||||
const { featureValues: _omitted, ...rest } = config;
|
||||
return rest;
|
||||
});
|
||||
expect(options).toEqual({});
|
||||
});
|
||||
|
||||
test("agent.create.before hook returning undefined featureValues leaves the agent without options", async () => {
|
||||
const options = await optionsAfterCreateHook((config) => ({
|
||||
...config,
|
||||
featureValues: undefined,
|
||||
}));
|
||||
expect(options).toEqual({});
|
||||
});
|
||||
|
||||
test("agent.create.before hook deleting one featureValues key keeps the others", async () => {
|
||||
const options = await optionsAfterCreateHook((config) => ({
|
||||
...config,
|
||||
featureValues: withoutFastMode(config.featureValues),
|
||||
}));
|
||||
expect(options).toEqual({ plan_mode: true });
|
||||
});
|
||||
|
||||
test("overlapping patches to different keys both reach the provider and the stored record", async () => {
|
||||
let release: () => void = () => {};
|
||||
const firstApplyHeld = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
class HeldSession extends OptionsSession {
|
||||
override async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const first = this.applied.length === 0;
|
||||
this.applied.push(options);
|
||||
if (first) await firstApplyHeld;
|
||||
}
|
||||
}
|
||||
class HeldClient extends OptionsClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
const session = new HeldSession(config);
|
||||
this.session = session;
|
||||
return session;
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-overlap-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new HeldClient();
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
const speed = manager.setAgentOptions(created.id, { service_tier: "fast" });
|
||||
const plan = manager.setAgentOptions(created.id, { plan_mode: true });
|
||||
release();
|
||||
await Promise.all([speed, plan]);
|
||||
|
||||
expect(client.session?.applied.at(-1)).toEqual({ service_tier: "fast", plan_mode: true });
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
||||
service_tier: "fast",
|
||||
plan_mode: true,
|
||||
});
|
||||
await manager.flush();
|
||||
expect((await storage.get(created.id))?.config?.providerOptions).toEqual({
|
||||
service_tier: "fast",
|
||||
plan_mode: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a rejected update that saw provider events leaves the record, snapshot and stored copy as they were", async () => {
|
||||
class NoisySession extends OptionsSession {
|
||||
override async applyOptions(): Promise<void> {
|
||||
// The provider reports a change and a usage update, then refuses the request.
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { voice: "soft" },
|
||||
});
|
||||
this.pushEvent({
|
||||
type: "usage_updated",
|
||||
provider: this.provider,
|
||||
usage: { inputTokens: 1 },
|
||||
});
|
||||
throw new Error("refused");
|
||||
}
|
||||
}
|
||||
class NoisyClient extends OptionsClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.session = new NoisySession(config);
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-rejected-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new NoisyClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { voice: "direct" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const published: Array<ProviderOptions | undefined> = [];
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === created.id) {
|
||||
published.push(event.agent.effectiveOptions);
|
||||
}
|
||||
},
|
||||
{ replayState: false },
|
||||
);
|
||||
|
||||
await expect(manager.setAgentOptions(created.id, { voice: "soft" })).rejects.toThrow("refused");
|
||||
await manager.flush();
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ voice: "direct" });
|
||||
expect(toAgentPayload(manager.getAgent(created.id)!).options).toMatchObject({
|
||||
voice: "direct",
|
||||
});
|
||||
expect(published.every((options) => options?.voice === "direct")).toBe(true);
|
||||
expect((await storage.get(created.id))?.config?.providerOptions).toEqual({ voice: "direct" });
|
||||
});
|
||||
|
||||
test("a metadata-only update of a stored agent reports its saved options and config defaults", async () => {
|
||||
class PlanSession extends OptionsSession {
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [createControl({ key: "plan_mode", label: "Plan" })];
|
||||
}
|
||||
}
|
||||
class PlanClient extends OptionsClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new PlanSession(config);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-stored-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new PlanClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { plan_mode: true } },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-1" },
|
||||
);
|
||||
await manager.closeAgent(created.id);
|
||||
|
||||
const updates: ManagedAgent[] = [];
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === created.id)
|
||||
updates.push(event.agent);
|
||||
},
|
||||
{ replayState: false },
|
||||
);
|
||||
await manager.markAgentUnread(created.id);
|
||||
|
||||
expect(updates).toHaveLength(1);
|
||||
const payload = toAgentPayload(updates[0]!);
|
||||
expect(payload.options).toEqual({ service_tier: "default", plan_mode: true });
|
||||
expect(payload.features?.find((feature) => feature.id === "plan_mode")).toMatchObject({
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a metadata-only update of an agent stored with legacy features reports their values", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-legacy-features-"));
|
||||
const agentsDir = join(workdir, "agents");
|
||||
const first = new AgentManager({
|
||||
clients: { codex: new OptionsClient() },
|
||||
registry: new AgentStorage(agentsDir, logger),
|
||||
logger,
|
||||
});
|
||||
const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: "workspace-1",
|
||||
});
|
||||
await first.closeAgent(created.id);
|
||||
|
||||
// Rewrite the record as an older daemon stored it: features with values, no controls.
|
||||
const dir = readdirSync(agentsDir, { withFileTypes: true }).find(
|
||||
(entry) =>
|
||||
entry.isDirectory() && existsSync(join(agentsDir, entry.name, `${created.id}.json`)),
|
||||
)!;
|
||||
const file = join(agentsDir, dir.name, `${created.id}.json`);
|
||||
const stored = JSON.parse(readFileSync(file, "utf8"));
|
||||
delete stored.controls;
|
||||
stored.features = [
|
||||
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: "soft",
|
||||
options: [
|
||||
{ id: "direct", label: "Direct" },
|
||||
{ id: "soft", label: "Soft" },
|
||||
],
|
||||
},
|
||||
];
|
||||
writeFileSync(file, JSON.stringify(stored));
|
||||
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new OptionsClient() },
|
||||
registry: new AgentStorage(agentsDir, logger),
|
||||
logger,
|
||||
});
|
||||
const updates: ManagedAgent[] = [];
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === created.id)
|
||||
updates.push(event.agent);
|
||||
},
|
||||
{ replayState: false },
|
||||
);
|
||||
await manager.markAgentUnread(created.id);
|
||||
|
||||
const payload = toAgentPayload(updates.at(-1)!);
|
||||
expect(payload.features?.map((feature) => [feature.id, feature.value])).toEqual([
|
||||
["fast_mode", true],
|
||||
["voice", "soft"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentResumePurpose,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentSlashCommand,
|
||||
type AgentMode,
|
||||
@@ -59,14 +59,7 @@ import {
|
||||
type ListImportableSessionsOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import {
|
||||
applyProviderOptionsPatch,
|
||||
exposeFeatureValuesToHook,
|
||||
foldHookFeatureValues,
|
||||
type ProviderOptionsPatch,
|
||||
} from "./option-controls.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentOwner } from "./agent-owner.js";
|
||||
import {
|
||||
@@ -217,6 +210,9 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (record.config.thinkingOptionId != null) {
|
||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||
}
|
||||
if (record.config.featureValues != null) {
|
||||
config.featureValues = record.config.featureValues;
|
||||
}
|
||||
if (record.config.providerOptions != null) {
|
||||
config.providerOptions = record.config.providerOptions;
|
||||
}
|
||||
@@ -432,9 +428,7 @@ interface ManagedAgentBase {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
availableModes: AgentMode[];
|
||||
controls?: AgentOptionControl[];
|
||||
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
|
||||
effectiveOptions?: ProviderOptions;
|
||||
features?: AgentFeature[];
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
bufferedPermissionResolutions: Map<
|
||||
@@ -781,8 +775,6 @@ export class AgentManager {
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
|
||||
private readonly collectingRuntimes = new Set<string>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
/** Options a `setAgentOptions` call is applying, until the provider accepts or rejects them. */
|
||||
private readonly pendingOptionCandidates = new Map<string, ProviderOptions>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly mcpAuthToken: string | null;
|
||||
@@ -1251,14 +1243,11 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async listDraftControls(
|
||||
config: AgentSessionConfig,
|
||||
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
|
||||
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const options = this.effectiveOptionsFor(normalizedConfig.provider, config.providerOptions);
|
||||
if (!normalizedConfig.model && !client.listControls) {
|
||||
return { controls: [], options };
|
||||
if (!normalizedConfig.model && !client.listFeatures) {
|
||||
return [];
|
||||
}
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
@@ -1267,13 +1256,13 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (client.listControls) {
|
||||
return { controls: await client.listControls(normalizedConfig), options };
|
||||
if (client.listFeatures) {
|
||||
return await client.listFeatures(normalizedConfig);
|
||||
}
|
||||
|
||||
const session = await client.createSession(normalizedConfig);
|
||||
try {
|
||||
return { controls: session.controls ?? [], options };
|
||||
return session.features ?? [];
|
||||
} finally {
|
||||
try {
|
||||
await session.close();
|
||||
@@ -1372,14 +1361,11 @@ export class AgentManager {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
if (this.pluginLifecycle && !config.internal) {
|
||||
const sent = exposeFeatureValuesToHook(config);
|
||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||
config: sent,
|
||||
config,
|
||||
env: options.env,
|
||||
});
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||
// against the previous SDK edits `config.featureValues`.
|
||||
config = { ...foldHookFeatureValues(request.config, sent), internal: config.internal };
|
||||
config = { ...request.config, internal: config.internal };
|
||||
options = { ...options, env: request.env };
|
||||
}
|
||||
await this.deleteAgentState(resolvedAgentId);
|
||||
@@ -2175,11 +2161,7 @@ export class AgentManager {
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt,
|
||||
availableModes: [],
|
||||
controls: record.controls,
|
||||
effectiveOptions: this.effectiveOptionsFor(
|
||||
record.provider,
|
||||
record.config?.providerOptions ?? undefined,
|
||||
),
|
||||
features: record.features,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
@@ -2272,53 +2254,18 @@ export class AgentManager {
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the agent's own options and hands the provider the result. The provider applies what
|
||||
* it can live and reads the rest, with the `config.json` defaults, at next open; a provider that rejects a
|
||||
* value leaves the stored options unchanged.
|
||||
*/
|
||||
async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void> {
|
||||
// One patch at a time per agent: each derives its options from the committed record, so
|
||||
// overlapping patches to different keys would otherwise overwrite one another.
|
||||
await this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = this.requireAgent(agentId);
|
||||
let candidate = applyProviderOptionsPatch(agent.config.providerOptions, patch);
|
||||
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
|
||||
if (agent.session.applyOptions) {
|
||||
// The candidate stays out of the agent's record until the provider accepts it. Values
|
||||
// the provider reports meanwhile (`options_changed`) are folded into the candidate, so
|
||||
// a rejection leaves the record, its snapshots and its stored copy as they were.
|
||||
this.pendingOptionCandidates.set(agentId, candidate);
|
||||
try {
|
||||
await agent.session.applyOptions(this.liveOptionsFor(agent.provider, candidate));
|
||||
} finally {
|
||||
// Events the provider queued before answering must reach the candidate, not the record.
|
||||
await this.drainSessionEvents(agentId);
|
||||
candidate = this.pendingOptionCandidates.get(agentId) ?? candidate;
|
||||
this.pendingOptionCandidates.delete(agentId);
|
||||
}
|
||||
}
|
||||
agent.config.providerOptions = Object.keys(candidate).length > 0 ? candidate : undefined;
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
});
|
||||
}
|
||||
if (!agent.session.setFeature) {
|
||||
throw new Error("Agent session does not support setting features");
|
||||
}
|
||||
|
||||
/**
|
||||
* The options a running provider is handed: the keys the agent holds, each resolved against
|
||||
* the `config.json` defaults. A removed key is absent, so the provider keeps its setting;
|
||||
* defaults for absent keys apply when the session is next opened.
|
||||
*/
|
||||
private liveOptionsFor(provider: AgentProvider, own: ProviderOptions): ProviderOptions {
|
||||
const effective = this.effectiveOptionsFor(provider, own);
|
||||
return Object.fromEntries(Object.keys(own).map((key) => [key, effective[key]]));
|
||||
}
|
||||
|
||||
private effectiveOptionsFor(
|
||||
provider: AgentProvider,
|
||||
own: ProviderOptions | undefined,
|
||||
): ProviderOptions {
|
||||
return this.clients.get(provider)?.effectiveOptions?.(own) ?? own ?? {};
|
||||
await agent.session.setFeature(featureId, value);
|
||||
await this.drainSessionEvents(agentId);
|
||||
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
async setTitle(agentId: string, title: string): Promise<void> {
|
||||
@@ -4364,7 +4311,7 @@ export class AgentManager {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
this.syncControls(agent);
|
||||
this.syncFeaturesFromSession(agent);
|
||||
await this.refreshRuntimeInfo(agent, options);
|
||||
}
|
||||
|
||||
@@ -4710,23 +4657,6 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
private onStreamOptionsChanged(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
patch: ProviderOptionsPatch;
|
||||
flags: StreamEventFlags;
|
||||
}): void {
|
||||
const { agent, patch, flags } = params;
|
||||
flags.shouldDispatchEvent = false;
|
||||
const candidate = this.pendingOptionCandidates.get(agent.id);
|
||||
if (candidate) {
|
||||
this.pendingOptionCandidates.set(agent.id, applyProviderOptionsPatch(candidate, patch));
|
||||
return;
|
||||
}
|
||||
const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch);
|
||||
agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined;
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
private dispatchStreamEventByType(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: AgentStreamEvent;
|
||||
@@ -4778,9 +4708,6 @@ export class AgentManager {
|
||||
flags.shouldDispatchEvent = false;
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
case "options_changed":
|
||||
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
|
||||
return undefined;
|
||||
case "timeline":
|
||||
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
||||
case "turn_completed":
|
||||
@@ -5256,7 +5183,7 @@ export class AgentManager {
|
||||
this.enqueueBackgroundPersist(agent);
|
||||
}
|
||||
|
||||
this.syncControls(agent);
|
||||
this.syncFeaturesFromSession(agent);
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -5278,21 +5205,10 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private syncControls(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.controls) {
|
||||
agent.controls = agent.session.controls;
|
||||
private syncFeaturesFromSession(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
const own = agent.config.providerOptions;
|
||||
const effective = { ...this.effectiveOptionsFor(agent.provider, own) };
|
||||
// After a removal the running provider keeps its setting, so a control it reports for a key
|
||||
// the agent no longer holds shows that live value rather than the `config.json` default.
|
||||
for (const control of agent.controls ?? []) {
|
||||
const live = control.defaultValue;
|
||||
if (own?.[control.key] === undefined && control.key in effective && live !== undefined) {
|
||||
effective[control.key] = live;
|
||||
}
|
||||
}
|
||||
agent.effectiveOptions = effective;
|
||||
}
|
||||
|
||||
private checkAndSetAttention(agent: ManagedAgent): void {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "./agent-projections.js";
|
||||
import type { AgentSession } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
ImportableProviderSession,
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
@@ -128,6 +128,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toStoredAgentRecord", () => {
|
||||
it("captures lifecycle metadata, config, and persistence", () => {
|
||||
const agent = createManagedAgent({
|
||||
@@ -464,44 +474,13 @@ describe("toAgentPayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects controls, effective options, and legacy features from the same source", () => {
|
||||
const controls: AgentOptionControl[] = [
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
];
|
||||
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
|
||||
// Options without a control are part of the snapshot too.
|
||||
const agent = createManagedAgent({
|
||||
controls,
|
||||
effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" },
|
||||
});
|
||||
it("includes features in the snapshot payload", () => {
|
||||
const features = [createFeature()];
|
||||
const agent = createManagedAgent({ features });
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
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" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(payload.features).toEqual(features);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
@@ -18,9 +18,7 @@ import type {
|
||||
AgentUsage,
|
||||
ImportableProviderSession,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import { featuresFromControls } from "./option-controls.js";
|
||||
import type { JsonValue } from "../json-utils.js";
|
||||
import {
|
||||
isStoredAgentProviderAvailable,
|
||||
@@ -99,7 +97,7 @@ export function toStoredAgentRecord(
|
||||
config: config ?? null,
|
||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||
runtimeInfo,
|
||||
controls: normalizeControls(agent.controls),
|
||||
features: normalizeFeatures(agent.features),
|
||||
persistence,
|
||||
lastError: agent.lastError ?? undefined,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
@@ -145,12 +143,7 @@ export function toAgentPayload(
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
features: featuresFromControls(agent.controls, agent.effectiveOptions),
|
||||
...(agent.effectiveOptions
|
||||
? { options: sanitizeEffectiveOptions(agent.effectiveOptions) }
|
||||
: {}),
|
||||
controls: normalizeControls(agent.controls),
|
||||
features: normalizeFeatures(agent.features),
|
||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
@@ -382,6 +375,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
||||
if (config.thinkingOptionId) {
|
||||
serializable.thinkingOptionId = config.thinkingOptionId;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
|
||||
const featureValues = sanitizeMetadata(config.featureValues);
|
||||
if (featureValues !== undefined) {
|
||||
serializable.featureValues = featureValues;
|
||||
}
|
||||
}
|
||||
if (config.providerOptions !== undefined) {
|
||||
const providerOptions = sanitizeOptionalJson(config.providerOptions);
|
||||
if (providerOptions && isJsonObject(providerOptions)) {
|
||||
@@ -457,15 +456,8 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
|
||||
return modes.map((mode) => ({ ...mode }));
|
||||
}
|
||||
|
||||
function normalizeControls(
|
||||
controls: AgentOptionControl[] | null | undefined,
|
||||
): AgentOptionControl[] {
|
||||
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
|
||||
}
|
||||
|
||||
function sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions {
|
||||
const sanitized = sanitizeOptionalJson(options);
|
||||
return sanitized && isJsonObject(sanitized) ? sanitized : {};
|
||||
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
|
||||
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
|
||||
}
|
||||
|
||||
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
JsonValue,
|
||||
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type {
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
};
|
||||
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
|
||||
export interface ResolveAgentCreateConfigInput {
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
parent: AgentCreateConfigParent | null;
|
||||
unattended: boolean;
|
||||
availableModes: AgentMode[] | undefined;
|
||||
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
|
||||
|
||||
export interface ResolveAgentCreateConfigResult {
|
||||
modeId: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface AgentCreateConfigUnattendedInput {
|
||||
modeId: string | null;
|
||||
config: AgentSessionConfig;
|
||||
controls?: AgentOptionControl[];
|
||||
features?: AgentFeature[];
|
||||
availableModes: AgentMode[];
|
||||
}
|
||||
|
||||
@@ -429,8 +429,6 @@ export type AgentStreamEvent =
|
||||
provider: AgentProvider;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
/** The provider changed options on its own; `null` removes the agent's own value. */
|
||||
| { type: "options_changed"; provider: AgentProvider; patch: Record<string, unknown> }
|
||||
| {
|
||||
type: "turn_failed";
|
||||
provider: AgentProvider;
|
||||
@@ -612,6 +610,7 @@ export interface AgentSessionConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
@@ -680,8 +679,7 @@ export interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
/** Composer controls over top-level option keys. Values come from the agent's effective options. */
|
||||
readonly controls?: AgentOptionControl[];
|
||||
readonly features?: AgentFeature[];
|
||||
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
@@ -716,12 +714,7 @@ export interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
/**
|
||||
* The agent's effective options changed (defaults merged with the agent's own, after the
|
||||
* patch). Apply what can change live and read the rest at next open. Throw to reject a value;
|
||||
* the daemon stores the patch only when this resolves.
|
||||
*/
|
||||
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||
revertBoth?(input: { messageId: string }): Promise<void>;
|
||||
@@ -804,9 +797,7 @@ export interface AgentClient {
|
||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||
listControls?(config: AgentSessionConfig): Promise<AgentOptionControl[]>;
|
||||
/** Effective options: provider defaults from `config.json` deep-merged with the agent's own. */
|
||||
effectiveOptions?(own: ProviderOptions | undefined): ProviderOptions;
|
||||
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||
listImportableSessions?(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
@@ -44,6 +44,9 @@ function buildManagedAgentConfig(
|
||||
systemPrompt: configOverrides.systemPrompt,
|
||||
mcpServers: configOverrides.mcpServers,
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
||||
config.featureValues = configOverrides.featureValues;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -129,14 +132,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
};
|
||||
}
|
||||
|
||||
function findRecordFile(baseDir: string, agentId: string): string {
|
||||
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
||||
const candidate = path.join(baseDir, entry.name, `${agentId}.json`);
|
||||
if (entry.isDirectory() && existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`No stored record for ${agentId}`);
|
||||
}
|
||||
|
||||
describe("AgentStorage", () => {
|
||||
let tmpDir: string;
|
||||
let storagePath: string;
|
||||
@@ -200,75 +195,60 @@ describe("AgentStorage", () => {
|
||||
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
|
||||
});
|
||||
|
||||
test("applySnapshot stores and reloads composer values as providerOptions", async () => {
|
||||
test("applySnapshot stores and reloads featureValues when present", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-provider-options",
|
||||
config: { providerOptions: { fast_mode: true } },
|
||||
id: "agent-feature-values",
|
||||
config: {
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("agent-feature-values");
|
||||
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-feature-values");
|
||||
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
test("applySnapshot keeps featureValues absent when they were never set", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-no-feature-values",
|
||||
}),
|
||||
);
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-provider-options");
|
||||
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
|
||||
expect(buildConfigOverrides(persisted!)).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
const persisted = await reloaded.get("agent-no-feature-values");
|
||||
expect(persisted?.config?.featureValues).toBeUndefined();
|
||||
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
|
||||
});
|
||||
|
||||
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" },
|
||||
],
|
||||
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",
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
];
|
||||
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", defaultValue: true },
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "fast",
|
||||
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 () => {
|
||||
|
||||
@@ -4,19 +4,17 @@ import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
||||
import { controlsFromFeatures, foldFeatureValues } from "./option-controls.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.object({
|
||||
modeId: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; folded on parse.
|
||||
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
toolPolicy: z
|
||||
@@ -44,7 +42,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
const STORED_AGENT_SCHEMA = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -70,8 +68,6 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
extra: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
controls: z.array(AgentOptionControlSchema).optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; becomes controls.
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
lastError: z.string().nullable().optional(),
|
||||
@@ -88,32 +84,14 @@ export type SerializableAgentConfig = Pick<
|
||||
| "modeId"
|
||||
| "model"
|
||||
| "thinkingOptionId"
|
||||
| "featureValues"
|
||||
| "providerOptions"
|
||||
| "toolPolicy"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
>;
|
||||
|
||||
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 type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
||||
return STORED_AGENT_SCHEMA.parse(value);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
|
||||
availableModes: availableModeIds,
|
||||
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
||||
}),
|
||||
providerOptions: input.providerOptions,
|
||||
featureValues: input.featureValues,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ test("session create applies the resolved mode from the provider create config",
|
||||
const stub = createProviderSnapshotManagerStub();
|
||||
stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "build",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const dependencies: Parameters<typeof createAgentCommand>[0] = {
|
||||
agentManager: {
|
||||
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modeId: "build",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
}),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
|
||||
@@ -22,8 +22,6 @@ import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { foldFeatureValues, type LegacyFeatureValues } from "../option-controls.js";
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
finishSubscriptions?: FinishSubscriptions;
|
||||
@@ -49,8 +47,7 @@ export interface CreateAgentFromSessionInput {
|
||||
kind: "session";
|
||||
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
|
||||
agentId?: string;
|
||||
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
|
||||
config: AgentSessionConfig & LegacyFeatureValues;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
@@ -73,7 +70,7 @@ export interface CreateAgentFromMcpInput {
|
||||
cwd?: string;
|
||||
workspaceId?: string;
|
||||
thinking?: string;
|
||||
providerOptions?: ProviderOptions;
|
||||
features?: Record<string, unknown>;
|
||||
labels?: Record<string, string>;
|
||||
mode?: string;
|
||||
unattended?: boolean;
|
||||
@@ -221,19 +218,19 @@ async function resolveSessionCreateAgent(
|
||||
input: CreateAgentFromSessionInput,
|
||||
): Promise<ResolvedCreateAgent> {
|
||||
const trimmedPrompt = input.initialPrompt?.trim();
|
||||
const builtSessionConfig = foldFeatureValues(input.config);
|
||||
const builtSessionConfig = input.config;
|
||||
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: builtSessionConfig.cwd,
|
||||
provider: builtSessionConfig.provider,
|
||||
requestedMode: builtSessionConfig.modeId,
|
||||
providerOptions: builtSessionConfig.providerOptions,
|
||||
featureValues: builtSessionConfig.featureValues,
|
||||
parent: null,
|
||||
unattended: false,
|
||||
});
|
||||
const sessionConfig: AgentSessionConfig = {
|
||||
...builtSessionConfig,
|
||||
modeId: resolvedCreateConfig.modeId,
|
||||
providerOptions: resolvedCreateConfig.providerOptions,
|
||||
featureValues: resolvedCreateConfig.featureValues,
|
||||
};
|
||||
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
||||
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
||||
@@ -326,7 +323,7 @@ async function resolveMcpCreateAgent(
|
||||
resolvedCwd: intent.cwd,
|
||||
trimmedPrompt,
|
||||
resolvedMode: resolvedCreateConfig.modeId,
|
||||
resolvedProviderOptions: resolvedCreateConfig.providerOptions,
|
||||
resolvedFeatures: resolvedCreateConfig.featureValues,
|
||||
}),
|
||||
createOptions: {
|
||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||
@@ -364,23 +361,18 @@ async function resolveMcpProviderCreateConfig(params: {
|
||||
provider: string;
|
||||
resolvedCwd: string;
|
||||
parentAgent: ManagedAgent | null;
|
||||
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
|
||||
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
|
||||
const passthroughConfig = params.input.config;
|
||||
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: params.resolvedCwd,
|
||||
provider: params.provider,
|
||||
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
|
||||
providerOptions: mergeMcpProviderOptions(params.input),
|
||||
featureValues: params.input.features ?? passthroughConfig?.featureValues,
|
||||
parent: params.parentAgent,
|
||||
unattended: params.input.unattended ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeMcpProviderOptions(input: CreateAgentFromMcpInput): ProviderOptions | undefined {
|
||||
if (!input.providerOptions) return input.config?.providerOptions;
|
||||
return { ...input.config?.providerOptions, ...input.providerOptions };
|
||||
}
|
||||
|
||||
function buildMcpSessionConfig(params: {
|
||||
input: CreateAgentFromMcpInput;
|
||||
resolvedProviderModel: ResolvedProviderModel;
|
||||
@@ -388,14 +380,14 @@ function buildMcpSessionConfig(params: {
|
||||
resolvedCwd: string;
|
||||
trimmedPrompt: string;
|
||||
resolvedMode?: string;
|
||||
resolvedProviderOptions?: ProviderOptions;
|
||||
resolvedFeatures?: Record<string, unknown>;
|
||||
}): AgentSessionConfig {
|
||||
const passthroughConfig = params.input.config;
|
||||
const { provisionalTitle } = resolveCreateAgentTitles({
|
||||
configTitle: passthroughConfig?.title ?? params.input.title,
|
||||
initialPrompt: params.trimmedPrompt,
|
||||
});
|
||||
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
|
||||
const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
|
||||
const config: AgentSessionConfig = {
|
||||
...passthroughConfig,
|
||||
provider: params.provider,
|
||||
@@ -408,8 +400,8 @@ function buildMcpSessionConfig(params: {
|
||||
if (provisionalTitle) {
|
||||
config.title = provisionalTitle;
|
||||
}
|
||||
if (providerOptions) {
|
||||
config.providerOptions = providerOptions;
|
||||
if (featureValues) {
|
||||
config.featureValues = featureValues;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
|
||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentOptions: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
|
||||
cancelAgentRun: vi.fn(),
|
||||
getPendingPermissions: vi.fn(),
|
||||
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||
listDraftControls: vi.fn(),
|
||||
listDraftFeatures: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -472,9 +472,9 @@ function configureOpenCodeProviderStub(
|
||||
const opts = input as {
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
};
|
||||
return { modeId: opts.requestedMode, providerOptions: opts.providerOptions };
|
||||
return { modeId: opts.requestedMode, featureValues: opts.featureValues };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1805,7 +1805,7 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Feature test", providerOptions: { fast_mode: true } },
|
||||
config: { title: "Feature test", featureValues: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1834,7 +1834,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
@@ -3039,12 +3039,12 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Child", providerOptions: { fast_mode: true } },
|
||||
config: { title: "Child", featureValues: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||
const opts = input as { providerOptions: Record<string, unknown> | undefined };
|
||||
return { modeId: undefined, providerOptions: opts.providerOptions };
|
||||
const opts = input as { featureValues: Record<string, unknown> | undefined };
|
||||
return { modeId: undefined, featureValues: opts.featureValues };
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -3072,7 +3072,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
@@ -3273,7 +3273,7 @@ describe("create_agent MCP tool", () => {
|
||||
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||
const opts = input as { requestedMode: string | undefined };
|
||||
expect(opts.requestedMode).toBe("dynamic");
|
||||
return { modeId: "dynamic", providerOptions: undefined };
|
||||
return { modeId: "dynamic", featureValues: undefined };
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -3307,12 +3307,12 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: "build",
|
||||
availableModes: [],
|
||||
config: { title: "Child", providerOptions: { auto_accept: true } },
|
||||
config: { title: "Child", featureValues: { auto_accept: true } },
|
||||
} as ManagedAgent);
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "build",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -3332,7 +3332,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }),
|
||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
@@ -3360,7 +3360,7 @@ describe("create_agent MCP tool", () => {
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "resolver-mode",
|
||||
providerOptions: { resolver_feature: true },
|
||||
featureValues: { resolver_feature: true },
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -3384,7 +3384,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modeId: "resolver-mode",
|
||||
providerOptions: { resolver_feature: true },
|
||||
featureValues: { resolver_feature: true },
|
||||
}),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
@@ -4717,7 +4717,7 @@ describe("update_agent MCP tool", () => {
|
||||
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
||||
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true });
|
||||
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
||||
title: "Updated agent",
|
||||
labels: { role: "worker" },
|
||||
@@ -4742,12 +4742,12 @@ describe("update_agent MCP tool", () => {
|
||||
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update metadata when runtime settings fail", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature"));
|
||||
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -5065,7 +5065,7 @@ describe("create_schedule MCP tool", () => {
|
||||
config: {
|
||||
title: "Parent agent",
|
||||
model: "openai/gpt-5.5",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
} as ManagedAgent);
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
@@ -5093,7 +5093,7 @@ describe("create_schedule MCP tool", () => {
|
||||
cwd: REPO_CWD,
|
||||
modeId: "build",
|
||||
model: "openai/gpt-5.5",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -5867,7 +5867,7 @@ describe("agent profile listing MCP tool", () => {
|
||||
model: "claude-test-model",
|
||||
modeId: "bypassPermissions",
|
||||
thinkingOptionId: "high",
|
||||
options: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
||||
},
|
||||
];
|
||||
@@ -5882,21 +5882,7 @@ describe("agent profile listing MCP tool", () => {
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
// 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.",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.structuredContent).toEqual({ profiles });
|
||||
});
|
||||
|
||||
it("returns an empty array when no profiles are configured", async () => {
|
||||
@@ -5948,10 +5934,14 @@ describe("provider MCP tools", () => {
|
||||
|
||||
it("inspects provider features for a draft agent configuration", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.listDraftControls.mockResolvedValue({
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
|
||||
options: { fast_mode: true },
|
||||
});
|
||||
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
const codexEntry = buildSnapshotEntry({
|
||||
@@ -5984,13 +5974,13 @@ describe("provider MCP tools", () => {
|
||||
|
||||
const response = await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({
|
||||
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
|
||||
provider: "codex",
|
||||
cwd: expect.stringContaining("repo"),
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
expect(response.structuredContent).toEqual({
|
||||
provider: "codex",
|
||||
@@ -6005,7 +5995,7 @@ describe("provider MCP tools", () => {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: true,
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -6221,8 +6211,14 @@ describe("agent snapshot MCP serialization", () => {
|
||||
description: "Default coding mode",
|
||||
},
|
||||
],
|
||||
controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
|
||||
effectiveOptions: { "web-search": true },
|
||||
features: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "web-search",
|
||||
label: "Web search",
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
pendingPermissions: new Map(),
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
|
||||
@@ -1,241 +0,0 @@
|
||||
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. Removal
|
||||
* does not revert a running provider: it keeps its current setting, and the `config.json`
|
||||
* default (or the provider's own default) applies from the next open.
|
||||
*/
|
||||
export type ProviderOptionsPatch = Record<string, unknown>;
|
||||
|
||||
export function applyProviderOptionsPatch(
|
||||
own: ProviderOptions | undefined,
|
||||
patch: ProviderOptionsPatch,
|
||||
): ProviderOptions {
|
||||
const next: ProviderOptions = { ...own };
|
||||
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(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;
|
||||
},
|
||||
>(input: T): Omit<T, "featureValues"> {
|
||||
const { featureValues, ...rest } = input;
|
||||
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 the config's options there. The options the hook returns are the result: a key
|
||||
* it removed from `providerOptions` stays removed. Keys it added, changed, or deleted in
|
||||
* `featureValues` (compared with what it was sent, and an absent field counts as empty) are
|
||||
* applied on top.
|
||||
*/
|
||||
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> },
|
||||
>(returned: T, sent: T): Omit<T, "featureValues"> {
|
||||
const { featureValues, ...rest } = returned;
|
||||
const mirrored = sent.providerOptions !== undefined && sent.featureValues !== undefined;
|
||||
// Without a mirror the hook's `featureValues` are all new options, as for any other client.
|
||||
const options: ProviderOptions = mirrored
|
||||
? { ...rest.providerOptions }
|
||||
: { ...featureValues, ...rest.providerOptions };
|
||||
if (mirrored) {
|
||||
// A hook that dropped the whole field removed every key it was sent.
|
||||
const after = featureValues ?? {};
|
||||
const before = sent.featureValues ?? {};
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (isDeepStrictEqual(after[key], before[key])) continue;
|
||||
if (key in after) options[key] = after[key];
|
||||
else delete options[key];
|
||||
}
|
||||
}
|
||||
return { ...rest, providerOptions: options };
|
||||
}
|
||||
|
||||
interface ProfileOptionFields {
|
||||
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 the profile comes from storage,
|
||||
* `options` wins over a stale `featureValues` key. When it comes from a client write, the
|
||||
* client that sent `featureValues` is the unchanged app, which only knows that field, so its
|
||||
* `featureValues` win over the `options` it received from the previous read.
|
||||
*/
|
||||
export function foldProfileFeatureValues<T extends ProfileOptionFields>(
|
||||
profile: T,
|
||||
source: "stored" | "client write" = "stored",
|
||||
): Omit<T, "featureValues"> {
|
||||
const { featureValues, ...rest } = profile;
|
||||
if (!featureValues) return rest;
|
||||
return {
|
||||
...rest,
|
||||
options:
|
||||
source === "stored"
|
||||
? { ...featureValues, ...rest.options }
|
||||
: { ...rest.options, ...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 || candidate.aliases?.includes(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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a toggle is on: the effective option, else the control's own value (what the running
|
||||
* provider shows after the option was removed).
|
||||
*/
|
||||
export function isToggleOn(
|
||||
key: string,
|
||||
options: ProviderOptions | undefined,
|
||||
controls: readonly AgentOptionControl[] | undefined,
|
||||
): boolean {
|
||||
const option = options?.[key];
|
||||
if (typeof option === "boolean") return option;
|
||||
const control = controls?.find((candidate) => candidate.key === key);
|
||||
return control?.type === "toggle" && control.defaultValue === true;
|
||||
}
|
||||
|
||||
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(
|
||||
controls: readonly AgentOptionControl[] | undefined,
|
||||
options: ProviderOptions | undefined,
|
||||
): AgentFeature[] {
|
||||
const effective = options ?? {};
|
||||
return (controls ?? []).map((control) => featureFromControl(control, effective));
|
||||
}
|
||||
|
||||
/**
|
||||
* A recorded feature's value is the control's unset value: it is what the agent showed before
|
||||
* any option was stored, and an explicit effective option still wins over it.
|
||||
*/
|
||||
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
|
||||
const descriptor = {
|
||||
key: feature.id,
|
||||
label: feature.label,
|
||||
...describe(feature),
|
||||
};
|
||||
if (feature.type === "toggle") {
|
||||
return { type: "toggle", ...descriptor, defaultValue: feature.value };
|
||||
}
|
||||
const defaultChoice = feature.options.find((option) => option.isDefault);
|
||||
const unsetValue = feature.value ?? defaultChoice?.id;
|
||||
return {
|
||||
type: "select",
|
||||
...descriptor,
|
||||
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
|
||||
...(unsetValue != null ? { defaultValue: unsetValue } : {}),
|
||||
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);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import { toAgentPayload, toStoredAgentRecord } from "./agent-projections.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import { AgentManager } from "./agent-manager.js";
|
||||
import { buildProviderRegistry } from "./provider-registry.js";
|
||||
import { ProviderOverrideSchema } from "@getpaseo/protocol/provider-config";
|
||||
@@ -876,89 +876,6 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
|
||||
}
|
||||
}
|
||||
|
||||
const VOICES = [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Soft", value: "soft" },
|
||||
];
|
||||
|
||||
function voiceConfig(voice: string) {
|
||||
return {
|
||||
model: "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: [
|
||||
{ type: "select" as const, id: "voice", label: "Voice", value: voice, options: VOICES },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin whose `voice` setting defaults to "direct". With `acceptsVoice: false` it completes a
|
||||
* configure request but keeps its voice.
|
||||
*/
|
||||
function startVoicePlugin(input: { acceptsVoice: boolean; configurable?: boolean }) {
|
||||
const capabilities = CAPABILITIES.filter(
|
||||
(name) => input.configurable !== false || name !== "session.configure",
|
||||
);
|
||||
const harness = createProviderHarness({
|
||||
capabilities,
|
||||
async handleInput(request, emit) {
|
||||
if (request.type === "session.open") {
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: request.requestId,
|
||||
sessionId: request.sessionId,
|
||||
capabilities,
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { token: "root" } },
|
||||
cwd: request.config.cwd,
|
||||
});
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId: request.sessionId,
|
||||
config: voiceConfig(
|
||||
input.acceptsVoice ? String(request.config.settings?.voice ?? "direct") : "direct",
|
||||
),
|
||||
});
|
||||
emit({ type: "session.ready", requestId: request.requestId, sessionId: request.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (request.type !== "session.configure") return false;
|
||||
const requested = request.changes.settings?.voice;
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId: request.sessionId,
|
||||
config: voiceConfig(
|
||||
input.acceptsVoice && typeof requested === "string" ? requested : "direct",
|
||||
),
|
||||
});
|
||||
emit({ type: "request.completed", requestId: request.requestId });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
const logger = createTestLogger();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({
|
||||
logger,
|
||||
clients: registry.clients(),
|
||||
providerDefinitions: registry.definitions(),
|
||||
});
|
||||
return { harness, manager, registry };
|
||||
}
|
||||
|
||||
function configuredSettings(harness: ReturnType<typeof createProviderHarness>) {
|
||||
return harness.inputs.flatMap((input) =>
|
||||
input.type === "session.configure" ? [input.changes.settings] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function voiceFeature(payload: ReturnType<typeof toAgentPayload>) {
|
||||
return payload.features?.find((feature) => feature.id === "voice");
|
||||
}
|
||||
|
||||
describe("PluginAgentClientRegistry", () => {
|
||||
test("history keeps the tool's first position when completion follows later text", async () => {
|
||||
const harness = createProviderHarness();
|
||||
@@ -1572,160 +1489,6 @@ describe("PluginAgentClientRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a setting the plugin changes on its own replaces an option the user had selected", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const logger = createTestLogger();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([harness.registration]);
|
||||
const manager = new AgentManager({
|
||||
logger,
|
||||
clients: registry.clients(),
|
||||
providerDefinitions: registry.definitions(),
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "direct" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open");
|
||||
if (opened?.type !== "session.open") throw new Error("session was not opened");
|
||||
|
||||
// The plugin reports a new voice after, say, a mode change.
|
||||
harness.emit({
|
||||
type: "session.config",
|
||||
sessionId: opened.sessionId,
|
||||
config: {
|
||||
model: "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: [
|
||||
{
|
||||
type: "select",
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: "soft",
|
||||
options: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Soft", value: "soft" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(() => manager.getAgent(agent.id)?.config.providerOptions)
|
||||
.toEqual({ voice: "soft" });
|
||||
const payload = toAgentPayload(manager.getAgent(agent.id)!);
|
||||
expect(payload.options).toEqual({ voice: "soft" });
|
||||
expect(payload.features?.find((feature) => feature.id === "voice")).toMatchObject({
|
||||
value: "soft",
|
||||
});
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setting passed to a plugin that cannot be configured goes in the open request", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({
|
||||
acceptsVoice: true,
|
||||
configurable: false,
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
const opened = harness.inputs.find((input) => input.type === "session.open");
|
||||
expect(opened?.type === "session.open" && opened.config.settings).toEqual({ voice: "soft" });
|
||||
expect(configuredSettings(harness)).toEqual([]);
|
||||
expect(toAgentPayload(manager.getAgent(agent.id)!).options).toEqual({ voice: "soft" });
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("a setting a plugin without configuration normalizes while opening replaces the requested option", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({
|
||||
acceptsVoice: false,
|
||||
configurable: false,
|
||||
});
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
const payload = toAgentPayload(manager.getAgent(agent.id)!);
|
||||
expect(payload.options).toEqual({ voice: "direct" });
|
||||
expect(voiceFeature(payload)).toMatchObject({ value: "direct" });
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("a selection the plugin settles on while the agent is created replaces the requested option", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false });
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
const payload = toAgentPayload(manager.getAgent(agent.id)!);
|
||||
expect(payload.options).toEqual({ voice: "direct" });
|
||||
expect(voiceFeature(payload)).toMatchObject({ value: "direct" });
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("removing an option leaves the plugin's setting as it is", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: true });
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
await manager.setAgentOptions(agent.id, { voice: null });
|
||||
|
||||
const payload = toAgentPayload(manager.getAgent(agent.id)!);
|
||||
expect(payload.options).toEqual({});
|
||||
expect(configuredSettings(harness)).toEqual([]);
|
||||
expect(voiceFeature(payload)).toMatchObject({ value: "soft" });
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("a selection the plugin keeps on its previous value replaces the requested option", async () => {
|
||||
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false });
|
||||
try {
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "direct" } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
await manager.setAgentOptions(agent.id, { voice: "soft" });
|
||||
|
||||
const payload = toAgentPayload(manager.getAgent(agent.id)!);
|
||||
expect(payload.options).toEqual({ voice: "direct" });
|
||||
expect(voiceFeature(payload)).toMatchObject({ value: "direct" });
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("adapts callback providers into the existing AgentClient and AgentSession path", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
@@ -1748,14 +1511,12 @@ describe("PluginAgentClientRegistry", () => {
|
||||
});
|
||||
|
||||
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
|
||||
expect(session.controls).toEqual([
|
||||
{
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
defaultValue: "direct",
|
||||
choices: [{ value: "direct", label: "Direct" }],
|
||||
},
|
||||
id: "voice",
|
||||
options: [{ id: "direct", label: "Direct", value: "direct" }],
|
||||
}),
|
||||
]);
|
||||
expect(session.describePersistence()).toMatchObject({
|
||||
provider: "plugin-direct",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import { PluginSessionHistory } from "./plugin-session-history.js";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
type ProviderCapability,
|
||||
type ProviderConfigChanges,
|
||||
type ProviderConfigState,
|
||||
type ProviderSetting,
|
||||
type ProviderConnection,
|
||||
type ProviderError,
|
||||
type ProviderEvent,
|
||||
@@ -32,10 +31,10 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentOptionControl,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
@@ -1095,8 +1094,9 @@ class PluginAgentClient implements AgentClient {
|
||||
history: "replay" | "skip";
|
||||
persist: boolean;
|
||||
}): Promise<PluginAgentSession> {
|
||||
const sessionId = randomUUID();
|
||||
const bridge = await this.runtime.openSession({
|
||||
sessionId: randomUUID(),
|
||||
sessionId,
|
||||
config: mapSessionConfig(input.config, input.launchContext, input.persist),
|
||||
persistence: input.persistence,
|
||||
history: input.history,
|
||||
@@ -1133,10 +1133,8 @@ class PluginAgentClient implements AgentClient {
|
||||
});
|
||||
},
|
||||
bind,
|
||||
input.config.providerOptions ?? {},
|
||||
);
|
||||
bind(bridge);
|
||||
session.reportOpeningSettings();
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -1213,8 +1211,6 @@ class PluginAgentSession implements AgentSession {
|
||||
closed: boolean;
|
||||
} | null = null;
|
||||
private replaying: Promise<void> | null = null;
|
||||
/** Option changes the plugin made before anything listened, delivered to the first subscriber. */
|
||||
private optionsChangedBeforeSubscribe: Record<string, JsonValue> | null = null;
|
||||
|
||||
constructor(
|
||||
readonly provider: string,
|
||||
@@ -1225,7 +1221,6 @@ class PluginAgentSession implements AgentSession {
|
||||
state: ProviderConfigState,
|
||||
) => Promise<ProviderRuntimeSession>,
|
||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||
private heldOptions: ProviderOptions,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
@@ -1251,29 +1246,14 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
/** The plugin's `settings` are its option controls; each setting id is an option key. */
|
||||
get controls(): AgentOptionControl[] {
|
||||
return this.bridge.config.settings.map(
|
||||
(setting): AgentOptionControl =>
|
||||
setting.type === "toggle"
|
||||
? {
|
||||
type: "toggle",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
defaultValue: setting.value,
|
||||
}
|
||||
: {
|
||||
type: "select",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
...(setting.value !== null ? { defaultValue: setting.value } : {}),
|
||||
choices: setting.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
},
|
||||
get features(): AgentFeature[] {
|
||||
return this.bridge.config.settings.map((setting) =>
|
||||
setting.type === "toggle"
|
||||
? { ...setting }
|
||||
: {
|
||||
...setting,
|
||||
options: setting.options.map((option) => ({ ...option, id: option.value })),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1334,11 +1314,6 @@ class PluginAgentSession implements AgentSession {
|
||||
|
||||
subscribe(callback: (event: AgentStreamEvent) => void): () => void {
|
||||
this.listeners.add(callback);
|
||||
if (this.optionsChangedBeforeSubscribe) {
|
||||
const patch = this.optionsChangedBeforeSubscribe;
|
||||
this.optionsChangedBeforeSubscribe = null;
|
||||
callback({ type: "options_changed", provider: this.provider, patch });
|
||||
}
|
||||
return () => this.listeners.delete(callback);
|
||||
}
|
||||
|
||||
@@ -1435,24 +1410,11 @@ class PluginAgentSession implements AgentSession {
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
// A setting the options leave out stays as the plugin has it.
|
||||
const settings: Record<string, JsonValue> = {};
|
||||
for (const setting of this.bridge.config.settings) {
|
||||
const value = options[setting.id];
|
||||
if (value === undefined || value === setting.value) continue;
|
||||
settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`);
|
||||
}
|
||||
// Held before the request, because the plugin's config report arrives while it is pending.
|
||||
const previous = this.heldOptions;
|
||||
this.heldOptions = options;
|
||||
try {
|
||||
if (Object.keys(settings).length > 0) await this.bridge.configure({ settings });
|
||||
} catch (error) {
|
||||
this.heldOptions = previous;
|
||||
throw error;
|
||||
}
|
||||
await this.bridge.configure({
|
||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
||||
});
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
@@ -1525,46 +1487,6 @@ class PluginAgentSession implements AgentSession {
|
||||
this.history.push(next);
|
||||
if (live) this.emit(next);
|
||||
}
|
||||
if (!live || event.type !== "session.config") return;
|
||||
// A setting that is not what the agent holds is an option change, so the stored option
|
||||
// follows what the plugin reports.
|
||||
const patch = this.changedSettings(event.config.settings);
|
||||
if (!patch) return;
|
||||
// The plugin can settle on other values while the session opens, before the manager listens.
|
||||
if (this.listeners.size === 0) {
|
||||
this.optionsChangedBeforeSubscribe = { ...this.optionsChangedBeforeSubscribe, ...patch };
|
||||
return;
|
||||
}
|
||||
this.emit({ type: "options_changed", provider: this.provider, patch });
|
||||
}
|
||||
|
||||
/**
|
||||
* The settings whose reported value differs from the option the agent holds. A plugin that
|
||||
* changed a setting on its own, or kept its previous value when asked for another, reports the
|
||||
* value that is in effect.
|
||||
*/
|
||||
/**
|
||||
* Holds back, for the manager, the opening settings the plugin settled on that differ from the
|
||||
* options the agent was given. Reports that arrive after open are handled as they come.
|
||||
*/
|
||||
reportOpeningSettings(): void {
|
||||
const patch = this.changedSettings(this.bridge.config.settings);
|
||||
if (patch) {
|
||||
this.optionsChangedBeforeSubscribe = { ...this.optionsChangedBeforeSubscribe, ...patch };
|
||||
}
|
||||
}
|
||||
|
||||
private changedSettings(settings: readonly ProviderSetting[]): Record<string, JsonValue> | null {
|
||||
const changed: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of settingValues(settings)) {
|
||||
// Only a setting the agent holds an option for is reported; the others show through the
|
||||
// control's default.
|
||||
if (!Object.hasOwn(this.heldOptions, id)) continue;
|
||||
if (this.heldOptions[id] !== value) changed[id] = value;
|
||||
}
|
||||
if (Object.keys(changed).length === 0) return null;
|
||||
this.heldOptions = { ...this.heldOptions, ...changed };
|
||||
return changed;
|
||||
}
|
||||
|
||||
private translate(event: ProviderEvent): AgentStreamEvent[] {
|
||||
@@ -1930,8 +1852,7 @@ function mapSessionConfig(
|
||||
model: config.model,
|
||||
mode: config.modeId,
|
||||
thinkingOption: config.thinkingOptionId,
|
||||
// The plugin SDK reads composer values from `settings`; they are options here.
|
||||
settings: toJsonObject(config.providerOptions ?? {}, "provider settings"),
|
||||
settings: toJsonObject(config.featureValues ?? {}, "provider settings"),
|
||||
providerOptions: config.providerOptions,
|
||||
title: config.title ?? undefined,
|
||||
persist,
|
||||
@@ -2074,10 +1995,6 @@ function toJsonObject(value: unknown, label: string): Record<string, JsonValue>
|
||||
return encoded as Record<string, JsonValue>;
|
||||
}
|
||||
|
||||
function settingValues(settings: readonly ProviderSetting[]): Map<string, JsonValue> {
|
||||
return new Map(settings.map((setting) => [setting.id, setting.value]));
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown, label: string): JsonValue {
|
||||
const encoded = JSON.stringify(value);
|
||||
if (encoded === undefined) throw new Error(`${label} must be JSON-serializable`);
|
||||
|
||||
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"applyOptions",
|
||||
"setFeature",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
|
||||
this.recordedCalls.push("setThinkingOption");
|
||||
}
|
||||
|
||||
async applyOptions() {
|
||||
this.recordedCalls.push("applyOptions");
|
||||
async setFeature() {
|
||||
this.recordedCalls.push("setFeature");
|
||||
}
|
||||
|
||||
async revertConversation() {
|
||||
@@ -193,7 +193,7 @@ describe("wrapSessionProvider", () => {
|
||||
await wrapped.listCommands?.();
|
||||
await wrapped.setModel?.("sonnet");
|
||||
await wrapped.setThinkingOption?.("high");
|
||||
await wrapped.applyOptions?.({ "feature-1": true });
|
||||
await wrapped.setFeature?.("feature-1", true);
|
||||
await wrapped.revertConversation?.({ messageId: "message-1" });
|
||||
await wrapped.revertFiles?.({ messageId: "message-1" });
|
||||
await wrapped.revertBoth?.({ messageId: "message-1" });
|
||||
@@ -208,7 +208,7 @@ describe("wrapSessionProvider", () => {
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"applyOptions",
|
||||
"setFeature",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentModelDefinition,
|
||||
AgentMode,
|
||||
AgentSessionConfig,
|
||||
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
return true;
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
mockState.cursorListFeaturesConfigs.push(config);
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: "fast",
|
||||
id: "fast",
|
||||
label: "Fast",
|
||||
defaultValue: "false",
|
||||
choices: [{ value: "false", label: "Off" }],
|
||||
value: "false",
|
||||
options: [{ id: "false", label: "Off" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -823,7 +823,7 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
});
|
||||
|
||||
test("wrapped cursor client lists ACP controls through the inner provider", async () => {
|
||||
test("wrapped cursor client lists ACP features through the inner provider", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
cursor: {
|
||||
@@ -837,17 +837,17 @@ test("wrapped cursor client lists ACP controls through the inner provider", asyn
|
||||
const client = registry.cursor.createClient(logger);
|
||||
|
||||
await expect(
|
||||
client.listControls?.({
|
||||
client.listFeatures?.({
|
||||
provider: "cursor",
|
||||
cwd: "/tmp/cursor",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "select",
|
||||
key: "fast",
|
||||
id: "fast",
|
||||
label: "Fast",
|
||||
defaultValue: "false",
|
||||
choices: [{ value: "false", label: "Off" }],
|
||||
value: "false",
|
||||
options: [{ id: "false", label: "Off" }],
|
||||
},
|
||||
]);
|
||||
expect(mockState.cursorListFeaturesConfigs).toEqual([
|
||||
|
||||
@@ -447,8 +447,8 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
get initialTimeline() {
|
||||
return inner.initialTimeline;
|
||||
},
|
||||
get controls() {
|
||||
return inner.controls;
|
||||
get features() {
|
||||
return inner.features;
|
||||
},
|
||||
usageSession: inner.usageSession?.bind(inner),
|
||||
run: (prompt, options) => inner.run(prompt, options),
|
||||
@@ -473,7 +473,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
listCommands: inner.listCommands?.bind(inner),
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||
applyOptions: inner.applyOptions?.bind(inner),
|
||||
setFeature: inner.setFeature?.bind(inner),
|
||||
revertConversation: inner.revertConversation?.bind(inner),
|
||||
revertFiles: inner.revertFiles?.bind(inner),
|
||||
revertBoth: inner.revertBoth?.bind(inner),
|
||||
@@ -491,7 +491,7 @@ function wrapClientProvider(
|
||||
): AgentClient {
|
||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||
const importSession = inner.importSession?.bind(inner);
|
||||
const listControls = inner.listControls?.bind(inner);
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
|
||||
@@ -557,10 +557,9 @@ function wrapClientProvider(
|
||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
|
||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||
effectiveOptions: (own) => mergeProviderOptions(providerOptions, own) ?? {},
|
||||
listControls: listControls
|
||||
listFeatures: listFeatures
|
||||
? async (config) =>
|
||||
await listControls({
|
||||
await listFeatures({
|
||||
...config,
|
||||
provider: inner.provider,
|
||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
||||
|
||||
@@ -1307,7 +1307,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
resolverInputs.push(input);
|
||||
return {
|
||||
modeId: input.parent?.isUnattended ? "child-unattended" : undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent,
|
||||
unattended: false,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined });
|
||||
expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined });
|
||||
expect(resolverInputs).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "parent-unattended",
|
||||
@@ -1363,68 +1363,6 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCreateConfig classifies a parent on its effective options", async () => {
|
||||
const resolverInputs: ResolveAgentCreateConfigInput[] = [];
|
||||
const manager = new ProviderSnapshotManager({
|
||||
logger: createTestLogger(),
|
||||
providerOverrides: {
|
||||
copilot: { enabled: false },
|
||||
opencode: { enabled: false },
|
||||
pi: { enabled: false },
|
||||
},
|
||||
extraClients: {
|
||||
codex: createExtraClient("codex", {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async fetchCatalog() {
|
||||
return { models: [] as AgentModelDefinition[], modes: [] };
|
||||
},
|
||||
resolveCreateConfig(input) {
|
||||
resolverInputs.push(input);
|
||||
return { modeId: undefined, providerOptions: undefined };
|
||||
},
|
||||
}),
|
||||
claude: createExtraClient("claude", {
|
||||
async isAvailable() {
|
||||
return true;
|
||||
},
|
||||
async fetchCatalog() {
|
||||
return { models: [] as AgentModelDefinition[], modes: [] };
|
||||
},
|
||||
isCreateConfigUnattended(input) {
|
||||
return input.config.providerOptions?.auto_accept === true;
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
try {
|
||||
// The auto-accept comes from provider configuration, not from the agent's own options.
|
||||
const parent: ManagedAgent = {
|
||||
...CLOSED_PARENT,
|
||||
id: "parent-agent",
|
||||
provider: "claude",
|
||||
currentModeId: "default",
|
||||
config: { provider: "claude", cwd: "/tmp/project" },
|
||||
effectiveOptions: { auto_accept: true },
|
||||
};
|
||||
|
||||
await manager.resolveCreateConfig({
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
parent,
|
||||
unattended: false,
|
||||
});
|
||||
|
||||
expect(resolverInputs[0]).toMatchObject({ parent: { isUnattended: true }, unattended: true });
|
||||
expect(parent.config.providerOptions).toBeUndefined();
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolveCreateConfig passes explicit unattended intent to provider policy", async () => {
|
||||
const resolverInputs: ResolveAgentCreateConfigInput[] = [];
|
||||
const modes: AgentMode[] = [{ id: "worker", label: "Worker", isUnattended: true }];
|
||||
@@ -1448,7 +1386,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
resolverInputs.push(input);
|
||||
return {
|
||||
modeId: input.unattended ? "worker" : undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -1459,17 +1397,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined });
|
||||
expect(resolved).toEqual({ modeId: "worker", featureValues: undefined });
|
||||
expect(resolverInputs).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: modes,
|
||||
@@ -1518,7 +1456,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
config: {
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1526,38 +1464,18 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "opencode",
|
||||
requestedMode: "base",
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent,
|
||||
unattended: false,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||
expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("an OpenCode parent whose auto accept option was removed but is still on is unattended", async () => {
|
||||
const openCode = new OpenCodeAgentClient(createTestLogger());
|
||||
const parent: ManagedAgent = {
|
||||
...CLOSED_PARENT,
|
||||
id: "parent-agent",
|
||||
provider: "opencode",
|
||||
config: { provider: "opencode", cwd: "/tmp/project" },
|
||||
controls: [{ type: "toggle", key: "auto_accept", label: "Auto Accept", defaultValue: true }],
|
||||
};
|
||||
|
||||
expect(
|
||||
openCode.isCreateConfigUnattended({
|
||||
modeId: parent.currentModeId,
|
||||
config: parent.config,
|
||||
controls: parent.controls,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
|
||||
test("adds a derived provider and includes it in subsequent reads", async () => {
|
||||
const manager = new ProviderSnapshotManager({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeAgentModelCatalog, type ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types";
|
||||
import { createHash } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { homedir } from "node:os";
|
||||
@@ -160,14 +160,14 @@ export interface ResolveProviderCreateConfigOptions {
|
||||
cwd?: string | null;
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
parent: ManagedAgent | null;
|
||||
unattended: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedProviderCreateConfig {
|
||||
modeId: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
interface ResolveDefaultModelOptions {
|
||||
@@ -563,7 +563,7 @@ export class ProviderSnapshotManager {
|
||||
return definition.resolveCreateConfig({
|
||||
provider: input.provider,
|
||||
requestedMode: input.requestedMode,
|
||||
providerOptions: input.providerOptions,
|
||||
featureValues: input.featureValues,
|
||||
parent,
|
||||
unattended: input.unattended || parent?.isUnattended === true,
|
||||
availableModes: entry.modes ?? [],
|
||||
@@ -744,13 +744,8 @@ export class ProviderSnapshotManager {
|
||||
modeId: parent.currentModeId,
|
||||
isUnattended: definition.isCreateConfigUnattended({
|
||||
modeId: parent.currentModeId,
|
||||
// Provider defaults from `config.json` apply to the parent too, so classify on the
|
||||
// effective options while the stored overrides stay as they are.
|
||||
config: {
|
||||
...parent.config,
|
||||
providerOptions: parent.effectiveOptions ?? parent.config.providerOptions,
|
||||
},
|
||||
controls: parent.controls,
|
||||
config: parent.config,
|
||||
features: parent.features,
|
||||
availableModes: parent.availableModes ?? definition.modes ?? [],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -198,7 +198,7 @@ function createSessionWithConfig(
|
||||
provider?: string;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
featureValues?: Record<string, unknown>;
|
||||
} = {},
|
||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
@@ -208,7 +208,7 @@ function createSessionWithConfig(
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: config.modeId ?? undefined,
|
||||
model: config.model ?? undefined,
|
||||
providerOptions: config.providerOptions,
|
||||
featureValues: config.featureValues,
|
||||
},
|
||||
{
|
||||
provider: config.provider ?? "claude-acp",
|
||||
@@ -300,14 +300,14 @@ function selectConfigOption(
|
||||
|
||||
function createCopilotSessionWithConfig(
|
||||
modeId?: string | null,
|
||||
providerOptions?: Record<string, unknown>,
|
||||
featureValues?: Record<string, unknown>,
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: modeId ?? undefined,
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
...(featureValues ? { featureValues } : {}),
|
||||
},
|
||||
{
|
||||
provider: "copilot",
|
||||
@@ -1498,7 +1498,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
|
||||
const session = createSessionWithConfig({
|
||||
provider: "cursor-acp",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const events: Array<{ type: string }> = [];
|
||||
|
||||
@@ -1530,7 +1530,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
test("does not auto-accept ACP chooser requests", async () => {
|
||||
const session = createSessionWithConfig({
|
||||
provider: "kimi-acp",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -1583,8 +1583,11 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
internals.sessionId = "session-1";
|
||||
internals.connection = {};
|
||||
|
||||
await session.applyOptions({ auto_accept: true });
|
||||
await session.setFeature("auto_accept", true);
|
||||
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ type: "toggle", id: "auto_accept", value: true }),
|
||||
);
|
||||
await expect(
|
||||
session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
@@ -1602,7 +1605,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
});
|
||||
|
||||
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
|
||||
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } });
|
||||
const session = createSessionWithConfig({ featureValues: { auto_accept: true } });
|
||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
|
||||
@@ -1762,19 +1765,35 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
internals.configOptions = [copilotAgentConfigOption("")];
|
||||
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
{
|
||||
type: "select",
|
||||
key: "agent",
|
||||
id: "agent",
|
||||
label: "Agent",
|
||||
description: "Use a Copilot custom agent profile",
|
||||
tooltip: "Select Copilot agent",
|
||||
icon: undefined,
|
||||
defaultValue: "",
|
||||
choices: [
|
||||
{ value: "", label: "Default", description: undefined },
|
||||
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
|
||||
value: "",
|
||||
options: [
|
||||
{
|
||||
id: "",
|
||||
label: "Default",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "Probe Agent",
|
||||
label: "Probe Agent",
|
||||
description: "Temporary probe agent",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1797,9 +1816,15 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "auto_accept" }),
|
||||
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1813,16 +1838,22 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
connection: { setSessionConfigOption },
|
||||
});
|
||||
|
||||
await session.applyOptions({ agent: "Probe Agent" });
|
||||
await session.setFeature("agent", "Probe Agent");
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "auto_accept" }),
|
||||
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2053,14 +2084,14 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { provider_feature: "kept" },
|
||||
featureValues: { provider_feature: "kept" },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({
|
||||
modeId: undefined,
|
||||
providerOptions: { provider_feature: "kept", auto_accept: true },
|
||||
featureValues: { provider_feature: "kept", auto_accept: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2075,12 +2106,12 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { auto_accept: false },
|
||||
featureValues: { auto_accept: false },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: false } });
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: false } });
|
||||
});
|
||||
|
||||
test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
|
||||
@@ -2094,7 +2125,7 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "bypassPermissions",
|
||||
@@ -2103,7 +2134,7 @@ describe("ACPAgentClient config features", () => {
|
||||
unattended: false,
|
||||
availableModes: [{ id: "agent", label: "Agent" }],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("treats Auto Accept as an unattended ACP configuration", () => {
|
||||
@@ -2119,32 +2150,13 @@ describe("ACPAgentClient config features", () => {
|
||||
config: {
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
availableModes: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("an ACP session whose Auto Accept option was removed but is still on stays unattended", () => {
|
||||
const client = new ACPAgentClient({
|
||||
provider: "generic-acp",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand: ["generic-acp", "acp"],
|
||||
});
|
||||
|
||||
expect(
|
||||
client.isCreateConfigUnattended({
|
||||
modeId: null,
|
||||
config: { provider: "generic-acp", cwd: "/tmp/acp-features", providerOptions: {} },
|
||||
controls: [
|
||||
{ type: "toggle", key: "auto_accept", label: "Auto Accept", defaultValue: true },
|
||||
],
|
||||
availableModes: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("exposes Auto Accept for every ACP provider without starting a probe", async () => {
|
||||
const client = new ACPAgentClient({
|
||||
provider: "generic-acp",
|
||||
@@ -2153,17 +2165,22 @@ describe("ACPAgentClient config features", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
value: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("derives controls from configured ACP select options", async () => {
|
||||
test("derives features from configured ACP select options", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
@@ -2189,19 +2206,23 @@ describe("ACPAgentClient config features", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/acp-features",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
key: "agent",
|
||||
defaultValue: "Probe Agent",
|
||||
choices: [
|
||||
expect.objectContaining({ value: "", label: "Default" }),
|
||||
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
options: [
|
||||
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
|
||||
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
@@ -3976,7 +3997,7 @@ describe("ACPAgentClient probe cleanup", () => {
|
||||
terminateProcess: terminator.terminate,
|
||||
});
|
||||
|
||||
await client.listControls({ provider: "copilot", cwd: "/tmp/acp-features" });
|
||||
await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" });
|
||||
|
||||
expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" });
|
||||
expect(terminator.terminated).toContain(child);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { ACPProviderOptionsSchema } from "./acp-options.js";
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -66,8 +65,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateConfigUnattendedInput,
|
||||
type AgentOptionControl,
|
||||
type AgentOptionToggleControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -112,7 +110,6 @@ import {
|
||||
isDefaultAgentCreateConfigUnattended,
|
||||
resolveDefaultAgentCreateConfig,
|
||||
} from "../create-agent-mode.js";
|
||||
import { isToggleOn } from "../option-controls.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
@@ -851,10 +848,10 @@ export function deriveModelDefinitionsFromACP(
|
||||
}));
|
||||
}
|
||||
|
||||
export function deriveControlsFromACP(
|
||||
export function deriveFeaturesFromACP(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
featureOptions: ACPConfigFeatureOption[],
|
||||
): AgentOptionControl[] {
|
||||
): AgentFeature[] {
|
||||
return featureOptions.flatMap((featureOption) => {
|
||||
const option = findSelectConfigFeatureOption(configOptions, featureOption);
|
||||
if (!option) {
|
||||
@@ -864,44 +861,42 @@ export function deriveControlsFromACP(
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: featureOption.id,
|
||||
id: featureOption.id,
|
||||
label: featureOption.label,
|
||||
description: featureOption.description,
|
||||
tooltip: featureOption.tooltip,
|
||||
icon: featureOption.icon,
|
||||
// The agent reports its own current value; an unset option stands for it.
|
||||
...(option.currentValue != null ? { defaultValue: option.currentValue } : {}),
|
||||
choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({
|
||||
value: choice.id,
|
||||
label: choice.label,
|
||||
description: choice.description,
|
||||
})),
|
||||
value: option.currentValue ?? null,
|
||||
options: deriveConfigFeatureSelectOptions(option, featureOption),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
|
||||
const ACP_AUTO_ACCEPT_CONTROL: AgentOptionToggleControl = {
|
||||
type: "toggle",
|
||||
key: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
};
|
||||
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: isACPAutoAcceptEnabled(config),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveACPCreateConfig(
|
||||
input: ResolveAgentCreateConfigInput,
|
||||
): ResolveAgentCreateConfigResult {
|
||||
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
|
||||
const providerOptions =
|
||||
isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||
? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: input.providerOptions;
|
||||
const featureValues =
|
||||
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: input.featureValues;
|
||||
|
||||
if (
|
||||
input.requestedMode === undefined &&
|
||||
@@ -909,16 +904,22 @@ function resolveACPCreateConfig(
|
||||
input.parent !== null &&
|
||||
input.parent.provider !== input.provider
|
||||
) {
|
||||
return { modeId: undefined, providerOptions };
|
||||
return { modeId: undefined, featureValues };
|
||||
}
|
||||
|
||||
return resolveDefaultAgentCreateConfig({ ...input, providerOptions });
|
||||
return resolveDefaultAgentCreateConfig({ ...input, featureValues });
|
||||
}
|
||||
|
||||
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
|
||||
return (
|
||||
isDefaultAgentCreateConfigUnattended(input) ||
|
||||
isToggleOn(ACP_AUTO_ACCEPT_FEATURE_ID, input.config.providerOptions, input.controls)
|
||||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
|
||||
input.features?.some(
|
||||
(feature) =>
|
||||
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
|
||||
feature.type === "toggle" &&
|
||||
feature.value === true,
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1197,9 +1198,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
|
||||
if (this.configFeatureOptions.length === 0) {
|
||||
return [ACP_AUTO_ACCEPT_CONTROL];
|
||||
return [autoAcceptFeature];
|
||||
}
|
||||
|
||||
this.assertProvider(config);
|
||||
@@ -1215,8 +1217,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
probeSessionId = response.sessionId;
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
return [
|
||||
ACP_AUTO_ACCEPT_CONTROL,
|
||||
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||
autoAcceptFeature,
|
||||
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
} finally {
|
||||
await this.closeProbe(probe, probeSessionId);
|
||||
@@ -1759,12 +1761,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private currentTitle: string | null = null;
|
||||
private lastActivityAt: string | null = null;
|
||||
private configOptions: SessionConfigOption[] = [];
|
||||
/** Whether permission prompts are approved. Removing the option leaves it as it is. */
|
||||
private autoAcceptEnabled: boolean;
|
||||
/** True while startup overrides are written, whose intermediate values are not provider changes. */
|
||||
private applyingStartupOverrides = false;
|
||||
/** Option changes made before anything listened, delivered to the first subscriber. */
|
||||
private optionsChangedBeforeSubscribe: Record<string, unknown> | null = null;
|
||||
private cachedCommands: AgentSlashCommand[] = [];
|
||||
/** When the last command batch arrived; null while nothing has been published. */
|
||||
private commandsUpdatedAt: number | null = null;
|
||||
@@ -1784,7 +1780,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
|
||||
constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) {
|
||||
this.provider = options.provider;
|
||||
this.autoAcceptEnabled = isACPAutoAcceptEnabled(config);
|
||||
this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill;
|
||||
this.capabilities = options.capabilities;
|
||||
this.logger = options.logger.child({ module: "agent", provider: options.provider });
|
||||
@@ -2030,11 +2025,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
sessionId: this.sessionId,
|
||||
});
|
||||
}
|
||||
if (this.optionsChangedBeforeSubscribe) {
|
||||
const patch = this.optionsChangedBeforeSubscribe;
|
||||
this.optionsChangedBeforeSubscribe = null;
|
||||
callback({ type: "options_changed", provider: this.provider, patch });
|
||||
}
|
||||
return () => {
|
||||
this.subscribers.delete(callback);
|
||||
};
|
||||
@@ -2064,10 +2054,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
get features(): AgentFeature[] {
|
||||
return [
|
||||
{ ...ACP_AUTO_ACCEPT_CONTROL, defaultValue: this.autoAcceptEnabled },
|
||||
...deriveControlsFromACP(this.configOptions, this.configFeatureOptions),
|
||||
buildACPAutoAcceptFeature(this.config),
|
||||
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2222,7 +2212,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: modeId,
|
||||
label: "mode",
|
||||
});
|
||||
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
|
||||
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
|
||||
this.pushEvent({
|
||||
type: "mode_changed",
|
||||
@@ -2348,7 +2337,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: modelId,
|
||||
label: "model",
|
||||
});
|
||||
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
|
||||
this.pushEvent({
|
||||
type: "model_changed",
|
||||
provider: this.provider,
|
||||
@@ -2400,7 +2388,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: thinkingOptionId,
|
||||
label: "thought-level",
|
||||
});
|
||||
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
|
||||
this.pushEvent({
|
||||
type: "thinking_option_changed",
|
||||
provider: this.provider,
|
||||
@@ -2408,55 +2395,19 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* When the provider reports a different value for a select the agent holds an explicit option
|
||||
* for, the stored option follows the report. Selections with no stored option are shown through
|
||||
* the control's default instead.
|
||||
*/
|
||||
private reportSelectionChanges(): AgentStreamEvent[] {
|
||||
if (this.applyingStartupOverrides) return [];
|
||||
const patch: Record<string, string> = {};
|
||||
const held = this.config.providerOptions;
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
const stored = held?.[featureOption.id];
|
||||
if (typeof stored !== "string") continue;
|
||||
const current = findSelectConfigFeatureOption(
|
||||
this.configOptions,
|
||||
featureOption,
|
||||
)?.currentValue;
|
||||
if (current != null && current !== stored) patch[featureOption.id] = current;
|
||||
}
|
||||
if (Object.keys(patch).length === 0) return [];
|
||||
this.config.providerOptions = { ...this.config.providerOptions, ...patch };
|
||||
return [{ type: "options_changed", provider: this.provider, patch }];
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
// What the provider kept for each selection written here, which can differ from the request.
|
||||
// A key the options leave out is not written: the running provider keeps its selection.
|
||||
const acknowledged: Record<string, unknown> = {};
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
if (!Object.prototype.hasOwnProperty.call(options, featureOption.id)) continue;
|
||||
const value = options[featureOption.id];
|
||||
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
|
||||
if (option && value === option.currentValue) continue;
|
||||
await this.setConfigFeature(featureOption.id, value);
|
||||
acknowledged[featureOption.id] = this.config.providerOptions?.[featureOption.id];
|
||||
}
|
||||
// Last, so a rejected selection leaves permission handling as it was.
|
||||
if (Object.prototype.hasOwnProperty.call(options, ACP_AUTO_ACCEPT_FEATURE_ID)) {
|
||||
this.autoAcceptEnabled = options[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
// The session holds the options it was given, so a key the agent no longer has stays absent
|
||||
// and a later provider report is not mistaken for a change to a stored override.
|
||||
this.config.providerOptions = { ...options, ...acknowledged };
|
||||
}
|
||||
|
||||
private async setConfigFeature(featureId: string, value: unknown): Promise<void> {
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
const featureOption = this.configFeatureOptions.find((option) => option.id === featureId);
|
||||
if (!featureOption) {
|
||||
throw new Error(`Unknown ${this.provider} feature: ${featureId}`);
|
||||
@@ -2490,15 +2441,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue,
|
||||
label: featureOption.label,
|
||||
});
|
||||
this.config.providerOptions = { ...this.config.providerOptions, [featureId]: currentValue };
|
||||
if (currentValue !== requestedValue) {
|
||||
// The provider kept a different selection than the one asked for.
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { [featureId]: currentValue },
|
||||
});
|
||||
}
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
|
||||
}
|
||||
|
||||
private applyConfigOptionResponse({
|
||||
@@ -2669,7 +2612,8 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
const canAutoAccept = this.autoAcceptEnabled && !isACPChooserRequest(params.options);
|
||||
const canAutoAccept =
|
||||
isACPAutoAcceptEnabled(this.config) && !isACPChooserRequest(params.options);
|
||||
if (canAutoAccept) {
|
||||
const allowOption = selectPermissionOption(params.options, { behavior: "allow" });
|
||||
if (allowOption) {
|
||||
@@ -3043,18 +2987,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
private async applyConfiguredOverrides(): Promise<void> {
|
||||
// Mode, model and thinking writes move other selections through values that are not final
|
||||
// yet; the requested options are reconciled once everything has been written.
|
||||
this.applyingStartupOverrides = true;
|
||||
try {
|
||||
await this.writeConfiguredOverrides();
|
||||
} finally {
|
||||
this.applyingStartupOverrides = false;
|
||||
}
|
||||
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
|
||||
}
|
||||
|
||||
private async writeConfiguredOverrides(): Promise<void> {
|
||||
const configuredModeId = this.config.modeId;
|
||||
if (configuredModeId && configuredModeId !== this.currentMode) {
|
||||
const selection = resolveACPModeSelection({
|
||||
@@ -3086,13 +3018,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||
}
|
||||
const configuredFeatureValues = this.config.providerOptions ?? {};
|
||||
const configuredFeatureValues = this.config.featureValues ?? {};
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.setConfigFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
} catch (error) {
|
||||
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
|
||||
throw error;
|
||||
@@ -3350,7 +3282,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
thinkingOptionId: this.thinkingOptionId,
|
||||
});
|
||||
}
|
||||
events.push(...this.reportSelectionChanges());
|
||||
return events;
|
||||
}
|
||||
|
||||
@@ -3436,15 +3367,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
},
|
||||
"provider.acp.event_emit",
|
||||
);
|
||||
// The provider can settle on other options while the session starts, before the manager
|
||||
// listens; the first subscriber receives them.
|
||||
if (event.type === "options_changed" && this.subscribers.size === 0) {
|
||||
this.optionsChangedBeforeSubscribe = {
|
||||
...this.optionsChangedBeforeSubscribe,
|
||||
...event.patch,
|
||||
};
|
||||
return;
|
||||
}
|
||||
for (const subscriber of this.subscribers) {
|
||||
subscriber(event);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { constants as bufferConstants } from "node:buffer";
|
||||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,9 +18,6 @@ import {
|
||||
resolveClaudeCodeVersion,
|
||||
toClaudeSdkMcpConfig,
|
||||
} from "./agent.js";
|
||||
import { AgentManager } from "../../agent-manager.js";
|
||||
import { ensureAgentLoaded } from "../../agent-loading.js";
|
||||
import { AgentStorage } from "../../agent-storage.js";
|
||||
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
@@ -867,34 +864,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("applying options before the first query keeps the session's restrictions", 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 },
|
||||
},
|
||||
});
|
||||
|
||||
// The manager hands the session only the agent's own options.
|
||||
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("passes extra Claude Code CLI arguments to the SDK", async () => {
|
||||
const { queryFactory, launches } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
@@ -984,39 +953,39 @@ describe("ClaudeAgentSession features", () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8[1m]",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8-20260101",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-5",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "openrouter/anthropic/claude-opus-4-8",
|
||||
@@ -1024,7 +993,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-5",
|
||||
@@ -1032,7 +1001,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-4-6",
|
||||
@@ -1051,7 +1020,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1523,7 +1492,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
ensureQuery(): Promise<unknown>;
|
||||
}
|
||||
).ensureQuery();
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
||||
@@ -1532,146 +1501,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
|
||||
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("an unrelated options patch is accepted after switching away from a Fast model", 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.setModel("claude-opus-4-8");
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
await session.setModel("claude-sonnet-5");
|
||||
queryMock.applyFlagSettings.mockClear();
|
||||
|
||||
// The manager sends the full record, which still holds the dormant Fast preference.
|
||||
await expect(
|
||||
session.applyOptions?.({ fast_mode: true, allowedTools: ["Read"] }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
|
||||
|
||||
// Removing the option keeps the preference as it is; nothing is sent to the runtime.
|
||||
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
|
||||
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("removing the fast_mode option leaves Fast on and the control shows it", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
});
|
||||
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
queryMock.applyFlagSettings.mockClear();
|
||||
|
||||
await session.applyOptions?.({});
|
||||
|
||||
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "fast_mode", defaultValue: true }),
|
||||
]);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("fast mode survives a daemon restart through the stored providerOptions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-"));
|
||||
const storagePath = path.join(root, "agents");
|
||||
const makeClient = () => {
|
||||
const mock = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: mock.queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
return { ...mock, client };
|
||||
};
|
||||
const agentId = "00000000-0000-4000-8000-0000000000f1";
|
||||
const before = makeClient();
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { claude: before.client },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await manager.createAgent(
|
||||
{ provider: "claude", cwd: root, model: "claude-opus-4-8" },
|
||||
agentId,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||
await manager.closeAgent(agentId);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
const after = makeClient();
|
||||
const restartedStorage = new AgentStorage(storagePath, logger);
|
||||
const restarted = new AgentManager({
|
||||
clients: { claude: after.client },
|
||||
registry: restartedStorage,
|
||||
logger,
|
||||
});
|
||||
const agent = await ensureAgentLoaded(agentId, {
|
||||
agentManager: restarted,
|
||||
agentStorage: restartedStorage,
|
||||
logger,
|
||||
});
|
||||
const session = (agent as unknown as { session: { ensureQuery(): Promise<unknown> } | null })
|
||||
.session;
|
||||
await session?.ensureQuery();
|
||||
|
||||
expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
|
||||
fastMode: true,
|
||||
});
|
||||
await restarted.closeAgent(agentId);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -66,7 +65,7 @@ import { foldSubagentObservations, type SubagentObservation } from "./subagents/
|
||||
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
|
||||
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
|
||||
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
|
||||
import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
@@ -105,7 +104,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -1753,8 +1752,12 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
return buildClaudeControls(this.assertConfig(config).model);
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
return buildClaudeFeatures({
|
||||
modelId: claudeConfig.model,
|
||||
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
|
||||
});
|
||||
}
|
||||
|
||||
async listImportableSessions(
|
||||
@@ -2443,8 +2446,11 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return this.claudeSessionId;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return buildClaudeControls(this.config.model, this.config.providerOptions.fast_mode === true);
|
||||
get features(): AgentFeature[] {
|
||||
return buildClaudeFeatures({
|
||||
modelId: this.config.model,
|
||||
fastModeEnabled: this.config.featureValues?.fast_mode === true,
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -2742,10 +2748,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await activeQuery.setModel(normalizedModelId ?? undefined);
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.reconcileThinkingOptionForModel(normalizedModelId);
|
||||
if (this.config.providerOptions.fast_mode) {
|
||||
// The option is the user's intent; the runtime follows it only on models that support it.
|
||||
await activeQuery.applyFlagSettings({ fastMode: this.resolveFastModeSetting() === true });
|
||||
this.cachedRuntimeInfo = null;
|
||||
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
|
||||
await this.applyFastModeFeature(false, activeQuery);
|
||||
}
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
@@ -2797,28 +2801,31 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const providerOptions =
|
||||
validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {};
|
||||
// Supplied keys update the running options; absent keys keep their running value.
|
||||
const running = { ...this.config.providerOptions, ...providerOptions };
|
||||
const hadFast = this.config.providerOptions.fast_mode === true;
|
||||
const wantsFast = running.fast_mode === true;
|
||||
const modelSupportsFast = claudeModelSupportsFastMode(this.config.model);
|
||||
if (wantsFast && !hadFast && !modelSupportsFast) {
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId !== "fast_mode") {
|
||||
throw new Error(`Unknown Claude feature: ${featureId}`);
|
||||
}
|
||||
|
||||
const enabled = Boolean(value);
|
||||
if (enabled && !claudeModelSupportsFastMode(this.config.model)) {
|
||||
throw new Error(
|
||||
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
// A saved Fast preference stays dormant on a model without Fast (see `setModel`), so the
|
||||
// runtime setting is the preference filtered by model support.
|
||||
const runtimeFast = wantsFast && modelSupportsFast;
|
||||
if (runtimeFast !== (hadFast && modelSupportsFast)) {
|
||||
await this.query?.applyFlagSettings({ fastMode: runtimeFast });
|
||||
this.cachedRuntimeInfo = null;
|
||||
|
||||
await this.applyFastModeFeature(enabled);
|
||||
}
|
||||
|
||||
private async applyFastModeFeature(enabled: boolean, query?: Query): Promise<void> {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
fast_mode: enabled,
|
||||
};
|
||||
const activeQuery = query ?? this.query;
|
||||
if (activeQuery) {
|
||||
await activeQuery.applyFlagSettings({ fastMode: enabled });
|
||||
}
|
||||
// Held until the runtime accepts, so a rejected change is retried instead of skipped.
|
||||
this.config.providerOptions = running;
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
@@ -3647,9 +3654,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
|
||||
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||
// `fast_mode` is a composer control the provider applies through flag settings.
|
||||
const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
|
||||
const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
|
||||
const providerOptions = applyClaudeToolPolicy(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.harnessEnvironment;
|
||||
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
||||
@@ -3768,7 +3776,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!claudeModelSupportsFastMode(this.config.model)) {
|
||||
return null;
|
||||
}
|
||||
return this.config.providerOptions.fast_mode === true;
|
||||
return this.config.featureValues?.fast_mode === true;
|
||||
}
|
||||
|
||||
private normalizeMcpServers(
|
||||
@@ -6845,40 +6853,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
||||
}
|
||||
|
||||
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 current name is the last usable one in the file. Searching backwards finds it without
|
||||
* walking the transcript, which matters because sessions reach tens of
|
||||
* megabytes.
|
||||
* the current name is the last usable one in the file. Reading backwards a chunk
|
||||
* at a time finds it without walking the transcript, which matters because
|
||||
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
||||
* transcript past V8's maximum string length still imports.
|
||||
*/
|
||||
function readLastClaudeCustomTitle(content: string): string | null {
|
||||
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 {
|
||||
parsed = JSON.parse(line.trim());
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const entry = toObjectRecord(parsed);
|
||||
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||
const title = normalizeClaudeSessionTitle(
|
||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
||||
const handle = await fsPromises.open(filePath, "r");
|
||||
try {
|
||||
const stats = await handle.stat();
|
||||
if (!stats.isFile()) {
|
||||
// Opening a directory succeeds on Windows even though reading it does not,
|
||||
// so reject the way reading the transcript as a file would.
|
||||
throw Object.assign(
|
||||
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
||||
{ code: "EISDIR", path: filePath },
|
||||
);
|
||||
if (title) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||
// usable name, so keep looking backwards for one that does.
|
||||
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||
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));
|
||||
end -= length;
|
||||
}
|
||||
// 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 {
|
||||
@@ -6890,9 +6937,8 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
sessionId: string;
|
||||
configDir: string;
|
||||
}): Promise<string | null> {
|
||||
let content: string;
|
||||
try {
|
||||
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
||||
} catch (error) {
|
||||
// A session with no transcript on disk has no rename to carry over. Every
|
||||
// other read failure is a real fault and stays explicit.
|
||||
@@ -6901,7 +6947,6 @@ async function readClaudeRenamedSessionTitle(input: {
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return readLastClaudeCustomTitle(content);
|
||||
}
|
||||
|
||||
function normalizeImportablePromptPreview(text: string): string | null {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AgentOptionControl, AgentOptionToggleControl } from "../../agent-sdk-types.js";
|
||||
import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js";
|
||||
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
|
||||
|
||||
export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
|
||||
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
type: "toggle",
|
||||
key: "fast_mode",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
description: "Lower latency Opus responses at higher token cost",
|
||||
tooltip: "Toggle fast mode",
|
||||
@@ -14,11 +14,18 @@ export function claudeModelSupportsFastMode(modelId: string | null | undefined):
|
||||
return claudeManifestModelSupportsFastMode(modelId);
|
||||
}
|
||||
|
||||
export function buildClaudeControls(
|
||||
modelId: string | null | undefined,
|
||||
fastEnabled = false,
|
||||
): AgentOptionControl[] {
|
||||
return claudeModelSupportsFastMode(modelId)
|
||||
? [{ ...CLAUDE_FAST_MODE_CONTROL, defaultValue: fastEnabled }]
|
||||
: [];
|
||||
export function buildClaudeFeatures(input: {
|
||||
modelId: string | null | undefined;
|
||||
fastModeEnabled: boolean;
|
||||
}): AgentFeature[] {
|
||||
if (!claudeModelSupportsFastMode(input.modelId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...CLAUDE_FAST_MODE_FEATURE,
|
||||
value: input.fastModeEnabled,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ const SandboxFilesystemSchema = z
|
||||
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
|
||||
export const ClaudeProviderOptionsSchema = z
|
||||
.object({
|
||||
/** Composer control, not a Claude SDK option: the provider reads it and keeps it out of the SDK options. */
|
||||
fast_mode: z.boolean().optional(),
|
||||
allowedTools: z.array(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
additionalDirectories: z.array(z.string()).optional(),
|
||||
|
||||
+150
-370
@@ -1,14 +1,7 @@
|
||||
import { featuresFromControls } from "../option-controls.js";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { toAgentPayload } from "../agent-projections.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
@@ -44,33 +37,23 @@ const TEST_SPEED_MODELS = [
|
||||
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
|
||||
}));
|
||||
|
||||
function speedControl(defaultValue = "default") {
|
||||
function speedFeature(value: string) {
|
||||
return {
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
description: "Choose processing speed. Faster tiers increase usage.",
|
||||
tooltip: "Select speed",
|
||||
icon: "zap",
|
||||
desktopTrigger: "icon",
|
||||
defaultValue,
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
value,
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const planControl = (defaultValue = false) => ({
|
||||
type: "toggle",
|
||||
defaultValue,
|
||||
key: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
});
|
||||
|
||||
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
{
|
||||
name: "Code",
|
||||
@@ -149,7 +132,7 @@ async function createConnectedSession(
|
||||
return harness;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider controls", () => {
|
||||
describe("Codex app-server provider features", () => {
|
||||
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "gpt-6.1-sol" },
|
||||
@@ -166,19 +149,19 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.controls).toContainEqual(
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
id: "service_tier",
|
||||
type: "select",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
{ value: "ultrafast", label: "Ultrafast" },
|
||||
value: "default",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
{ id: "ultrafast", label: "Ultrafast" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.applyOptions?.({ service_tier: "ultrafast" });
|
||||
await session.setFeature?.("service_tier", "ultrafast");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "ultrafast",
|
||||
@@ -203,16 +186,16 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.controls).toContainEqual(
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "future-tier", label: "Future speed" },
|
||||
id: "service_tier",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "future-tier", label: "Future speed" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.applyOptions?.({ service_tier: "future-tier" });
|
||||
await session.setFeature?.("service_tier", "future-tier");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model: "future-codex-model",
|
||||
@@ -227,7 +210,7 @@ describe("Codex app-server provider controls", () => {
|
||||
"restores %s and Normal explicitly overrides an existing thread tier",
|
||||
async (tier) => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||
{ model: "future-model", featureValues: { service_tier: tier } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
@@ -241,8 +224,11 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: tier }),
|
||||
);
|
||||
await session.getRuntimeInfo();
|
||||
await session.applyOptions?.({ service_tier: "default" });
|
||||
await session.setFeature?.("service_tier", "default");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
@@ -253,40 +239,17 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["priority", "ultrafast"])("restores %s from providerOptions", async (tier) => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "future-model",
|
||||
serviceTiers: [
|
||||
{ id: "priority", name: "Fast", description: "Fast processing" },
|
||||
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
try {
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: tier });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unadvertised tiers and non-string values", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
const { session } = await createConnectedSession();
|
||||
try {
|
||||
for (const value of ["ultrafast", "bogus"]) {
|
||||
await expect(session.applyOptions?.({ service_tier: value })).rejects.toThrow(
|
||||
for (const value of ["ultrafast", "bogus", true, null]) {
|
||||
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
|
||||
"is not available",
|
||||
);
|
||||
}
|
||||
await expect(session.applyOptions?.({ service_tier: true })).rejects.toThrow();
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -294,7 +257,7 @@ describe("Codex app-server provider controls", () => {
|
||||
|
||||
test("switching to a model without the selected tier resets speed", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "ultra-model", providerOptions: { service_tier: "ultrafast" } },
|
||||
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
@@ -307,14 +270,8 @@ describe("Codex app-server provider controls", () => {
|
||||
);
|
||||
try {
|
||||
await session.setModel("gpt-6.1-sol");
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
],
|
||||
}),
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
@@ -326,7 +283,7 @@ describe("Codex app-server provider controls", () => {
|
||||
test("maps legacy Fast commands to the catalog's request id", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
try {
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
@@ -350,13 +307,13 @@ describe("Codex app-server provider controls", () => {
|
||||
])("exposes and sends Fast for %s", async (model) => {
|
||||
const { session, appServer } = await createConnectedSession({ model });
|
||||
try {
|
||||
expect(session.controls).toContainEqual(
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
defaultValue: "default",
|
||||
id: "service_tier",
|
||||
value: "default",
|
||||
}),
|
||||
);
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model,
|
||||
@@ -381,13 +338,12 @@ describe("Codex app-server provider controls", () => {
|
||||
])("does not expose or restore Fast for %s", async (model) => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model,
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
const fresh = await createConnectedSession({ model });
|
||||
try {
|
||||
expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]);
|
||||
await expect(fresh.session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
|
||||
`Codex speed 'fast' is not available for model '${model}'`,
|
||||
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]);
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
||||
`Codex fast mode is not available for model '${model}'`,
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -395,16 +351,21 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
await fresh.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("restores Fast on Astra and preserves it when switching supported models", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model: "gpt-6-astra",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "service_tier",
|
||||
value: "priority",
|
||||
}),
|
||||
);
|
||||
await session.setModel("gpt-5.6-sol");
|
||||
await session.setModel("gpt-6-astra");
|
||||
await session.startTurn("hello");
|
||||
@@ -417,47 +378,72 @@ describe("Codex app-server provider controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("controls return the speed selector and plan toggle when supported", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
test("features returns speed selector and plan toggle when supported", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
expect(session.controls).toEqual([speedControl(), planControl()]);
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("default"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
|
||||
await session.startTurn("hello");
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
collaborationMode: expect.objectContaining({ mode: "plan" }),
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("priority"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("controls return only the plan toggle when model does not support fast mode", async () => {
|
||||
test("features returns only plan toggle when model does not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
expect(session.controls).toEqual([planControl()]);
|
||||
});
|
||||
|
||||
test("removing the speed and plan options leaves the session as it is", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
|
||||
|
||||
await session.applyOptions?.({});
|
||||
|
||||
expect(session.controls).toEqual([speedControl("priority"), planControl(true)]);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
collaborationMode: expect.objectContaining({ mode: "plan" }),
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("constructor ignores restored fast mode when model does not support it", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model: "gpt-3.5-turbo",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
expect(session.controls).toEqual([planControl()]);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -468,7 +454,7 @@ describe("Codex app-server provider controls", () => {
|
||||
test("selecting Fast sends the catalog service tier", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -478,10 +464,10 @@ describe("Codex app-server provider controls", () => {
|
||||
|
||||
test("legacy Fast off explicitly selects Normal", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ fast_mode: false });
|
||||
await session.setFeature?.("fast_mode", false);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -489,34 +475,53 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("applyOptions({ fast_mode: true }) rejects models that do not support fast mode", async () => {
|
||||
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
|
||||
"Codex speed 'fast' is not available for model 'gpt-3.5-turbo'",
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
||||
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyOptions invalidates runtime info", async () => {
|
||||
test("setFeature invalidates runtime info", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
});
|
||||
|
||||
test("constructor restores composer options from config.providerOptions", async () => {
|
||||
test("setFeature throws for unknown feature ids", async () => {
|
||||
const { session } = createSessionHarness();
|
||||
|
||||
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
||||
"Unknown Codex feature: unknown_feature",
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor restores feature flags from config.featureValues", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { fast_mode: true, plan_mode: true },
|
||||
featureValues: { fast_mode: true, plan_mode: true },
|
||||
});
|
||||
|
||||
expect(session.controls).toEqual([speedControl("priority"), planControl(true)]);
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("priority"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -527,54 +532,10 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("applyOptions accepts an option that has no control", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
test("applying unrelated options keeps the running sandbox", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { sandbox_mode: "read-only" },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
sandboxPolicy: { type: "readOnly" },
|
||||
});
|
||||
});
|
||||
|
||||
test("removing the sandbox option keeps the running sandbox", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { sandbox_mode: "read-only" },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({});
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
sandboxPolicy: { type: "readOnly" },
|
||||
});
|
||||
});
|
||||
|
||||
test("an explicit tier still beats a legacy fast_mode on a patch that supplies neither", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { service_tier: "default", fast_mode: true },
|
||||
});
|
||||
|
||||
// The manager hands the session only the agent's own options.
|
||||
await session.applyOptions?.({ fast_mode: true, plan_mode: true });
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
});
|
||||
|
||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -582,202 +543,11 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a saved Fast preference selects the model's Fast tier without rewriting the stored option", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { service_tier: "fast" },
|
||||
});
|
||||
|
||||
// The model's Fast tier is "priority". The choice names "fast" as an alias, so a client reading
|
||||
// `options` + `controls` selects it, and the legacy features projection agrees.
|
||||
const speed = session.controls.find((control) => control.key === "service_tier");
|
||||
expect(speed).toMatchObject({
|
||||
choices: expect.arrayContaining([
|
||||
expect.objectContaining({ value: "priority", aliases: ["fast"] }),
|
||||
]),
|
||||
});
|
||||
expect(
|
||||
featuresFromControls(session.controls, { service_tier: "fast" }).find(
|
||||
(feature) => feature.id === "service_tier",
|
||||
),
|
||||
).toMatchObject({ value: "priority" });
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
|
||||
});
|
||||
|
||||
describe("a pre-v0.10 fast_mode: true option", () => {
|
||||
function createLegacyFastHarness(configDefaults: Record<string, unknown> = {}) {
|
||||
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: CODEX_PROVIDER,
|
||||
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: { codex: client }, 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: CODEX_PROVIDER,
|
||||
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: CODEX_PROVIDER,
|
||||
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 };
|
||||
}
|
||||
|
||||
test("is stored as set, shows Fast, and sends the Fast tier", async () => {
|
||||
const { create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await create({ fast_mode: true });
|
||||
|
||||
expect(shownSpeed(agentId)).toEqual({
|
||||
options: { fast_mode: true },
|
||||
own: { fast_mode: true },
|
||||
selected: "priority",
|
||||
feature: "priority",
|
||||
});
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("shows Fast after a stored record is resumed", async () => {
|
||||
const { resume, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await resume({ fast_mode: true });
|
||||
|
||||
expect(shownSpeed(agentId)).toEqual({
|
||||
options: { fast_mode: true },
|
||||
own: { fast_mode: true },
|
||||
selected: "priority",
|
||||
feature: "priority",
|
||||
});
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("from config.json defaults shows Fast and leaves the agent's own options empty", async () => {
|
||||
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
|
||||
const agentId = await create();
|
||||
|
||||
expect(shownSpeed(agentId)).toEqual({
|
||||
options: { fast_mode: true },
|
||||
own: undefined,
|
||||
selected: "priority",
|
||||
feature: "priority",
|
||||
});
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("set live shows Fast at once, and an explicit service_tier then wins", async () => {
|
||||
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await create();
|
||||
expect(shownSpeed(agentId).selected).toBe("default");
|
||||
|
||||
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||
expect(shownSpeed(agentId)).toMatchObject({ selected: "priority", feature: "priority" });
|
||||
await runTurn(agentId, "priority");
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: "default" });
|
||||
expect(shownSpeed(agentId)).toMatchObject({
|
||||
own: { fast_mode: true, 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("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
|
||||
const capture = createCapturedLogger();
|
||||
const prompt = "secret prompt text should not be logged";
|
||||
const { session } = await createConnectedSession(
|
||||
{ providerOptions: { fast_mode: true } },
|
||||
{ featureValues: { fast_mode: true } },
|
||||
{ logger: capture.logger },
|
||||
);
|
||||
|
||||
@@ -800,10 +570,20 @@ describe("Codex app-server provider controls", () => {
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.setModel("gpt-3.5-turbo");
|
||||
|
||||
expect(session.controls).toEqual([planControl()]);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -814,7 +594,7 @@ describe("Codex app-server provider controls", () => {
|
||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ async function withPendingPlan(
|
||||
cwd: workdir,
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
providerOptions: { plan_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -526,7 +526,7 @@ test("idle provider exit preserves a synthetic plan approval across reconnect",
|
||||
cwd: workdir,
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
providerOptions: { plan_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
|
||||
@@ -6188,7 +6188,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true, fast_mode: true },
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -6258,7 +6258,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("does not complete Codex plan timeline cards while plan approval is pending", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true, fast_mode: true },
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -6304,7 +6304,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("replaces a pending synthetic plan approval when a later plan completes", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -6337,7 +6337,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -6379,7 +6379,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -6433,7 +6433,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -6483,7 +6483,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true },
|
||||
featureValues: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -7107,7 +7107,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true, fast_mode: true },
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -7138,10 +7138,9 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
expect(events).toContainEqual({
|
||||
type: "options_changed",
|
||||
provider: "codex",
|
||||
patch: { plan_mode: false },
|
||||
expect(asInternals(session).config.featureValues).toEqual({
|
||||
plan_mode: false,
|
||||
fast_mode: true,
|
||||
});
|
||||
// The session returns the follow-up prompt instead of calling startTurn directly.
|
||||
// The caller (session/agent-manager) is responsible for sending it through streamAgent.
|
||||
@@ -7162,7 +7161,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true, fast_mode: false },
|
||||
featureValues: { plan_mode: true, fast_mode: false },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -7193,10 +7192,9 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
expect(events).toContainEqual({
|
||||
type: "options_changed",
|
||||
provider: "codex",
|
||||
patch: { plan_mode: false },
|
||||
expect(asInternals(session).config.featureValues).toEqual({
|
||||
plan_mode: false,
|
||||
fast_mode: false,
|
||||
});
|
||||
expect(result?.followUpPrompt).toEqual(
|
||||
expect.stringContaining("The user approved the plan. Implement it now."),
|
||||
@@ -7205,7 +7203,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => {
|
||||
const session = createSession({
|
||||
providerOptions: { plan_mode: true, fast_mode: true },
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
asInternals(session).collaborationModes = [
|
||||
{
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
@@ -6,7 +5,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentMode,
|
||||
@@ -76,7 +75,7 @@ import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import {
|
||||
buildCodexControls,
|
||||
buildCodexFeatures,
|
||||
CodexServiceTierSchema,
|
||||
readCodexServiceTier,
|
||||
type CodexServiceTier,
|
||||
@@ -3430,7 +3429,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private readonly codexHome: string;
|
||||
private currentMode: string;
|
||||
private hasWorkflowModeOverride: boolean;
|
||||
private providerOptions: CodexProviderOptions;
|
||||
private readonly providerOptions: CodexProviderOptions;
|
||||
private resolvedWorkspaceWrite: NonNullable<
|
||||
CodexProviderOptions["sandbox_workspace_write"]
|
||||
> | null = null;
|
||||
@@ -3458,8 +3457,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
/** The tier the options ask for; `serviceTier` is what the current model can honor. */
|
||||
private requestedServiceTier: string | null = null;
|
||||
private serviceTier: string | null = null;
|
||||
private speedModels: CodexModel[] = [];
|
||||
private planModeEnabled = false;
|
||||
// History read by rewind or an archived resume, held until streamHistory consumes it.
|
||||
@@ -3570,8 +3568,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
this.requestedServiceTier = readCodexServiceTier(this.providerOptions);
|
||||
this.planModeEnabled = this.providerOptions.plan_mode === true;
|
||||
this.serviceTier = readCodexServiceTier(this.config.featureValues);
|
||||
if (this.config.featureValues?.plan_mode) {
|
||||
this.planModeEnabled = true;
|
||||
}
|
||||
|
||||
if (this.resumeHandle?.sessionId) {
|
||||
this.currentThreadId = this.resumeHandle.sessionId;
|
||||
@@ -3586,10 +3586,10 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return this.currentThreadId;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return buildCodexControls({
|
||||
get features(): AgentFeature[] {
|
||||
return buildCodexFeatures({
|
||||
serviceTiers: this.currentServiceTiers(),
|
||||
runningServiceTier: this.requestedServiceTier,
|
||||
serviceTier: this.serviceTier ?? "default",
|
||||
planModeEnabled: this.planModeEnabled,
|
||||
planModeAvailable: this.hasPlanCollaborationMode(),
|
||||
});
|
||||
@@ -3650,6 +3650,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
this.speedModels =
|
||||
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
|
||||
this.reconcileServiceTier();
|
||||
await this.loadResolvedWorkspaceWrite();
|
||||
await this.loadCollaborationModes();
|
||||
await this.loadSkills();
|
||||
@@ -3862,19 +3863,24 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return selectedModel?.serviceTiers ?? [];
|
||||
}
|
||||
|
||||
private get serviceTier(): string | null {
|
||||
return this.resolveServiceTier(this.requestedServiceTier);
|
||||
}
|
||||
|
||||
private resolveServiceTier(requested: string | null): string | null {
|
||||
// Until the model list arrives there is nothing to check the request against.
|
||||
if (this.speedModels.length === 0 || requested === "default") return requested;
|
||||
private reconcileServiceTier(): void {
|
||||
const tiers = this.currentServiceTiers();
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
if (requested === "fast" && !tiers.some((tier) => tier.id === "fast")) {
|
||||
return tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
|
||||
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
|
||||
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
|
||||
}
|
||||
return tiers.some((tier) => tier.id === requested) ? requested : null;
|
||||
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
|
||||
this.serviceTier = null;
|
||||
}
|
||||
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
|
||||
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
|
||||
}
|
||||
|
||||
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
|
||||
this.planModeEnabled = value;
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
|
||||
private rememberPlanResult(item: ToolCallTimelineItem): void {
|
||||
@@ -3939,13 +3945,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const planText =
|
||||
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
|
||||
|
||||
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 },
|
||||
});
|
||||
this.applyFeatureValue("plan_mode", false);
|
||||
|
||||
return buildCodexPlanImplementationPrompt(planText);
|
||||
}
|
||||
@@ -4645,6 +4645,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
this.config.model = modelId ?? undefined;
|
||||
this.reconcileServiceTier();
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
@@ -4658,42 +4659,35 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const next = validateProviderOptions("codex", CodexProviderOptionsSchema, options) ?? {};
|
||||
// An absent speed or plan option leaves the session as it is.
|
||||
// Supplied keys update the running options; absent keys keep their running value, so an
|
||||
// explicit tier still beats a legacy `fast_mode` on a patch that supplies neither.
|
||||
const running = { ...this.providerOptions, ...next };
|
||||
const speedGiven = next.service_tier !== undefined || next.fast_mode !== undefined;
|
||||
const requestedTier = speedGiven ? readCodexServiceTier(running) : this.requestedServiceTier;
|
||||
if (
|
||||
requestedTier !== this.requestedServiceTier &&
|
||||
!this.isServiceTierAvailable(requestedTier)
|
||||
) {
|
||||
throw new Error(
|
||||
`Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
|
||||
if (featureId === "fast_mode") {
|
||||
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
|
||||
if (value && !fast)
|
||||
throw new Error(
|
||||
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
return this.setFeature("service_tier", value ? fast!.id : "default");
|
||||
}
|
||||
this.providerOptions = running;
|
||||
this.requestedServiceTier = requestedTier;
|
||||
this.setPlanMode(next.plan_mode === undefined ? this.planModeEnabled : next.plan_mode === true);
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
|
||||
private isServiceTierAvailable(tier: string | null): boolean {
|
||||
if (tier === null || tier === "default") return true;
|
||||
const tiers = this.currentServiceTiers();
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
const legacyFast =
|
||||
tier === "fast" && tiers.some((entry) => entry.name.toLowerCase() === "fast");
|
||||
return legacyFast || tiers.some((entry) => entry.id === tier);
|
||||
}
|
||||
|
||||
private setPlanMode(enabled: boolean): void {
|
||||
if (this.planModeEnabled === enabled) return;
|
||||
this.planModeEnabled = enabled;
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
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;
|
||||
this.reconcileServiceTier();
|
||||
this.cachedRuntimeInfo = null;
|
||||
return;
|
||||
}
|
||||
if (featureId === "plan_mode") {
|
||||
this.applyFeatureValue("plan_mode", Boolean(value));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown Codex feature: ${featureId}`);
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
@@ -5346,6 +5340,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async applyDefaultModelAndThinking(): Promise<string> {
|
||||
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
|
||||
this.config.model = model;
|
||||
this.reconcileServiceTier();
|
||||
this.config.thinkingOptionId = thinkingOptionId;
|
||||
return model;
|
||||
}
|
||||
@@ -5412,13 +5407,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private buildCodexInnerConfig(): Record<string, unknown> | null {
|
||||
const innerConfig: Record<string, unknown> = {};
|
||||
const {
|
||||
service_tier: _tier,
|
||||
plan_mode: _plan,
|
||||
fast_mode: _fast,
|
||||
...codexConfig
|
||||
} = this.providerOptions;
|
||||
Object.assign(innerConfig, codexConfig);
|
||||
Object.assign(innerConfig, this.providerOptions);
|
||||
if (this.deps.customCodexConfig) {
|
||||
Object.assign(innerConfig, this.deps.customCodexConfig);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentOptionControl, AgentOptionToggleControl } from "../agent-sdk-types.js";
|
||||
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -9,65 +9,51 @@ export const CodexServiceTierSchema = z.object({
|
||||
});
|
||||
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
|
||||
|
||||
// The control shows the speed the session is running as its default: a client selects the
|
||||
// option's value when `service_tier` is set and the running speed when it is not.
|
||||
function buildCodexSpeedControl(
|
||||
tiers: CodexServiceTier[],
|
||||
runningTier: string | null,
|
||||
): AgentOptionControl[] {
|
||||
function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] {
|
||||
if (tiers.length === 0) return [];
|
||||
const choices = [
|
||||
{ value: "default", label: "Normal" },
|
||||
...tiers.map((tier) => ({
|
||||
value: tier.id,
|
||||
label: tier.name,
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
...(tier.id !== "fast" && tier.name.toLowerCase() === "fast" ? { aliases: ["fast"] } : {}),
|
||||
})),
|
||||
];
|
||||
const defaultChoice = choices.find(
|
||||
(choice) => choice.value === runningTier || choice.aliases?.includes(runningTier ?? ""),
|
||||
);
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
description: "Choose processing speed. Faster tiers increase usage.",
|
||||
tooltip: "Select speed",
|
||||
icon: "zap",
|
||||
desktopTrigger: "icon",
|
||||
defaultValue: defaultChoice?.value ?? "default",
|
||||
choices,
|
||||
value,
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = {
|
||||
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
type: "toggle",
|
||||
key: "plan_mode",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
};
|
||||
|
||||
export function buildCodexControls(input: {
|
||||
export function buildCodexFeatures(input: {
|
||||
serviceTiers: CodexServiceTier[];
|
||||
runningServiceTier: string | null;
|
||||
serviceTier: string;
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable?: boolean;
|
||||
}): AgentOptionControl[] {
|
||||
const controls = buildCodexSpeedControl(input.serviceTiers, input.runningServiceTier);
|
||||
}): AgentFeature[] {
|
||||
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
|
||||
if (input.planModeAvailable !== false) {
|
||||
controls.push({ ...CODEX_PLAN_MODE_CONTROL, defaultValue: input.planModeEnabled });
|
||||
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled });
|
||||
}
|
||||
return controls;
|
||||
return features;
|
||||
}
|
||||
|
||||
export function readCodexServiceTier(options: Record<string, unknown> | undefined): string | null {
|
||||
const tier = options?.service_tier;
|
||||
export function readCodexServiceTier(values: Record<string, unknown> | undefined): string | null {
|
||||
const tier = values?.service_tier;
|
||||
if (typeof tier === "string") return tier;
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
return options?.fast_mode ? "fast" : null;
|
||||
return values?.fast_mode ? "fast" : null;
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@ const NetworkPolicySchema = z
|
||||
// Codex config reference, maintained against Codex CLI 0.143+.
|
||||
export const CodexProviderOptionsSchema = z
|
||||
.object({
|
||||
// Composer controls, not Codex config: the provider reads them and keeps them out of `config`.
|
||||
service_tier: z.string().optional(),
|
||||
plan_mode: z.boolean().optional(),
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
fast_mode: z.boolean().optional(),
|
||||
approval_policy: ApprovalPolicySchema.optional(),
|
||||
sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(),
|
||||
sandbox_workspace_write: z
|
||||
|
||||
@@ -6,7 +6,7 @@ import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { ACPAgentSession } from "./acp-agent.js";
|
||||
import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js";
|
||||
import type { AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
|
||||
import type { AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js";
|
||||
import { GenericACPAgentClient } from "./generic-acp-agent.js";
|
||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
||||
@@ -155,7 +155,7 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes Cursor fast mode through provider controls", async () => {
|
||||
test("exposes Cursor fast mode through provider features", async () => {
|
||||
const client = new TestCursorACPAgentClient({
|
||||
sessionId: "session-1",
|
||||
models: null,
|
||||
@@ -163,30 +163,43 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "acp",
|
||||
cwd: "/tmp/cursor",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
key: "auto_accept",
|
||||
id: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
key: CURSOR_FAST_FEATURE_OPTION.id,
|
||||
id: CURSOR_FAST_FEATURE_OPTION.id,
|
||||
label: "Fast",
|
||||
description: "Cursor fast mode",
|
||||
tooltip: "Select Cursor fast mode",
|
||||
icon: "zap",
|
||||
defaultValue: "false",
|
||||
choices: [
|
||||
{ value: "false", label: "Off" },
|
||||
{ value: "true", label: "Fast" },
|
||||
value: "false",
|
||||
options: [
|
||||
{
|
||||
id: "false",
|
||||
label: "Off",
|
||||
isDefault: true,
|
||||
description: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "true",
|
||||
label: "Fast",
|
||||
isDefault: false,
|
||||
description: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -215,9 +228,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
session: {
|
||||
currentModelId: string;
|
||||
configOptions: SessionConfigOption[];
|
||||
setSessionConfigOption?: (request: {
|
||||
value: string;
|
||||
}) => Promise<{ configOptions: SessionConfigOption[] }>;
|
||||
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>;
|
||||
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||
logger = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
@@ -266,7 +277,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
// still stored after the draft moved to a model that has no fast variant.
|
||||
const warn = captureWarnings();
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||
warn.logger,
|
||||
);
|
||||
@@ -284,7 +295,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
// still listed after the model moves to one that has no fast variant.
|
||||
const warn = captureWarnings();
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||
@@ -308,7 +319,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
|
||||
test("still fails session start when a stored feature write fails for another reason", async () => {
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||
@@ -323,251 +334,16 @@ describe("CursorACPAgentClient session start", () => {
|
||||
const session = createCursorSession({ model: "kimi-k3" });
|
||||
await session.initializeNewSession();
|
||||
|
||||
await expect(session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" })).rejects.toThrow(
|
||||
await expect(session.setFeature(CURSOR_FAST_FEATURE_OPTION.id, "true")).rejects.toThrow(
|
||||
"acp does not expose ACP feature 'fast'",
|
||||
);
|
||||
});
|
||||
|
||||
test("removing auto_accept keeps answering permission requests and the control shows it", 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 session.applyOptions({});
|
||||
|
||||
await expect(session.requestPermission({ ...request })).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "allow-always" },
|
||||
});
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({ key: "auto_accept", defaultValue: true }),
|
||||
);
|
||||
});
|
||||
|
||||
test("a rejected options update leaves permission handling as it was", async () => {
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5" },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false")],
|
||||
setSessionConfigOption: vi.fn(async () => ({ configOptions: [fastConfigOption("false")] })),
|
||||
},
|
||||
);
|
||||
await session.initializeNewSession();
|
||||
|
||||
await expect(
|
||||
session.applyOptions({
|
||||
auto_accept: true,
|
||||
[CURSOR_FAST_FEATURE_OPTION.id]: "no-such-choice",
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
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("a selection the provider changes on its own replaces the stored option", async () => {
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("true")],
|
||||
},
|
||||
);
|
||||
await session.initializeNewSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: { sessionUpdate: "config_option_update", configOptions: [fastConfigOption("false")] },
|
||||
});
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "options_changed",
|
||||
provider: "acp",
|
||||
patch: { [CURSOR_FAST_FEATURE_OPTION.id]: "false" },
|
||||
});
|
||||
});
|
||||
|
||||
test("a selection a model or thinking write changes replaces the stored option", async () => {
|
||||
const reasoning = (currentValue: string): SessionConfigOption => ({
|
||||
...KIMI_K3_CONFIG_OPTIONS[0]!,
|
||||
currentValue,
|
||||
});
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [reasoning("max"), fastConfigOption("true")],
|
||||
// Changing the reasoning level also turns Fast off, with no separate notification.
|
||||
setSessionConfigOption: async (request) => ({
|
||||
configOptions: [
|
||||
reasoning(request.value === "low" ? "low" : "max"),
|
||||
fastConfigOption(request.value === "low" ? "false" : "true"),
|
||||
],
|
||||
}),
|
||||
},
|
||||
);
|
||||
await session.initializeNewSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.setThinkingOption("low");
|
||||
|
||||
expect(events).toContainEqual({
|
||||
type: "options_changed",
|
||||
provider: "acp",
|
||||
patch: { [CURSOR_FAST_FEATURE_OPTION.id]: "false" },
|
||||
});
|
||||
});
|
||||
|
||||
test("an option removed from the agent stays absent when the provider later selects it again", 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();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" });
|
||||
await session.applyOptions({});
|
||||
await session.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: { sessionUpdate: "config_option_update", configOptions: [fastConfigOption("true")] },
|
||||
});
|
||||
|
||||
expect(events.filter((event) => event.type === "options_changed")).toEqual([]);
|
||||
});
|
||||
|
||||
test("startup writes keep the requested Fast option while a thinking change settles", async () => {
|
||||
const state = { reasoning: "max", fast: "false" };
|
||||
const setSessionConfigOption = vi.fn(async (request: { configId: string; value: string }) => {
|
||||
if (request.configId === "reasoning") state.reasoning = request.value;
|
||||
if (request.configId === "fast") state.fast = request.value;
|
||||
return {
|
||||
configOptions: [
|
||||
{ ...KIMI_K3_CONFIG_OPTIONS[0], currentValue: state.reasoning } as SessionConfigOption,
|
||||
fastConfigOption(state.fast as "true" | "false"),
|
||||
],
|
||||
};
|
||||
});
|
||||
const session = createCursorSession(
|
||||
{
|
||||
model: "composer-2.5",
|
||||
thinkingOptionId: "low",
|
||||
providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" },
|
||||
},
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [KIMI_K3_CONFIG_OPTIONS[0]!, fastConfigOption("false")],
|
||||
setSessionConfigOption,
|
||||
},
|
||||
);
|
||||
|
||||
await session.initializeNewSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual([
|
||||
"low",
|
||||
"true",
|
||||
]);
|
||||
expect(events.filter((event) => event.type === "options_changed")).toEqual([]);
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({ key: "fast", defaultValue: "true" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("an options patch that never held fast leaves a selection the provider made alone", 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.sessionUpdate({
|
||||
sessionId: "session-1",
|
||||
update: { sessionUpdate: "config_option_update", configOptions: [fastConfigOption("true")] },
|
||||
});
|
||||
|
||||
await session.applyOptions({ rpcTimeoutMs: 120000 });
|
||||
|
||||
expect(setSessionConfigOption).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("an acknowledgement given while the session starts reaches the first subscriber", async () => {
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false")],
|
||||
setSessionConfigOption: vi.fn(async () => ({
|
||||
configOptions: [fastConfigOption("false")],
|
||||
})),
|
||||
},
|
||||
);
|
||||
await session.initializeNewSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
expect(events.filter((event) => event.type === "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "acp", patch: { fast: "false" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("removing the Fast option leaves the provider on its current selection", 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" });
|
||||
// `{ fast: null }` with no config.json default reaches the provider as no key at all.
|
||||
await session.applyOptions({});
|
||||
|
||||
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual(["true"]);
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({ key: "fast", defaultValue: "true" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("still fails session start when the provider rejects a write it should accept", async () => {
|
||||
// No model switch, so the session's options came straight from session/new and the
|
||||
// provider disagreeing about a feature it just advertised is a real failure.
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "composer-2.5", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false")],
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
providerOptions: { mockPromptRejections: 1 },
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
});
|
||||
|
||||
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
|
||||
@@ -95,7 +95,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
providerOptions: {
|
||||
featureValues: {
|
||||
mockStreamingAssistantResponse: response,
|
||||
mockStreamingAssistantIntervalMs: 20,
|
||||
},
|
||||
@@ -467,7 +467,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "e2e-fast-stream",
|
||||
providerOptions: { mockAssistantResponses: ["First response", ""] },
|
||||
featureValues: { mockAssistantResponses: ["First response", ""] },
|
||||
});
|
||||
await session.run("Emit a synthetic turn failure.");
|
||||
expect((await session.run("First prompt")).finalText).toBe("First response");
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -787,7 +787,7 @@ function getConfiguredAssistantResponses(value: unknown): string[] {
|
||||
export class MockLoadTestAgentSession implements AgentSession {
|
||||
readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID;
|
||||
readonly capabilities = CAPABILITIES;
|
||||
readonly controls: AgentOptionControl[] = [];
|
||||
readonly features: AgentFeature[] = [];
|
||||
readonly id: string;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
|
||||
@@ -812,18 +812,18 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
|
||||
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
||||
this.assistantResponse =
|
||||
typeof options.config.providerOptions?.mockAssistantResponse === "string"
|
||||
? options.config.providerOptions.mockAssistantResponse
|
||||
typeof options.config.featureValues?.mockAssistantResponse === "string"
|
||||
? options.config.featureValues.mockAssistantResponse
|
||||
: null;
|
||||
this.assistantResponses = getConfiguredAssistantResponses(
|
||||
options.config.providerOptions?.mockAssistantResponses,
|
||||
options.config.featureValues?.mockAssistantResponses,
|
||||
);
|
||||
this.streamingAssistantResponse =
|
||||
typeof options.config.providerOptions?.mockStreamingAssistantResponse === "string"
|
||||
? options.config.providerOptions.mockStreamingAssistantResponse
|
||||
typeof options.config.featureValues?.mockStreamingAssistantResponse === "string"
|
||||
? options.config.featureValues.mockStreamingAssistantResponse
|
||||
: null;
|
||||
const requestedStreamingInterval =
|
||||
options.config.providerOptions?.mockStreamingAssistantIntervalMs;
|
||||
options.config.featureValues?.mockStreamingAssistantIntervalMs;
|
||||
this.streamingAssistantIntervalMs =
|
||||
typeof requestedStreamingInterval === "number" &&
|
||||
Number.isFinite(requestedStreamingInterval) &&
|
||||
@@ -831,10 +831,10 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
? Math.min(requestedStreamingInterval, 1_000)
|
||||
: MOCK_LOAD_TEST_INTERVAL_MS;
|
||||
this.rewindError =
|
||||
typeof options.config.providerOptions?.mockRewindError === "string"
|
||||
? options.config.providerOptions.mockRewindError
|
||||
typeof options.config.featureValues?.mockRewindError === "string"
|
||||
? options.config.featureValues.mockRewindError
|
||||
: null;
|
||||
const requestedPromptRejections = options.config.providerOptions?.mockPromptRejections;
|
||||
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
|
||||
this.remainingPromptRejections =
|
||||
typeof requestedPromptRejections === "number" &&
|
||||
Number.isSafeInteger(requestedPromptRejections) &&
|
||||
@@ -842,7 +842,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
? requestedPromptRejections
|
||||
: 0;
|
||||
this.remainingSteerFailures = getPositiveFeatureInteger(
|
||||
options.config.providerOptions?.mockSteerAmbiguousFailures,
|
||||
options.config.featureValues?.mockSteerAmbiguousFailures,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -613,9 +613,9 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
|
||||
"openrouter/google/gemini-3.8-flash",
|
||||
);
|
||||
expect(omp.requireSession().controls).toEqual([
|
||||
expect(omp.requireSession().features).toEqual([
|
||||
expect.objectContaining({
|
||||
defaultValue: true,
|
||||
value: true,
|
||||
description: expect.stringMatching(/does not apply/i),
|
||||
}),
|
||||
]);
|
||||
@@ -626,57 +626,18 @@ describe("OMP agent client and session", () => {
|
||||
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
|
||||
const session = omp.requireSession();
|
||||
omp.runtime().fastModeResult = { enabled: true, active: false };
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(session.controls).toEqual([
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
key: "fast_mode",
|
||||
defaultValue: true,
|
||||
id: "fast_mode",
|
||||
value: true,
|
||||
description: expect.stringMatching(/does not apply to this model/i),
|
||||
tooltip: expect.stringMatching(/does not apply to this model/i),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("removing the fast_mode option leaves the runtime on its current setting", 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]);
|
||||
|
||||
// `{ fast_mode: null }` with no config.json default reaches the provider as no key at all.
|
||||
await session.applyOptions?.({});
|
||||
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]);
|
||||
});
|
||||
|
||||
test("an options patch that never held fast_mode leaves the runtime's Fast setting alone", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({ model: "openai/gpt-5" });
|
||||
const session = omp.requireSession();
|
||||
expect(omp.runtime().state.fastModeEnabled).toBe(false);
|
||||
|
||||
// Another model family has Fast on natively.
|
||||
omp.runtime().setModelResult = { provider: "anthropic", id: "claude-x" };
|
||||
omp.runtime().queueStateReports([
|
||||
{
|
||||
...omp.runtime().state,
|
||||
model: omp.runtime().setModelResult,
|
||||
fastModeEnabled: true,
|
||||
fastModeActive: true,
|
||||
},
|
||||
]);
|
||||
await session.setModel?.("anthropic/claude-x");
|
||||
|
||||
await session.applyOptions?.({ rpcTimeoutMs: 120000 });
|
||||
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([]);
|
||||
});
|
||||
|
||||
test("shows Fast only when OMP reports its state fields", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
@@ -688,40 +649,11 @@ describe("OMP agent client and session", () => {
|
||||
} = omp.runtime().state;
|
||||
omp.runtime().state = olderState;
|
||||
await session.getRuntimeInfo();
|
||||
expect(session.controls).toEqual([]);
|
||||
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(/unavailable/i);
|
||||
expect(session.features).toEqual([]);
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
|
||||
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
|
||||
await session.getRuntimeInfo();
|
||||
expect(session.controls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a Fast change OMP makes by itself replaces the held fast_mode option", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start({ model: "openai/gpt-5" });
|
||||
const session = omp.requireSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
|
||||
// Another model family has its own Fast tier, which is unset.
|
||||
omp.runtime().setModelResult = { provider: "anthropic", id: "claude-x" };
|
||||
omp.runtime().queueStateReports([
|
||||
{
|
||||
...omp.runtime().state,
|
||||
model: omp.runtime().setModelResult,
|
||||
fastModeEnabled: false,
|
||||
fastModeActive: false,
|
||||
},
|
||||
]);
|
||||
await session.setModel?.("anthropic/claude-x");
|
||||
|
||||
expect(events.filter((event) => event.type === "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "omp", patch: { fast_mode: false } },
|
||||
]);
|
||||
// The options the manager now holds leave the runtime alone.
|
||||
await session.applyOptions?.({ fast_mode: false });
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(session.features).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("switching model refreshes whether Fast applies", async () => {
|
||||
@@ -729,8 +661,8 @@ describe("OMP agent client and session", () => {
|
||||
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
|
||||
const session = omp.requireSession();
|
||||
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]);
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
expect(session.features).toEqual([expect.objectContaining({ value: true })]);
|
||||
|
||||
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
|
||||
omp.runtime().queueStateReports([
|
||||
@@ -742,9 +674,9 @@ describe("OMP agent client and session", () => {
|
||||
},
|
||||
]);
|
||||
await session.setModel?.("openrouter/other/model");
|
||||
expect(session.controls).toEqual([
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
defaultValue: true,
|
||||
value: true,
|
||||
description: expect.stringMatching(/does not apply/i),
|
||||
}),
|
||||
]);
|
||||
@@ -752,14 +684,14 @@ describe("OMP agent client and session", () => {
|
||||
|
||||
test("restores Fast from the initial OMP state on create and resume", async () => {
|
||||
const created = new OmpHarness();
|
||||
await created.start({ providerOptions: { fast_mode: true } });
|
||||
await created.start({ featureValues: { fast_mode: true } });
|
||||
expect(created.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(created.runtime().getStateRequestCount).toBe(1);
|
||||
|
||||
const resumed = new OmpHarness();
|
||||
await resumed.resume(
|
||||
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
|
||||
{ providerOptions: { fast_mode: true } },
|
||||
{ featureValues: { fast_mode: true } },
|
||||
);
|
||||
expect(resumed.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(resumed.runtime().getStateRequestCount).toBe(1);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { mapCustomMessageToToolCall } from "../custom-message.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -10,7 +9,7 @@ import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -805,16 +804,15 @@ export class OmpAgentSession implements AgentSession {
|
||||
return this.state.sessionId;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
get features(): AgentFeature[] {
|
||||
if (!hasOmpFastMode(this.state)) return [];
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
key: "fast_mode",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
icon: "zap",
|
||||
// OMP owns fast mode; an unset option stands for what the runtime reports.
|
||||
defaultValue: this.state.fastModeEnabled === true,
|
||||
value: this.state.fastModeEnabled === true,
|
||||
...(this.state.fastModeEnabled && !this.state.fastModeActive
|
||||
? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
|
||||
: {}),
|
||||
@@ -822,24 +820,17 @@ export class OmpAgentSession implements AgentSession {
|
||||
];
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const requested = options.fast_mode;
|
||||
if (requested !== undefined && typeof requested !== "boolean") {
|
||||
throw new Error("OMP fast mode requires a boolean");
|
||||
}
|
||||
// An absent option leaves Fast as the runtime has it.
|
||||
if (requested !== undefined && requested !== this.state.fastModeEnabled) {
|
||||
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
|
||||
const result = await this.runtimeSession.setFastMode(requested);
|
||||
this.state = {
|
||||
...this.state,
|
||||
fastModeEnabled: result.enabled,
|
||||
fastModeActive: result.active,
|
||||
};
|
||||
}
|
||||
const { fast_mode: _previous, ...others } = this.config.providerOptions ?? {};
|
||||
this.config.providerOptions =
|
||||
requested === undefined ? others : { ...others, fast_mode: requested };
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`);
|
||||
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
|
||||
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
|
||||
const result = await this.runtimeSession.setFastMode(value);
|
||||
this.state = {
|
||||
...this.state,
|
||||
fastModeEnabled: result.enabled,
|
||||
fastModeActive: result.active,
|
||||
};
|
||||
this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled };
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -2237,23 +2228,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
|
||||
private async refreshState(): Promise<void> {
|
||||
this.state = await this.runtimeSession.getState();
|
||||
this.followRuntimeFastMode();
|
||||
}
|
||||
|
||||
/**
|
||||
* OMP tracks Fast per model family, so switching models can change it. A held `fast_mode`
|
||||
* option follows what the runtime reports; with no option held, the control's default shows it.
|
||||
*/
|
||||
private followRuntimeFastMode(): void {
|
||||
const held = this.config.providerOptions?.fast_mode;
|
||||
const reported = this.state.fastModeEnabled;
|
||||
if (typeof held !== "boolean" || typeof reported !== "boolean" || held === reported) return;
|
||||
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: reported };
|
||||
this.emit({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { fast_mode: reported },
|
||||
});
|
||||
}
|
||||
|
||||
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
|
||||
@@ -2321,7 +2295,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
state: OmpSessionState,
|
||||
): Promise<OmpSessionState> {
|
||||
const value = config.providerOptions?.fast_mode;
|
||||
const value = config.featureValues?.fast_mode;
|
||||
if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
|
||||
return state;
|
||||
const result = await runtimeSession.setFastMode(value);
|
||||
@@ -2556,7 +2530,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ export { OMP_MODES };
|
||||
|
||||
export const OmpProviderOptionsSchema = z
|
||||
.object({
|
||||
// Composer control, not a runtime option: the session reads it and asks OMP to match.
|
||||
fast_mode: z.boolean().optional(),
|
||||
sessionDir: z.string().min(1).optional(),
|
||||
rpcTimeoutMs: z.number().int().positive().optional(),
|
||||
smolModel: z.string().min(1).optional(),
|
||||
|
||||
@@ -108,22 +108,33 @@ describe("OpenCode auto_accept feature", () => {
|
||||
expect(modes).toEqual([]);
|
||||
});
|
||||
|
||||
test("lists auto accept as a provider control", async () => {
|
||||
test("lists auto accept as a provider feature", async () => {
|
||||
const { runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const controls = await client.listControls({
|
||||
const enabledFeatures = await client.listFeatures({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const legacyFeatures = await client.listFeatures({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "full-access",
|
||||
});
|
||||
|
||||
expect(controls).toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
|
||||
expect(enabledFeatures).toEqual([
|
||||
expect.objectContaining({
|
||||
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 () => {
|
||||
@@ -140,7 +151,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
|
||||
expect(await session.getCurrentMode()).toBe("build");
|
||||
expect(session.controls).toEqual([expect.objectContaining({ key: "auto_accept" })]);
|
||||
expect(session.features).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
|
||||
|
||||
await session.run("Implement the change");
|
||||
|
||||
@@ -159,7 +170,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: "full-access",
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: null,
|
||||
unattended: false,
|
||||
availableModes: [
|
||||
@@ -167,7 +178,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "plan", label: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: "build", providerOptions: { auto_accept: true } });
|
||||
).toEqual({ modeId: "build", featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("inherits unattended callers as auto accept with OpenCode's default agent", () => {
|
||||
@@ -180,7 +191,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "bypassPermissions",
|
||||
@@ -192,7 +203,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "plan", label: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => {
|
||||
@@ -202,7 +213,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [
|
||||
@@ -210,7 +221,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "plan", label: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => {
|
||||
@@ -220,7 +231,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "opencode",
|
||||
modeId: "paseo-custom",
|
||||
@@ -233,7 +244,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "paseo-custom", label: "Paseo Custom" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: "paseo-custom", providerOptions: { auto_accept: true } });
|
||||
).toEqual({ modeId: "paseo-custom", featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("inherits auto accept from an OpenCode parent when the child chooses a mode", () => {
|
||||
@@ -243,7 +254,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: "base",
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "opencode",
|
||||
modeId: "orchestrator",
|
||||
@@ -256,7 +267,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "orchestrator", label: "Orchestrator" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||
).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("auto-approves tool permissions when auto accept is enabled", async () => {
|
||||
@@ -272,7 +283,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
session.subscribe((event) => receivedEvents.push(event));
|
||||
|
||||
@@ -290,33 +301,6 @@ describe("OpenCode auto_accept feature", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("removing auto_accept keeps auto-approving and the control shows it", 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 },
|
||||
});
|
||||
|
||||
// The daemon passes effective options; with the key removed and no config default it is absent.
|
||||
await session.applyOptions?.({});
|
||||
await session.run("Run verification");
|
||||
|
||||
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "auto_accept", defaultValue: true }),
|
||||
]);
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("keeps questions separate from auto accept tool approval", async () => {
|
||||
const { openCodeClient, runtime } = mockOpenCodeClient({
|
||||
events: [questionEvent(), idleEvent()],
|
||||
@@ -330,7 +314,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
session.subscribe((event) => receivedEvents.push(event));
|
||||
|
||||
|
||||
@@ -5274,7 +5274,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
const parent = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/workspace/repo",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
parent.subscribe(() => undefined);
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
@@ -26,8 +25,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentOptionControl,
|
||||
type AgentOptionToggleControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
@@ -60,7 +58,6 @@ import {
|
||||
type ToolCallDetail,
|
||||
type ToolCallTimelineItem,
|
||||
} from "../agent-sdk-types.js";
|
||||
import { isToggleOn } from "../option-controls.js";
|
||||
import { importSessionFromPersistence } from "../provider-session-import.js";
|
||||
import {
|
||||
raceProviderRefreshAbort,
|
||||
@@ -235,15 +232,15 @@ const DEFAULT_MODES: AgentMode[] = [
|
||||
];
|
||||
|
||||
function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||
return config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
|
||||
function withOpenCodeAutoAccept(
|
||||
providerOptions: ProviderOptions | undefined,
|
||||
function withOpenCodeAutoAcceptFeature(
|
||||
featureValues: Record<string, unknown> | undefined,
|
||||
enabled: boolean,
|
||||
): ProviderOptions {
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
...providerOptions,
|
||||
...featureValues,
|
||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
|
||||
};
|
||||
}
|
||||
@@ -262,25 +259,25 @@ function resolveOpenCodeCreateConfig(
|
||||
const requestedMode = legacyFullAccess
|
||||
? OPENCODE_BUILD_MODE_ID
|
||||
: (input.requestedMode ?? inheritedOpenCodeMode);
|
||||
const providerOptions =
|
||||
const featureValues =
|
||||
legacyFullAccess ||
|
||||
(isUnattendedCreate && input.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
||||
? withOpenCodeAutoAccept(input.providerOptions, true)
|
||||
: input.providerOptions;
|
||||
(isUnattendedCreate && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
||||
? withOpenCodeAutoAcceptFeature(input.featureValues, true)
|
||||
: input.featureValues;
|
||||
|
||||
if (inheritsUnattended && requestedMode === undefined) {
|
||||
// Unattendedness for OpenCode is carried by auto_accept (set above), not
|
||||
// by any particular agent. Leave the mode unset so OpenCode uses its own
|
||||
// default agent — `build` may not exist in the user's OpenCode config.
|
||||
return { modeId: undefined, providerOptions };
|
||||
return { modeId: undefined, featureValues };
|
||||
}
|
||||
|
||||
const resolved = resolveDefaultAgentCreateConfig({
|
||||
...input,
|
||||
requestedMode,
|
||||
providerOptions,
|
||||
featureValues,
|
||||
});
|
||||
return { ...resolved, providerOptions };
|
||||
return { ...resolved, featureValues };
|
||||
}
|
||||
|
||||
function isOpenCodeCreateConfigUnattended(
|
||||
@@ -288,18 +285,26 @@ function isOpenCodeCreateConfigUnattended(
|
||||
): boolean {
|
||||
return (
|
||||
isDefaultAgentCreateConfigUnattended(input) ||
|
||||
isToggleOn(OPENCODE_AUTO_ACCEPT_FEATURE_ID, input.config.providerOptions, input.controls)
|
||||
input.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true ||
|
||||
input.features?.some(
|
||||
(feature) =>
|
||||
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
|
||||
(feature.value === true || feature.value === "true"),
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
const OPENCODE_AUTO_ACCEPT_CONTROL: AgentOptionToggleControl = {
|
||||
type: "toggle",
|
||||
key: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves OpenCode tool permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
};
|
||||
function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves OpenCode tool permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: isOpenCodeAutoAcceptEnabled(config),
|
||||
};
|
||||
}
|
||||
|
||||
function buildOpenCodePermissionActions(): AgentPermissionAction[] {
|
||||
return [
|
||||
@@ -677,7 +682,10 @@ function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConf
|
||||
return {
|
||||
...normalized,
|
||||
modeId: OPENCODE_BUILD_MODE_ID,
|
||||
providerOptions: withOpenCodeAutoAccept(normalized.providerOptions, true),
|
||||
featureValues: {
|
||||
...normalized.featureValues,
|
||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1671,9 +1679,8 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
this.assertConfig(config);
|
||||
return [OPENCODE_AUTO_ACCEPT_CONTROL];
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
|
||||
}
|
||||
|
||||
async listImportableSessions(
|
||||
@@ -3558,8 +3565,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return this.turnState.status === "running" ? this.turnState.turnId : null;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [{ ...OPENCODE_AUTO_ACCEPT_CONTROL, defaultValue: this.autoAcceptEnabled }];
|
||||
get features(): AgentFeature[] {
|
||||
return [buildOpenCodeAutoAcceptFeature(this.config)];
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -4949,15 +4956,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const normalizedModeId = normalizeOpenCodeModeId(modeId);
|
||||
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
||||
this.currentMode = OPENCODE_BUILD_MODE_ID;
|
||||
this.setAutoAccept(true);
|
||||
this.notifySubscribers(
|
||||
{
|
||||
type: "options_changed",
|
||||
provider: "opencode",
|
||||
patch: { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true },
|
||||
},
|
||||
null,
|
||||
);
|
||||
await this.setFeature(OPENCODE_AUTO_ACCEPT_FEATURE_ID, true);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4965,15 +4964,17 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.modeId = normalizedModeId ?? undefined;
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
// An absent option leaves auto-accept as it is.
|
||||
const requested = options[OPENCODE_AUTO_ACCEPT_FEATURE_ID];
|
||||
if (requested !== undefined) this.setAutoAccept(requested === true);
|
||||
}
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) {
|
||||
throw new Error(`Unsupported OpenCode feature '${featureId}'`);
|
||||
}
|
||||
|
||||
private setAutoAccept(enabled: boolean): void {
|
||||
const enabled = value === true;
|
||||
this.autoAcceptEnabled = enabled;
|
||||
this.config.providerOptions = withOpenCodeAutoAccept(this.config.providerOptions, enabled);
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
|
||||
};
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
|
||||
@@ -10,8 +10,6 @@ const PermissionRuleSchema = z.union([
|
||||
// OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46.
|
||||
export const OpenCodeProviderOptionsSchema = z
|
||||
.object({
|
||||
// Composer control: approves permission prompts without asking.
|
||||
auto_accept: z.boolean().optional(),
|
||||
permission: z
|
||||
.union([
|
||||
PermissionActionSchema,
|
||||
|
||||
@@ -100,11 +100,11 @@ test.each([
|
||||
});
|
||||
try {
|
||||
await writeFile(script, initialSource);
|
||||
expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
"Auto Accept",
|
||||
);
|
||||
await writeFile(script, 'console.log("opencode v2.0.10")');
|
||||
expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
"Auto-accept",
|
||||
);
|
||||
} finally {
|
||||
@@ -202,7 +202,7 @@ test.each([
|
||||
const legacy = new OpenCodeAgentClient(logger);
|
||||
const config = { provider: "opencode", cwd: root };
|
||||
try {
|
||||
expect(await client.listControls(config)).toEqual(await legacy.listControls(config));
|
||||
expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config));
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
await legacy.shutdown();
|
||||
@@ -221,8 +221,8 @@ test("selects v2 without probing the binary when an external server is configure
|
||||
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
|
||||
});
|
||||
try {
|
||||
const controls = await client.listControls({ provider: "opencode", cwd: "/tmp/project" });
|
||||
expect(controls.map((control) => control.label)).toEqual(["Auto-accept"]);
|
||||
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
|
||||
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ export class OpenCodeRuntimeClient implements AgentClient {
|
||||
async listCommands(config: AgentSessionConfig) {
|
||||
return (await this.client()).listCommands(config);
|
||||
}
|
||||
async listControls(config: AgentSessionConfig) {
|
||||
return (await this.client()).listControls(config);
|
||||
async listFeatures(config: AgentSessionConfig) {
|
||||
return (await this.client()).listFeatures(config);
|
||||
}
|
||||
async listImportableSessions(options?: ListImportableSessionsOptions) {
|
||||
return (await this.client()).listImportableSessions(options);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { OpenCodeV2Session } from "./session.js";
|
||||
import { V2_CAPABILITIES } from "./capabilities.js";
|
||||
import {
|
||||
controls,
|
||||
features,
|
||||
permissionRules,
|
||||
applyResumeOverrides,
|
||||
requiresDedicatedV2Server,
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
@@ -222,8 +222,8 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async listControls(): Promise<AgentOptionControl[]> {
|
||||
return controls;
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
return features(config);
|
||||
}
|
||||
async listCommands(config: AgentSessionConfig) {
|
||||
const connection = await this.runtime.acquire();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { controls, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
|
||||
import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
|
||||
|
||||
describe("OpenCode v2 permission rules", () => {
|
||||
test("maps authored native permissions and exact injected MCP grants", () => {
|
||||
@@ -49,8 +49,17 @@ describe("OpenCode v2 helper isolation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode v2 controls", () => {
|
||||
test("describes the auto-accept toggle over the auto_accept option", () => {
|
||||
expect(controls).toEqual([{ type: "toggle", key: "auto_accept", label: "Auto-accept" }]);
|
||||
describe("OpenCode v2 features", () => {
|
||||
test("reports the auto-accept toggle from the session value", () => {
|
||||
expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([
|
||||
{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: false },
|
||||
]);
|
||||
expect(
|
||||
features({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
featureValues: { auto_accept: true },
|
||||
}),
|
||||
).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,8 @@ import type { SessionInfo, PermissionRuleset } from "@opencode/client";
|
||||
import type { V2Api } from "./api.js";
|
||||
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentOptionControl,
|
||||
AgentOptionToggleControl,
|
||||
AgentSessionConfig,
|
||||
} from "../../../agent-sdk-types.js";
|
||||
|
||||
@@ -12,15 +11,15 @@ import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../
|
||||
|
||||
import { modelRef } from "./mapping.js";
|
||||
|
||||
const autoAcceptControl: AgentOptionToggleControl = {
|
||||
type: "toggle",
|
||||
key: "auto_accept",
|
||||
label: "Auto-accept",
|
||||
};
|
||||
export const controls: AgentOptionControl[] = [autoAcceptControl];
|
||||
/** The controls of a running session, showing whether auto-accept is on. */
|
||||
export function controlsFor(autoAccept: boolean): AgentOptionControl[] {
|
||||
return [{ ...autoAcceptControl, defaultValue: autoAccept }];
|
||||
export function features(config: AgentSessionConfig): AgentFeature[] {
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto-accept",
|
||||
value: config.featureValues?.["auto_accept"] === true,
|
||||
},
|
||||
];
|
||||
}
|
||||
export function permissionRules(config: AgentSessionConfig): PermissionRuleset {
|
||||
const rules =
|
||||
|
||||
@@ -93,7 +93,7 @@ export class SessionPermissions {
|
||||
};
|
||||
this.pending.set(request.id, pending);
|
||||
this.permissionOwners.set(request.id, sessionID);
|
||||
if (!this.config.toolPolicy && this.config.providerOptions?.["auto_accept"] === true)
|
||||
if (!this.config.toolPolicy && this.config.featureValues?.["auto_accept"] === true)
|
||||
await this.respondToPermission(request.id, { behavior: "allow" });
|
||||
else this.emit({ type: "permission_requested", provider: "opencode", request: pending });
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { SessionChildren } from "./children.js";
|
||||
import { SessionTurns } from "./turns.js";
|
||||
import { V2Timeline } from "./timeline.js";
|
||||
@@ -32,7 +31,7 @@ import { type V2Connection } from "./runtime.js";
|
||||
import { modelRef, modesFromV2 } from "./mapping.js";
|
||||
|
||||
import { V2_CAPABILITIES } from "./capabilities.js";
|
||||
import { controlsFor } from "./configuration.js";
|
||||
import { features } from "./configuration.js";
|
||||
import { commands } from "./commands.js";
|
||||
import { messages } from "./history.js";
|
||||
import { SessionPermissions } from "./permissions.js";
|
||||
@@ -119,8 +118,8 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
get id() {
|
||||
return this.info.id;
|
||||
}
|
||||
get controls() {
|
||||
return controlsFor(this.config.providerOptions?.auto_accept === true);
|
||||
get features() {
|
||||
return features(this.config);
|
||||
}
|
||||
private get client() {
|
||||
return this.connection.client;
|
||||
@@ -359,12 +358,10 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
this.info = await this.client.session.get({ sessionID: this.id });
|
||||
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
|
||||
}
|
||||
async applyOptions(options: ProviderOptions) {
|
||||
// An absent key leaves auto-accept as it is.
|
||||
const value = options["auto_accept"];
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "boolean") throw new Error("OpenCode auto_accept requires a boolean");
|
||||
this.config.providerOptions = { ...this.config.providerOptions, auto_accept: value };
|
||||
async setFeature(id: string, value: unknown) {
|
||||
if (id !== "auto_accept" || typeof value !== "boolean")
|
||||
throw new Error("Unknown OpenCode feature");
|
||||
this.config.featureValues = { ...this.config.featureValues, [id]: value };
|
||||
if (value && !this.config.toolPolicy)
|
||||
for (const request of this.permissions.list())
|
||||
if (request.kind !== "question")
|
||||
|
||||
@@ -2961,12 +2961,12 @@ describe("PiRpcAgentClient", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("lists no draft controls without starting a Pi session", async () => {
|
||||
test("lists no draft features without starting a Pi session", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
await expect(
|
||||
client.listControls(createConfig({ model: "openrouter/test/model" })),
|
||||
client.listFeatures(createConfig({ model: "openrouter/test/model" })),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(0);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { z } from "zod";
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -2828,7 +2828,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-
|
||||
import type { AgentManager } from "../agent-manager.js";
|
||||
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||
import { featuresFromControls } from "../option-controls.js";
|
||||
import {
|
||||
AgentFeatureSchema,
|
||||
AgentPermissionRequestPayloadSchema,
|
||||
@@ -940,6 +939,9 @@ function createToolCatalog(
|
||||
...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions
|
||||
? { providerOptions: callerAgent.config.providerOptions }
|
||||
: {}),
|
||||
...(callerAgent.config.featureValues
|
||||
? { featureValues: callerAgent.config.featureValues }
|
||||
: {}),
|
||||
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
|
||||
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
|
||||
};
|
||||
@@ -1636,7 +1638,7 @@ function createToolCatalog(
|
||||
cwd: resolvedArgs.cwd,
|
||||
workspaceId: resolvedArgs.workspaceId,
|
||||
thinking: parsedArgs.settings?.thinkingOptionId,
|
||||
providerOptions: parsedArgs.settings?.features,
|
||||
features: parsedArgs.settings?.features,
|
||||
labels: {
|
||||
...parsedArgs.labels,
|
||||
...(callerAgentId && !resolvedArgs.detached && options.serverId
|
||||
@@ -2288,7 +2290,9 @@ function createToolCatalog(
|
||||
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
|
||||
}
|
||||
if (settings?.features) {
|
||||
await agentManager.setAgentOptions(agentId, settings.features);
|
||||
for (const [featureId, value] of Object.entries(settings.features)) {
|
||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
||||
}
|
||||
}
|
||||
|
||||
await updateAgentCommand({ agentManager }, { agentId, name, labels });
|
||||
@@ -3066,8 +3070,8 @@ function createToolCatalog(
|
||||
"List agent profiles: named provider/model/mode bundles a human configured for specific " +
|
||||
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
|
||||
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
|
||||
"`options` into create_agent as `settings.features` (there is no `profile` parameter). " +
|
||||
"Returns an empty list if none are configured.",
|
||||
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " +
|
||||
"list if none are configured.",
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
profiles: z.array(AgentProfileSchema),
|
||||
@@ -3120,13 +3124,13 @@ function createToolCatalog(
|
||||
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
|
||||
}
|
||||
const selectedModel = settings?.model ?? resolvedProviderModel.model;
|
||||
const { controls, options: effectiveOptions } = await agentManager.listDraftControls({
|
||||
const features = await agentManager.listDraftFeatures({
|
||||
provider: providerId,
|
||||
cwd: resolvedCwd,
|
||||
...(settings?.modeId ? { modeId: settings.modeId } : {}),
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
|
||||
...(settings?.features ? { providerOptions: settings.features } : {}),
|
||||
...(settings?.features ? { featureValues: settings.features } : {}),
|
||||
});
|
||||
return {
|
||||
content: [],
|
||||
@@ -3138,7 +3142,7 @@ function createToolCatalog(
|
||||
status: summary.status,
|
||||
modes: summary.modes,
|
||||
selectedModel: selectedModel ?? null,
|
||||
features: featuresFromControls(controls, effectiveOptions),
|
||||
features,
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -119,7 +119,6 @@ const INBOUND_PERMISSION = {
|
||||
list_available_providers_request: "daemon.read",
|
||||
list_commands_request: "daemon.read",
|
||||
list_provider_features_request: "daemon.read",
|
||||
"provider.controls.list.request": "daemon.read",
|
||||
list_provider_models_request: "daemon.read",
|
||||
list_provider_modes_request: "daemon.read",
|
||||
list_terminals_request: "workspace.read",
|
||||
@@ -357,7 +356,6 @@ const OUTBOUND_PERMISSION = {
|
||||
list_available_providers_response: "daemon.read",
|
||||
list_commands_response: "daemon.read",
|
||||
list_provider_features_response: "daemon.read",
|
||||
"provider.controls.list.response": "daemon.read",
|
||||
list_provider_models_response: "daemon.read",
|
||||
list_provider_modes_response: "daemon.read",
|
||||
list_terminals_response: "workspace.read",
|
||||
|
||||
@@ -180,56 +180,6 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => {
|
||||
const clients = createTestAgentClients();
|
||||
const codex = clients.codex as AgentClient;
|
||||
const applied: unknown[] = [];
|
||||
// Records what reaches the provider session; behavior is the fake client's.
|
||||
const recordingCodex: AgentClient = Object.create(codex, {
|
||||
createSession: {
|
||||
value: async (...args: Parameters<AgentClient["createSession"]>) => {
|
||||
const session = await codex.createSession(...args);
|
||||
const apply = session.applyOptions?.bind(session);
|
||||
session.applyOptions = async (options) => {
|
||||
applied.push(options);
|
||||
await apply?.(options);
|
||||
};
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { ...clients, codex: recordingCodex },
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: tmpCwd(),
|
||||
title: "Options agent",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
expect(agent.options?.sandbox_mode).toBeUndefined();
|
||||
|
||||
await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } });
|
||||
|
||||
expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]);
|
||||
const next = await client.fetchAgent(agent.id);
|
||||
expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" });
|
||||
expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
|
||||
@@ -3,11 +3,7 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
DaemonConfigStore,
|
||||
applyMutableProviderConfigToOverrides,
|
||||
toWireDaemonConfig,
|
||||
} from "./daemon-config-store.js";
|
||||
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js";
|
||||
import { loadConfig } from "./config.js";
|
||||
import { loadPersistedConfig } from "./persisted-config.js";
|
||||
import { readSpeechSettings } from "./speech/speech-settings.js";
|
||||
@@ -200,83 +196,13 @@ describe("DaemonConfigStore", () => {
|
||||
model: "claude-opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "think-hard",
|
||||
options: { webSearch: true },
|
||||
featureValues: { webSearch: true },
|
||||
notes: "Use for components, layout and design tokens.",
|
||||
},
|
||||
]);
|
||||
expect(store.get().agentProfiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("a profile written with featureValues is stored as options, and the config API answers with both", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
const store = new DaemonConfigStore(paseoHome, {
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: false },
|
||||
browserTools: { enabled: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
enableTerminalAgentHooks: false,
|
||||
appendSystemPrompt: "",
|
||||
});
|
||||
// The unchanged app sends back the `options` it read plus the `featureValues` it edited.
|
||||
await store.patch({
|
||||
agentProfiles: [
|
||||
{
|
||||
id: "p",
|
||||
name: "P",
|
||||
provider: "claude",
|
||||
options: { fast_mode: false },
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
|
||||
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
|
||||
]);
|
||||
expect(store.get().agentProfiles).toEqual([
|
||||
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
|
||||
]);
|
||||
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
|
||||
{
|
||||
id: "p",
|
||||
name: "P",
|
||||
provider: "claude",
|
||||
options: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
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" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("patch replaces the whole agent profile list rather than merging entries", async () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
@@ -1348,35 +1274,6 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().agentProfiles).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reload folds a profile file written with featureValues into options, and saving it persists options", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
daemon: {
|
||||
...persisted.daemon,
|
||||
agentProfiles: [
|
||||
{
|
||||
id: "legacy",
|
||||
name: "Legacy",
|
||||
provider: "codex",
|
||||
featureValues: { service_tier: "fast" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await store.reload();
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([
|
||||
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
|
||||
]);
|
||||
// The app reads the profile and writes the list back on its next edit.
|
||||
await store.patch({ agentProfiles: store.get().agentProfiles });
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
|
||||
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports a launch-controlled edit without changing live state", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
|
||||
@@ -5,11 +5,6 @@ import {
|
||||
type PersistedConfig,
|
||||
} from "./persisted-config.js";
|
||||
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
|
||||
import {
|
||||
foldProfileFeatureValues,
|
||||
withLegacyProfileFeatureValues,
|
||||
} from "./agent/option-controls.js";
|
||||
import type { AgentProfile } from "@getpaseo/protocol/agent-profile";
|
||||
import {
|
||||
MutableDaemonConfigSchema,
|
||||
MutableDaemonConfigPatchSchema,
|
||||
@@ -173,24 +168,6 @@ function omitProvidersFromOverrides(
|
||||
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
|
||||
}
|
||||
|
||||
/** Profiles are held and persisted with `options` only. */
|
||||
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(
|
||||
config: T,
|
||||
source: "stored" | "client write",
|
||||
): T {
|
||||
if (!config.agentProfiles) return config;
|
||||
return {
|
||||
...config,
|
||||
agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues(profile, source)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
|
||||
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
|
||||
if (!config.agentProfiles) return config;
|
||||
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
|
||||
}
|
||||
|
||||
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
|
||||
return path
|
||||
.split(".")
|
||||
@@ -403,7 +380,7 @@ export class DaemonConfigStore {
|
||||
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
|
||||
this.lastKnownPersisted = this.startupPersisted;
|
||||
this.current = MutableDaemonConfigSchema.parse({
|
||||
...foldConfigProfiles(initial, "stored"),
|
||||
...initial,
|
||||
relay: initial.relay ?? { enabled: true },
|
||||
...this.readSpeech(this.startupPersisted),
|
||||
});
|
||||
@@ -441,8 +418,7 @@ export class DaemonConfigStore {
|
||||
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
|
||||
);
|
||||
}
|
||||
const { removeProviders = [], ...submittedPatch } = parsedPatch;
|
||||
const patch = foldConfigProfiles(submittedPatch, "client write");
|
||||
const { removeProviders = [], ...patch } = parsedPatch;
|
||||
const { features: speechPatch, ...configPatch } = patch;
|
||||
const removedProviders = Array.from(new Set(removeProviders));
|
||||
const merged = deepMerge(this.current, configPatch);
|
||||
@@ -461,16 +437,7 @@ export class DaemonConfigStore {
|
||||
const configChanged = !isEqualValue(this.current, next);
|
||||
|
||||
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
|
||||
// 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
|
||||
) {
|
||||
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
@@ -528,7 +495,7 @@ export class DaemonConfigStore {
|
||||
// Plugin source changes require the plugin lifecycle operation or a daemon
|
||||
// restart. The global switch is independently reloadable.
|
||||
const desired = MutableDaemonConfigSchema.parse({
|
||||
...foldConfigProfiles(resolved.mutable, "stored"),
|
||||
...resolved.mutable,
|
||||
plugins: this.current.plugins,
|
||||
...this.readSpeech(persisted),
|
||||
});
|
||||
|
||||
@@ -86,10 +86,7 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
|
||||
cwd: response.payload.agent.cwd,
|
||||
});
|
||||
expect(JSON.stringify(response.payload.agent)).not.toContain(bearer);
|
||||
// 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("private-build");
|
||||
expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution");
|
||||
|
||||
const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update");
|
||||
@@ -107,6 +104,7 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
|
||||
cwd: update.payload.agent.cwd,
|
||||
});
|
||||
expect(JSON.stringify(update.payload.agent)).not.toContain(bearer);
|
||||
expect(JSON.stringify(update.payload.agent)).not.toContain("private-build");
|
||||
expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution");
|
||||
});
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export interface HubExecutionAgentCreateInput {
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
env?: Record<string, string>;
|
||||
@@ -192,6 +193,7 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
cwd: input.cwd,
|
||||
mode: input.modeId,
|
||||
thinking: input.thinkingOptionId,
|
||||
features: input.featureValues,
|
||||
env: input.env,
|
||||
...(input.mcpServers || input.providerOptions || input.toolPolicy
|
||||
? {
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from "../agent/provider-options.js";
|
||||
|
||||
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
|
||||
import { foldFeatureValues } from "../agent/option-controls.js";
|
||||
|
||||
interface HubExecutionControllerOptions {
|
||||
agents: HubExecutionAgents;
|
||||
@@ -168,8 +167,8 @@ export class HubExecutionController {
|
||||
model: message.model,
|
||||
modeId: message.modeId,
|
||||
thinkingOptionId: message.thinkingOptionId,
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
providerOptions: foldFeatureValues(message).providerOptions,
|
||||
featureValues: message.featureValues,
|
||||
providerOptions: message.providerOptions,
|
||||
toolPolicy: message.toolPolicy,
|
||||
env: message.env,
|
||||
mcpServers: message.mcpServers,
|
||||
|
||||
@@ -440,7 +440,7 @@ type AcceptedCreate = Omit<
|
||||
|
||||
const providerCatalog = {
|
||||
async resolveCreateConfig(input) {
|
||||
return { modeId: input.requestedMode, providerOptions: input.providerOptions };
|
||||
return { modeId: input.requestedMode, featureValues: input.featureValues };
|
||||
},
|
||||
} satisfies CreateAgentCommandDependencies["providerSnapshotManager"];
|
||||
|
||||
|
||||
@@ -73,6 +73,7 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||
model: record.config?.model ?? undefined,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||
featureValues: record.config?.featureValues ?? undefined,
|
||||
providerOptions: record.config?.providerOptions ?? undefined,
|
||||
toolPolicy: record.config?.toolPolicy ?? undefined,
|
||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||
@@ -94,6 +95,7 @@ export function buildSessionConfig(
|
||||
modeId: overrides.modeId,
|
||||
model: overrides.model,
|
||||
thinkingOptionId: overrides.thinkingOptionId,
|
||||
featureValues: overrides.featureValues,
|
||||
providerOptions: overrides.providerOptions,
|
||||
toolPolicy: overrides.toolPolicy,
|
||||
systemPrompt: overrides.systemPrompt,
|
||||
|
||||
@@ -88,7 +88,7 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
|
||||
async resolveCreateConfig(input) {
|
||||
return {
|
||||
modeId: input.unattended ? input.requestedMode : "interactive",
|
||||
providerOptions: input.providerOptions,
|
||||
featureValues: input.featureValues,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1911,7 +1911,7 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
return {
|
||||
modeId: input.unattended ? "bypassPermissions" : "interactive",
|
||||
providerOptions: input.providerOptions,
|
||||
featureValues: input.featureValues,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -1980,9 +1980,9 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
return {
|
||||
modeId: input.unattended ? "build" : "interactive",
|
||||
providerOptions: input.unattended
|
||||
? { ...input.providerOptions, auto_accept: true }
|
||||
: input.providerOptions,
|
||||
featureValues: input.unattended
|
||||
? { ...input.featureValues, auto_accept: true }
|
||||
: input.featureValues,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -2010,7 +2010,7 @@ describe("ScheduleService", () => {
|
||||
expect(inspected.runs[0]?.error).toBeNull();
|
||||
expect(createdConfigs[0]).toMatchObject({
|
||||
modeId: "build",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2052,7 +2052,7 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
return {
|
||||
modeId: input.requestedMode,
|
||||
providerOptions: { ...input.providerOptions, resolved: true },
|
||||
featureValues: { ...input.featureValues, resolved: true },
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -2099,13 +2099,11 @@ describe("ScheduleService", () => {
|
||||
model: "test-model",
|
||||
modeId: "stored-mode",
|
||||
thinkingOptionId: "think-hard",
|
||||
// Stored schedules written before options carry `featureValues`; launch folds them.
|
||||
providerOptions: {
|
||||
allowedTools: ["Read"],
|
||||
sandbox: { enabled: true, network: { allowLocalBinding: true } },
|
||||
auto_accept: true,
|
||||
resolved: true,
|
||||
},
|
||||
featureValues: { auto_accept: true, resolved: true },
|
||||
systemPrompt: "Stay concise.",
|
||||
mcpServers: {
|
||||
docs: {
|
||||
|
||||
@@ -15,7 +15,6 @@ import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
|
||||
import { ScheduleStore } from "./store.js";
|
||||
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
||||
import { foldFeatureValues } from "../agent/option-controls.js";
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
ScheduleExecutionResult,
|
||||
@@ -999,6 +998,7 @@ export class ScheduleService {
|
||||
},
|
||||
mode: config.modeId,
|
||||
thinking: config.thinkingOptionId,
|
||||
features: config.featureValues,
|
||||
unattended: true,
|
||||
promptFailure: "return-error",
|
||||
notifyOnFinish: false,
|
||||
@@ -1098,8 +1098,8 @@ function buildScheduleAgentConfig(
|
||||
model: config.model,
|
||||
thinkingOptionId: config.thinkingOptionId,
|
||||
title: config.title,
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
providerOptions: foldFeatureValues(config).providerOptions,
|
||||
providerOptions: config.providerOptions,
|
||||
featureValues: config.featureValues,
|
||||
systemPrompt: config.systemPrompt,
|
||||
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
|
||||
};
|
||||
|
||||
@@ -5816,7 +5816,7 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: liveAgentManager({ setAgentOptions: vi.fn().mockResolvedValue(undefined) }),
|
||||
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -5840,7 +5840,7 @@ describe("agent config setters", () => {
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: liveAgentManager({
|
||||
setAgentOptions: vi.fn().mockRejectedValue(new Error("feature boom")),
|
||||
setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
createWorkspaceScriptsService,
|
||||
type WorkspaceScriptsService,
|
||||
} from "./session/workspace-scripts/workspace-scripts-service.js";
|
||||
import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
@@ -1036,7 +1036,7 @@ export class Session {
|
||||
supportsProviderSnapshotReferences: () =>
|
||||
this.supports(CLIENT_CAPS.providerSnapshotReferences),
|
||||
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
||||
listDraftControls: (config) => this.agentManager.listDraftControls(config),
|
||||
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
|
||||
},
|
||||
providerSnapshotManager,
|
||||
logger: this.sessionLogger,
|
||||
@@ -1066,7 +1066,8 @@ export class Session {
|
||||
setMode: async (agentId, modeId) =>
|
||||
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
|
||||
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
|
||||
setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
|
||||
setFeature: (agentId, featureId, value) =>
|
||||
agentManager.setAgentFeature(agentId, featureId, value),
|
||||
setThinking: (agentId, thinkingOptionId) =>
|
||||
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
|
||||
},
|
||||
@@ -2949,10 +2950,7 @@ export class Session {
|
||||
case "get_daemon_config_request":
|
||||
this.emit({
|
||||
type: "get_daemon_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
config: toWireDaemonConfig(this.daemonConfigStore.get()),
|
||||
},
|
||||
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
|
||||
});
|
||||
return undefined;
|
||||
case "daemon.get_status.request":
|
||||
@@ -2976,7 +2974,7 @@ export class Session {
|
||||
type: "set_daemon_config_response",
|
||||
payload: {
|
||||
requestId: msg.requestId,
|
||||
config: toWireDaemonConfig(config),
|
||||
config,
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -3183,8 +3181,6 @@ export class Session {
|
||||
return this.providerCatalogSession.handleListProviderModesRequest(msg);
|
||||
case "list_provider_features_request":
|
||||
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
|
||||
case "provider.controls.list.request":
|
||||
return this.providerCatalogSession.handleProviderControlsListRequest(msg);
|
||||
case "list_available_providers_request":
|
||||
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
|
||||
case "get_providers_snapshot_request":
|
||||
|
||||
@@ -12,7 +12,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
|
||||
readonly loadedAgentIds: string[] = [];
|
||||
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
|
||||
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
|
||||
readonly optionsCalls: Array<{ agentId: string; patch: Record<string, unknown> }> = [];
|
||||
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
|
||||
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
|
||||
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
|
||||
readonly callLog: string[] = [];
|
||||
@@ -39,9 +39,9 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
|
||||
if (this.failWith) throw this.failWith;
|
||||
}
|
||||
|
||||
async setOptions(agentId: string, patch: Record<string, unknown>): Promise<void> {
|
||||
this.optionsCalls.push({ agentId, patch });
|
||||
this.callLog.push("options");
|
||||
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
this.featureCalls.push({ agentId, featureId, value });
|
||||
this.callLog.push(`feature:${featureId}`);
|
||||
if (this.failWith) throw this.failWith;
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("AgentConfigSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("set feature: maps the feature value to a one-key options patch and emits an accepted response with no notice", async () => {
|
||||
test("set feature: forwards the feature value and emits an accepted response with no notice", async () => {
|
||||
const { subsystem, emitted, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleSetAgentFeatureRequest({
|
||||
@@ -217,7 +217,9 @@ describe("AgentConfigSession", () => {
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]);
|
||||
expect(operations.featureCalls).toEqual([
|
||||
{ agentId: "agent-1", featureId: "web_search", value: true },
|
||||
]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "set_agent_feature_response",
|
||||
@@ -307,7 +309,7 @@ describe("AgentConfigSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("apply: sets model before thinking and options last, in one accepted response", async () => {
|
||||
test("apply: sets model before thinking and features last, in one accepted response", async () => {
|
||||
const { subsystem, emitted, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
@@ -317,16 +319,18 @@ describe("AgentConfigSession", () => {
|
||||
modelId: "opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "high",
|
||||
options: { webSearch: true },
|
||||
featureValues: { webSearch: true },
|
||||
},
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.callLog).toEqual(["model", "mode", "thinking", "options"]);
|
||||
expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]);
|
||||
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
|
||||
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
|
||||
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
|
||||
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { webSearch: true } }]);
|
||||
expect(operations.featureCalls).toEqual([
|
||||
{ agentId: "agent-1", featureId: "webSearch", value: true },
|
||||
]);
|
||||
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
@@ -342,39 +346,6 @@ describe("AgentConfigSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply: passes an options patch through, null included, in one call", async () => {
|
||||
const { subsystem, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
type: "agent.config.apply.request",
|
||||
agentId: "agent-1",
|
||||
config: { options: { fast_mode: true, service_tier: null } },
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.optionsCalls).toEqual([
|
||||
{ agentId: "agent-1", patch: { fast_mode: true, service_tier: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply: folds legacy featureValues into the options patch, options winning", async () => {
|
||||
const { subsystem, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
type: "agent.config.apply.request",
|
||||
agentId: "agent-1",
|
||||
config: {
|
||||
featureValues: { fast_mode: true, service_tier: "fast" },
|
||||
options: { fast_mode: false },
|
||||
},
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.optionsCalls).toEqual([
|
||||
{ agentId: "agent-1", patch: { fast_mode: false, service_tier: "fast" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply: leaves omitted fields alone", async () => {
|
||||
const { subsystem, operations } = makeSubsystem();
|
||||
|
||||
@@ -410,11 +381,11 @@ describe("AgentConfigSession", () => {
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
type: "agent.config.apply.request",
|
||||
agentId: "agent-1",
|
||||
config: { modeId: "plan", thinkingOptionId: "high", options: { webSearch: true } },
|
||||
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } },
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.callLog).toEqual(["mode", "thinking", "options"]);
|
||||
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "agent.config.apply.response",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
|
||||
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
|
||||
import type { ProviderOptionsPatch } from "../../agent/option-controls.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
|
||||
/**
|
||||
@@ -23,13 +22,13 @@ export interface AgentConfigSessionHost {
|
||||
* The per-agent config mutations this subsystem drives. The shell adapts these
|
||||
* onto the AgentManager and loads a closed agent before mutation (mode still
|
||||
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
|
||||
* thinking yield a provider notice; model and options do not.
|
||||
* thinking yield a provider notice; model and feature do not.
|
||||
*/
|
||||
export interface AgentConfigOperations {
|
||||
ensureLoaded(agentId: string): Promise<void>;
|
||||
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
|
||||
setModel(agentId: string, modelId: string | null): Promise<void>;
|
||||
setOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void>;
|
||||
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>;
|
||||
setThinking(
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null,
|
||||
@@ -113,9 +112,8 @@ export class AgentConfigSession {
|
||||
logLabel: "set_agent_feature_request",
|
||||
logFields: { agentId, featureId, value, requestId },
|
||||
failureText: "Failed to set agent feature",
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A feature is a one-key options patch.
|
||||
run: async () => {
|
||||
await this.operations.setOptions(agentId, { [featureId]: value });
|
||||
await this.operations.setFeature(agentId, featureId, value);
|
||||
return undefined;
|
||||
},
|
||||
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
|
||||
@@ -154,7 +152,7 @@ export class AgentConfigSession {
|
||||
|
||||
/**
|
||||
* Model first, because a thinking option only means anything against the model
|
||||
* that offers it; options last so they settle on top of the rest. An omitted
|
||||
* that offers it; features last so they settle on top of the rest. An omitted
|
||||
* field is left alone. The first notice produced is the one the client sees —
|
||||
* later steps still run, so a notice never truncates the bundle.
|
||||
*
|
||||
@@ -191,10 +189,8 @@ export class AgentConfigSession {
|
||||
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
|
||||
notice ??= thinkingNotice;
|
||||
}
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch.
|
||||
const optionsPatch = { ...config.featureValues, ...config.options };
|
||||
if (Object.keys(optionsPatch).length > 0) {
|
||||
await this.operations.setOptions(agentId, optionsPatch);
|
||||
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) {
|
||||
await this.operations.setFeature(agentId, featureId, value);
|
||||
}
|
||||
|
||||
return notice;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type ProviderSnapshot,
|
||||
type ProviderSnapshotTransition,
|
||||
} from "../../agent/provider-snapshot-manager.js";
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
|
||||
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
|
||||
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
|
||||
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
|
||||
@@ -58,7 +58,7 @@ function makeSubsystem(options: MakeOptions = {}) {
|
||||
},
|
||||
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
|
||||
listProviderAvailability: async () => [],
|
||||
listDraftControls: async () => ({ controls: [], options: {} }),
|
||||
listDraftFeatures: async () => [],
|
||||
...options.host,
|
||||
};
|
||||
const providerSnapshotManager = createStub<ProviderSnapshotManager>({
|
||||
@@ -296,100 +296,10 @@ describe("ProviderCatalogSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists controls and effective options for a draft config", async () => {
|
||||
const drafts: AgentSessionConfig[] = [];
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftControls: async (config) => {
|
||||
drafts.push(config);
|
||||
return {
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: true },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleProviderControlsListRequest({
|
||||
type: "provider.controls.list.request",
|
||||
requestId: "c1",
|
||||
draftConfig: {
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(drafts).toEqual([
|
||||
{
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
expect(findByType(emitted, "provider.controls.list.response")?.payload).toEqual({
|
||||
provider: "claude",
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: true },
|
||||
error: null,
|
||||
fetchedAt: expect.any(String),
|
||||
requestId: "c1",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a controls failure inline, not as an rpc_error", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftControls: async () => {
|
||||
throw new Error("controls probe failed");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleProviderControlsListRequest({
|
||||
type: "provider.controls.list.request",
|
||||
requestId: "c2",
|
||||
draftConfig: { provider: "codex", cwd: "/tmp/project" },
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
expect(findByType(emitted, "provider.controls.list.response")?.payload.error).toBe(
|
||||
"controls probe failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers list_provider_features_request from controls, folding legacy featureValues", async () => {
|
||||
const drafts: AgentSessionConfig[] = [];
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftControls: async (config) => {
|
||||
drafts.push(config);
|
||||
return {
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: true },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleListProviderFeaturesRequest({
|
||||
type: "list_provider_features_request",
|
||||
requestId: "f0",
|
||||
draftConfig: { provider: "claude", cwd: "/tmp/project", featureValues: { fast_mode: true } },
|
||||
});
|
||||
|
||||
expect(drafts[0]?.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(findByType(emitted, "list_provider_features_response")?.payload.features).toEqual([
|
||||
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces a feature-list failure inline, not as an rpc_error", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftControls: async () => {
|
||||
listDraftFeatures: async () => {
|
||||
throw new Error("feature probe failed");
|
||||
},
|
||||
},
|
||||
@@ -469,7 +379,7 @@ it("announces shared content without retransmitting models or hashing discovery
|
||||
},
|
||||
supportsProviderSnapshotReferences: () => references,
|
||||
listProviderAvailability: async () => [],
|
||||
listDraftControls: async () => ({ controls: [], options: {} }),
|
||||
listDraftFeatures: async () => [],
|
||||
},
|
||||
});
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
|
||||
@@ -13,14 +13,12 @@ import {
|
||||
} from "../../agent/provider-snapshot-manager.js";
|
||||
import {
|
||||
filterSelectableAgentModels,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentProvider,
|
||||
type AgentSessionConfig,
|
||||
type ProviderSnapshotEntry,
|
||||
} from "../../agent/agent-sdk-types.js";
|
||||
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
||||
import { featuresFromControls, foldFeatureValues } from "../../agent/option-controls.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { expandTilde } from "../../../utils/path.js";
|
||||
|
||||
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
|
||||
@@ -51,9 +49,7 @@ export interface ProviderCatalogSessionHost {
|
||||
supportsProviderSnapshotReferences(): boolean;
|
||||
publishSnapshot(project: () => SessionOutboundMessage | null): void;
|
||||
listProviderAvailability(): Promise<ProviderAvailability[]>;
|
||||
listDraftControls(
|
||||
config: AgentSessionConfig,
|
||||
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }>;
|
||||
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||
}
|
||||
|
||||
export interface ProviderCatalogSessionOptions {
|
||||
@@ -344,59 +340,29 @@ export class ProviderCatalogSession {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: ProviderOptions;
|
||||
}): AgentSessionConfig {
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
const { providerOptions } = foldFeatureValues(draftConfig);
|
||||
return {
|
||||
provider: draftConfig.provider,
|
||||
cwd: expandTilde(draftConfig.cwd),
|
||||
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
|
||||
...(draftConfig.model ? { model: draftConfig.model } : {}),
|
||||
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async handleProviderControlsListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "provider.controls.list.request" }>,
|
||||
): Promise<void> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
const provider = msg.draftConfig.provider;
|
||||
try {
|
||||
const { controls, options } = await this.host.listDraftControls(
|
||||
this.buildDraftAgentSessionConfig(msg.draftConfig),
|
||||
);
|
||||
this.host.emit({
|
||||
type: "provider.controls.list.response",
|
||||
payload: { provider, controls, options, error: null, fetchedAt, requestId: msg.requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ err: error, provider, draftConfig: msg.draftConfig },
|
||||
`Failed to list controls for ${provider}`,
|
||||
);
|
||||
this.host.emit({
|
||||
type: "provider.controls.list.response",
|
||||
payload: { provider, error: getErrorMessage(error), fetchedAt, requestId: msg.requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Answered from controls.
|
||||
async handleListProviderFeaturesRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
|
||||
): Promise<void> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
try {
|
||||
const { controls, options } = await this.host.listDraftControls(
|
||||
this.buildDraftAgentSessionConfig(msg.draftConfig),
|
||||
);
|
||||
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
|
||||
const features = await this.host.listDraftFeatures(sessionConfig);
|
||||
this.host.emit({
|
||||
type: "list_provider_features_response",
|
||||
payload: {
|
||||
provider: msg.draftConfig.provider,
|
||||
features: featuresFromControls(controls, options),
|
||||
features,
|
||||
error: null,
|
||||
fetchedAt,
|
||||
requestId: msg.requestId,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
||||
@@ -7,7 +6,7 @@ import path from "node:path";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -380,13 +379,14 @@ class FakeAgentSession implements AgentSession {
|
||||
return this.providerName;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
get features(): AgentFeature[] {
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
key: TEST_FEATURE_ID,
|
||||
id: TEST_FEATURE_ID,
|
||||
label: "Test feature",
|
||||
description: "Deterministic provider feature used by MCP integration tests.",
|
||||
value: this.config.featureValues?.[TEST_FEATURE_ID] === true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -873,8 +873,11 @@ class FakeAgentSession implements AgentSession {
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
this.config.providerOptions = { ...this.config.providerOptions, ...options };
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[featureId]: value,
|
||||
};
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
|
||||
@@ -223,7 +223,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
const listModes = vi.fn<ProviderSnapshotManager["listModes"]>(async () => []);
|
||||
const resolveCreateConfig = vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>(async () => ({
|
||||
modeId: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
}));
|
||||
const resolveDefaultModel = vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>(
|
||||
async () => undefined,
|
||||
|
||||
@@ -2007,7 +2007,6 @@ export class VoiceAssistantWebSocketServer {
|
||||
usageSetting: true,
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
providerOptionControls: true,
|
||||
binaryChunkingV1: true,
|
||||
agentRequestReceipts: true,
|
||||
workspaceRequestReceipts: true,
|
||||
|
||||
@@ -176,13 +176,6 @@
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"options": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {}
|
||||
},
|
||||
"notes": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -680,51 +680,46 @@ test("macOS jobs use latest runners while preserving release architectures", ()
|
||||
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
|
||||
});
|
||||
|
||||
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
|
||||
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
|
||||
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
|
||||
assert.ok(match, "the release workflow repairs the pinned signing tool");
|
||||
const script = match[1].replace(/^ /gm, "");
|
||||
const original = [
|
||||
"return await importCerts(keychainFile, certPaths, cscPasswords);",
|
||||
"async function importCerts(keychainFile, paths, keyPasswords) {",
|
||||
'["import", paths[i], "-P", password]',
|
||||
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
|
||||
"}",
|
||||
].join("\n");
|
||||
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
|
||||
test("Nix desktop packaging resolves the workspace-local builder", () => {
|
||||
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
|
||||
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
|
||||
assert.ok(invocation, "Nix invokes the desktop packaging tool");
|
||||
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
|
||||
try {
|
||||
const packageDirectory = join(directory, "node_modules/app-builder-lib");
|
||||
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
|
||||
const manifest = join(packageDirectory, "package.json");
|
||||
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
|
||||
for (const [version, source, succeeds] of [
|
||||
["26.8.1", original, true],
|
||||
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
|
||||
["26.18.0", original, false],
|
||||
]) {
|
||||
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
|
||||
writeFileSync(sourcePath, source);
|
||||
const result = spawnSync(process.execPath, ["-e", script], {
|
||||
cwd: directory,
|
||||
encoding: "utf8",
|
||||
});
|
||||
const actual = readFileSync(sourcePath, "utf8");
|
||||
if (succeeds) {
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(
|
||||
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
|
||||
);
|
||||
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
|
||||
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
|
||||
} else {
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
|
||||
}
|
||||
}
|
||||
const desktop = join(directory, "packages/desktop");
|
||||
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
|
||||
mkdirSync(join(desktop, "node_modules/electron-builder"));
|
||||
writeFileSync(
|
||||
join(desktop, "node_modules/electron-builder/package.json"),
|
||||
JSON.stringify({
|
||||
name: "electron-builder",
|
||||
version: "1.0.0",
|
||||
bin: { "electron-builder": "../.bin/electron-builder" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(directory, "package.json"),
|
||||
JSON.stringify({ private: true, workspaces: ["packages/desktop"] }),
|
||||
);
|
||||
writeFileSync(
|
||||
join(desktop, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "desktop-fixture",
|
||||
private: true,
|
||||
devDependencies: { "electron-builder": "1.0.0" },
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
join(desktop, "node_modules/.bin/electron-builder"),
|
||||
'#!/bin/sh\nprintf "workspace-builder\\n"\n',
|
||||
{ mode: 0o755 },
|
||||
);
|
||||
const result = spawnSync("sh", ["-c", `${invocation} --version`], {
|
||||
cwd: desktop,
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout.trim(), "workspace-builder");
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user