mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
feat(server): fold featureValues into providerOptions and describe composer controls
Providers emit option controls over top-level providerOptions keys; the daemon stores one options record per agent, merges config.json defaults as effective values, and applies patches live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles, schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from controls. Snapshot options expose only keys that have a control.
This commit is contained in:
@@ -23,6 +23,7 @@ import { AgentStorage } from "./agent-storage.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { startAgentRun } from "./agent-prompt.js";
|
||||
@@ -38,7 +39,7 @@ import type {
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentLaunchContext,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
@@ -182,13 +183,8 @@ class RecordingTimelineStore implements AgentTimelineStore {
|
||||
}
|
||||
}
|
||||
|
||||
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: args.id,
|
||||
label: args.label,
|
||||
value: args.value,
|
||||
};
|
||||
function createControl(args: { key: string; label: string }): AgentOptionControl {
|
||||
return { type: "toggle", key: args.key, label: args.label };
|
||||
}
|
||||
|
||||
function expectArchivedAgentRecord(
|
||||
@@ -2332,7 +2328,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
|
||||
]);
|
||||
});
|
||||
|
||||
test("listDraftFeatures does not start a fallback session without a model", async () => {
|
||||
test("listDraftControls does not start a fallback session without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -2365,18 +2361,21 @@ test("listDraftFeatures does not start a fallback session without a model", asyn
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
||||
await expect(manager.listDraftControls({ provider: "codex", cwd: workdir })).resolves.toEqual({
|
||||
controls: [],
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.availabilityCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("listDraftFeatures uses client feature listing without a model", async () => {
|
||||
test("listDraftControls uses client feature listing without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false });
|
||||
const draftControl = createControl({ key: "auto_accept", label: "Auto Accept" });
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
@@ -2398,9 +2397,9 @@ test("listDraftFeatures uses client feature listing without a model", async () =
|
||||
return await super.createSession(config);
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
this.featureConfigs.push(config);
|
||||
return [draftFeature];
|
||||
return [draftControl];
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
@@ -2412,9 +2411,13 @@ test("listDraftFeatures uses client feature listing without a model", async () =
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
|
||||
draftFeature,
|
||||
]);
|
||||
await expect(
|
||||
manager.listDraftControls({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true },
|
||||
}),
|
||||
).resolves.toEqual({ controls: [draftControl], options: { auto_accept: true } });
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
@@ -2423,15 +2426,16 @@ test("listDraftFeatures uses client feature listing without a model", async () =
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("listDraftFeatures uses explicit model config without default model fetching", async () => {
|
||||
test("listDraftControls uses explicit model config without default model fetching", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false });
|
||||
const draftControl = createControl({ key: "fast_mode", label: "Fast mode" });
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
@@ -2447,9 +2451,9 @@ test("listDraftFeatures uses explicit model config without default model fetchin
|
||||
return await super.createSession(config);
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
this.featureConfigs.push(config);
|
||||
return [draftFeature];
|
||||
return [draftControl];
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
@@ -2461,13 +2465,13 @@ test("listDraftFeatures uses explicit model config without default model fetchin
|
||||
logger,
|
||||
});
|
||||
|
||||
const features = await manager.listDraftFeatures({
|
||||
const draft = await manager.listDraftControls({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(features).toEqual([draftFeature]);
|
||||
expect(draft).toEqual({ controls: [draftControl], options: {} });
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.featureConfigs).toEqual([
|
||||
@@ -9984,16 +9988,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
expect(client.resumedModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||
test("respondToPermission applies provider option changes and refreshes runtime info after provider-managed plan approval", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class RefreshingPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
||||
];
|
||||
private planMode = true;
|
||||
private modeId = "auto";
|
||||
private pending = [
|
||||
{
|
||||
@@ -10005,8 +10006,11 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
||||
},
|
||||
];
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
createControl({ key: "fast_mode", label: "Fast" }),
|
||||
createControl({ key: "plan_mode", label: "Plan" }),
|
||||
];
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
@@ -10015,7 +10019,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
||||
sessionId: this.id,
|
||||
model: "gpt-5.4",
|
||||
modeId: this.modeId,
|
||||
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
|
||||
extra: { collaborationMode: this.planMode ? "Plan" : "Code" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10030,10 +10034,12 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.modeId = "auto";
|
||||
this.pending = [];
|
||||
this.featureState = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
];
|
||||
this.planMode = false;
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { plan_mode: false },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10056,6 +10062,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { fast_mode: true, plan_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
@@ -10080,20 +10087,14 @@ test("respondToPermission refreshes features and runtime info after provider-man
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.pendingPermissions.size).toBe(0);
|
||||
expect(updated?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
expect(updated?.effectiveOptions).toEqual({ fast_mode: true, plan_mode: false });
|
||||
expect(updated?.runtimeInfo).toMatchObject({
|
||||
model: "gpt-5.4",
|
||||
extra: { collaborationMode: "Code" },
|
||||
});
|
||||
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true, plan_mode: false });
|
||||
});
|
||||
|
||||
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
||||
@@ -10102,9 +10103,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class OrderedPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
];
|
||||
private modeId = "plan";
|
||||
private pending = [
|
||||
{
|
||||
@@ -10116,8 +10114,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
},
|
||||
];
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [createControl({ key: "fast_mode", label: "Fast" })];
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
@@ -10138,6 +10136,11 @@ 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,
|
||||
@@ -10145,7 +10148,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
this.modeId = "acceptEdits";
|
||||
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
||||
this.pending = [];
|
||||
}
|
||||
}
|
||||
@@ -10169,6 +10171,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
@@ -10180,9 +10183,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
return;
|
||||
}
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
||||
seen.push(
|
||||
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
|
||||
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.fast_mode)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -12695,3 +12697,114 @@ 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 });
|
||||
const payload = toAgentPayload(agent!);
|
||||
expect(payload.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 });
|
||||
|
||||
expect(client.session?.applied).toEqual([{ service_tier: "default" }]);
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toBeUndefined();
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
|
||||
});
|
||||
|
||||
test("a 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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentResumePurpose,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentSlashCommand,
|
||||
type AgentMode,
|
||||
@@ -59,7 +59,13 @@ 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,
|
||||
foldFeatureValues,
|
||||
type ProviderOptionsPatch,
|
||||
} from "./option-controls.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentOwner } from "./agent-owner.js";
|
||||
import {
|
||||
@@ -210,9 +216,6 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (record.config.thinkingOptionId != null) {
|
||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||
}
|
||||
if (record.config.featureValues != null) {
|
||||
config.featureValues = record.config.featureValues;
|
||||
}
|
||||
if (record.config.providerOptions != null) {
|
||||
config.providerOptions = record.config.providerOptions;
|
||||
}
|
||||
@@ -428,7 +431,9 @@ interface ManagedAgentBase {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
availableModes: AgentMode[];
|
||||
features?: AgentFeature[];
|
||||
controls?: AgentOptionControl[];
|
||||
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
|
||||
effectiveOptions?: ProviderOptions;
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
bufferedPermissionResolutions: Map<
|
||||
@@ -1243,11 +1248,14 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
async listDraftControls(
|
||||
config: AgentSessionConfig,
|
||||
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
|
||||
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
if (!normalizedConfig.model && !client.listFeatures) {
|
||||
return [];
|
||||
const options = this.effectiveOptionsFor(normalizedConfig.provider, config.providerOptions);
|
||||
if (!normalizedConfig.model && !client.listControls) {
|
||||
return { controls: [], options };
|
||||
}
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
@@ -1256,13 +1264,13 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (client.listFeatures) {
|
||||
return await client.listFeatures(normalizedConfig);
|
||||
if (client.listControls) {
|
||||
return { controls: await client.listControls(normalizedConfig), options };
|
||||
}
|
||||
|
||||
const session = await client.createSession(normalizedConfig);
|
||||
try {
|
||||
return session.features ?? [];
|
||||
return { controls: session.controls ?? [], options };
|
||||
} finally {
|
||||
try {
|
||||
await session.close();
|
||||
@@ -1365,7 +1373,9 @@ export class AgentManager {
|
||||
config,
|
||||
env: options.env,
|
||||
});
|
||||
config = { ...request.config, internal: config.internal };
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||
// against the previous SDK edits `config.featureValues`.
|
||||
config = { ...foldFeatureValues(request.config), internal: config.internal };
|
||||
options = { ...options, env: request.env };
|
||||
}
|
||||
await this.deleteAgentState(resolvedAgentId);
|
||||
@@ -2161,7 +2171,7 @@ export class AgentManager {
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt,
|
||||
availableModes: [],
|
||||
features: record.features,
|
||||
controls: record.controls,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
@@ -2254,20 +2264,31 @@ export class AgentManager {
|
||||
return notice;
|
||||
}
|
||||
|
||||
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
/**
|
||||
* Patches the agent's own options and hands the provider the resulting effective options. The
|
||||
* provider applies what it can live and reads the rest at next open; a provider that rejects a
|
||||
* value leaves the stored options unchanged.
|
||||
*/
|
||||
async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch);
|
||||
|
||||
if (!agent.session.setFeature) {
|
||||
throw new Error("Agent session does not support setting features");
|
||||
if (agent.session.applyOptions) {
|
||||
await agent.session.applyOptions(this.effectiveOptionsFor(agent.provider, ownOptions));
|
||||
await this.drainSessionEvents(agentId);
|
||||
}
|
||||
|
||||
await agent.session.setFeature(featureId, value);
|
||||
await this.drainSessionEvents(agentId);
|
||||
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
|
||||
agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined;
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
private effectiveOptionsFor(
|
||||
provider: AgentProvider,
|
||||
own: ProviderOptions | undefined,
|
||||
): ProviderOptions {
|
||||
return this.clients.get(provider)?.effectiveOptions?.(own) ?? own ?? {};
|
||||
}
|
||||
|
||||
async setTitle(agentId: string, title: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const normalizedTitle = title.trim();
|
||||
@@ -4311,7 +4332,7 @@ export class AgentManager {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
this.syncFeaturesFromSession(agent);
|
||||
this.syncControls(agent);
|
||||
await this.refreshRuntimeInfo(agent, options);
|
||||
}
|
||||
|
||||
@@ -4657,6 +4678,18 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
private onStreamOptionsChanged(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
patch: ProviderOptionsPatch;
|
||||
flags: StreamEventFlags;
|
||||
}): void {
|
||||
const { agent, patch, flags } = params;
|
||||
const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch);
|
||||
agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined;
|
||||
flags.shouldDispatchEvent = false;
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
private dispatchStreamEventByType(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: AgentStreamEvent;
|
||||
@@ -4708,6 +4741,9 @@ export class AgentManager {
|
||||
flags.shouldDispatchEvent = false;
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
case "options_changed":
|
||||
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
|
||||
return undefined;
|
||||
case "timeline":
|
||||
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
||||
case "turn_completed":
|
||||
@@ -5183,7 +5219,7 @@ export class AgentManager {
|
||||
this.enqueueBackgroundPersist(agent);
|
||||
}
|
||||
|
||||
this.syncFeaturesFromSession(agent);
|
||||
this.syncControls(agent);
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -5205,10 +5241,11 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private syncFeaturesFromSession(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
private syncControls(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.controls) {
|
||||
agent.controls = agent.session.controls;
|
||||
}
|
||||
agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
|
||||
}
|
||||
|
||||
private checkAndSetAttention(agent: ManagedAgent): void {
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "./agent-projections.js";
|
||||
import type { AgentSession } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
ImportableProviderSession,
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
@@ -128,16 +128,6 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toStoredAgentRecord", () => {
|
||||
it("captures lifecycle metadata, config, and persistence", () => {
|
||||
const agent = createManagedAgent({
|
||||
@@ -474,13 +464,44 @@ describe("toAgentPayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("includes features in the snapshot payload", () => {
|
||||
const features = [createFeature()];
|
||||
const agent = createManagedAgent({ features });
|
||||
it("projects controls, effective options, and legacy features from the same source", () => {
|
||||
const controls: AgentOptionControl[] = [
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
];
|
||||
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
|
||||
// Options without a control (sandbox paths, tool lists) never reach the snapshot.
|
||||
const agent = createManagedAgent({
|
||||
controls,
|
||||
effectiveOptions: { fast_mode: true, sandbox_workspace_write: { writable_roots: ["/x"] } },
|
||||
});
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.features).toEqual(features);
|
||||
expect(payload.controls).toEqual(controls);
|
||||
expect(payload.options).toEqual({ fast_mode: true });
|
||||
expect(payload.features).toEqual([
|
||||
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
value: "default",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
@@ -18,7 +18,9 @@ import type {
|
||||
AgentUsage,
|
||||
ImportableProviderSession,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import { featuresFromControls } from "./option-controls.js";
|
||||
import type { JsonValue } from "../json-utils.js";
|
||||
import {
|
||||
isStoredAgentProviderAvailable,
|
||||
@@ -97,7 +99,7 @@ export function toStoredAgentRecord(
|
||||
config: config ?? null,
|
||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||
runtimeInfo,
|
||||
features: normalizeFeatures(agent.features),
|
||||
controls: normalizeControls(agent.controls),
|
||||
persistence,
|
||||
lastError: agent.lastError ?? undefined,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
@@ -143,7 +145,12 @@ export function toAgentPayload(
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
features: normalizeFeatures(agent.features),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
features: featuresFromControls(agent.controls, agent.effectiveOptions),
|
||||
...(agent.effectiveOptions
|
||||
? { options: controlledOptions(agent.effectiveOptions, agent.controls) }
|
||||
: {}),
|
||||
controls: normalizeControls(agent.controls),
|
||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
@@ -375,12 +382,6 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
||||
if (config.thinkingOptionId) {
|
||||
serializable.thinkingOptionId = config.thinkingOptionId;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
|
||||
const featureValues = sanitizeMetadata(config.featureValues);
|
||||
if (featureValues !== undefined) {
|
||||
serializable.featureValues = featureValues;
|
||||
}
|
||||
}
|
||||
if (config.providerOptions !== undefined) {
|
||||
const providerOptions = sanitizeOptionalJson(config.providerOptions);
|
||||
if (providerOptions && isJsonObject(providerOptions)) {
|
||||
@@ -456,8 +457,26 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
|
||||
return modes.map((mode) => ({ ...mode }));
|
||||
}
|
||||
|
||||
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
|
||||
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
|
||||
function normalizeControls(
|
||||
controls: AgentOptionControl[] | null | undefined,
|
||||
): AgentOptionControl[] {
|
||||
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
|
||||
}
|
||||
|
||||
// Snapshots expose only the options a control describes. Other provider options (sandbox paths,
|
||||
// allowed tools, hub-supplied settings) stay daemon-side.
|
||||
function controlledOptions(
|
||||
options: ProviderOptions,
|
||||
controls: AgentOptionControl[] | null | undefined,
|
||||
): ProviderOptions {
|
||||
const exposed: ProviderOptions = {};
|
||||
for (const control of controls ?? []) {
|
||||
const value = sanitizeOptionalJson(options[control.key]);
|
||||
if (value !== undefined) {
|
||||
exposed[control.key] = value;
|
||||
}
|
||||
}
|
||||
return exposed;
|
||||
}
|
||||
|
||||
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
JsonValue,
|
||||
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type {
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
};
|
||||
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
|
||||
export interface ResolveAgentCreateConfigInput {
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
parent: AgentCreateConfigParent | null;
|
||||
unattended: boolean;
|
||||
availableModes: AgentMode[] | undefined;
|
||||
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
|
||||
|
||||
export interface ResolveAgentCreateConfigResult {
|
||||
modeId: string | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
}
|
||||
|
||||
export interface AgentCreateConfigUnattendedInput {
|
||||
modeId: string | null;
|
||||
config: AgentSessionConfig;
|
||||
features?: AgentFeature[];
|
||||
controls?: AgentOptionControl[];
|
||||
availableModes: AgentMode[];
|
||||
}
|
||||
|
||||
@@ -429,6 +429,8 @@ 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;
|
||||
@@ -610,7 +612,6 @@ export interface AgentSessionConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
@@ -679,7 +680,8 @@ export interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly features?: AgentFeature[];
|
||||
/** Composer controls over top-level option keys. Values come from the agent's effective options. */
|
||||
readonly controls?: AgentOptionControl[];
|
||||
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
@@ -714,7 +716,12 @@ export interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
/**
|
||||
* 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>;
|
||||
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||
revertBoth?(input: { messageId: string }): Promise<void>;
|
||||
@@ -797,7 +804,9 @@ export interface AgentClient {
|
||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||
listControls?(config: AgentSessionConfig): Promise<AgentOptionControl[]>;
|
||||
/** Effective options: provider defaults from `config.json` deep-merged with the agent's own. */
|
||||
effectiveOptions?(own: ProviderOptions | undefined): ProviderOptions;
|
||||
listImportableSessions?(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
@@ -44,9 +44,6 @@ function buildManagedAgentConfig(
|
||||
systemPrompt: configOverrides.systemPrompt,
|
||||
mcpServers: configOverrides.mcpServers,
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
||||
config.featureValues = configOverrides.featureValues;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -132,6 +129,14 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
};
|
||||
}
|
||||
|
||||
function findRecordFile(baseDir: string, agentId: string): string {
|
||||
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
||||
const candidate = path.join(baseDir, entry.name, `${agentId}.json`);
|
||||
if (entry.isDirectory() && existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`No stored record for ${agentId}`);
|
||||
}
|
||||
|
||||
describe("AgentStorage", () => {
|
||||
let tmpDir: string;
|
||||
let storagePath: string;
|
||||
@@ -195,62 +200,77 @@ describe("AgentStorage", () => {
|
||||
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
|
||||
});
|
||||
|
||||
test("applySnapshot stores and reloads featureValues when present", async () => {
|
||||
test("applySnapshot stores and reloads composer values as providerOptions", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-feature-values",
|
||||
config: {
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("agent-feature-values");
|
||||
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-feature-values");
|
||||
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
|
||||
});
|
||||
|
||||
test("applySnapshot keeps featureValues absent when they were never set", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-no-feature-values",
|
||||
id: "agent-provider-options",
|
||||
config: { providerOptions: { fast_mode: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-no-feature-values");
|
||||
expect(persisted?.config?.featureValues).toBeUndefined();
|
||||
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
|
||||
});
|
||||
|
||||
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-resume-overrides",
|
||||
config: {
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("agent-resume-overrides");
|
||||
expect(record).not.toBeNull();
|
||||
expect(buildConfigOverrides(record!)).toMatchObject({
|
||||
const persisted = await reloaded.get("agent-provider-options");
|
||||
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
|
||||
expect(buildConfigOverrides(persisted!)).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
});
|
||||
|
||||
test("a record stored with featureValues loads them in providerOptions", async () => {
|
||||
await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
|
||||
const file = findRecordFile(storagePath, "agent-legacy");
|
||||
const stored = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
stored.config.providerOptions = { allowedTools: ["Read"], fast_mode: false };
|
||||
stored.config.featureValues = { fast_mode: true, service_tier: "fast" };
|
||||
delete stored.controls;
|
||||
stored.features = [
|
||||
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
value: "fast",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
];
|
||||
await fs.writeFile(file, JSON.stringify(stored));
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-legacy");
|
||||
|
||||
// An explicit providerOptions key wins over the same featureValues key.
|
||||
expect(persisted?.config?.providerOptions).toEqual({
|
||||
allowedTools: ["Read"],
|
||||
fast_mode: false,
|
||||
service_tier: "fast",
|
||||
});
|
||||
expect(persisted?.config).not.toHaveProperty("featureValues");
|
||||
expect(persisted).not.toHaveProperty("features");
|
||||
expect(persisted?.controls).toEqual([
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await reloaded.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
|
||||
const rewritten = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
expect(rewritten.config).not.toHaveProperty("featureValues");
|
||||
expect(rewritten).not.toHaveProperty("features");
|
||||
});
|
||||
|
||||
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||
const agentId = "agent-created-at";
|
||||
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
|
||||
|
||||
@@ -4,17 +4,19 @@ import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
||||
import { controlsFromFeatures, foldFeatureValues } from "./option-controls.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.object({
|
||||
modeId: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; folded on parse.
|
||||
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
toolPolicy: z
|
||||
@@ -42,7 +44,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const STORED_AGENT_SCHEMA = z.object({
|
||||
const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -68,6 +70,8 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
extra: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
controls: z.array(AgentOptionControlSchema).optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; becomes controls.
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
lastError: z.string().nullable().optional(),
|
||||
@@ -84,14 +88,32 @@ export type SerializableAgentConfig = Pick<
|
||||
| "modeId"
|
||||
| "model"
|
||||
| "thinkingOptionId"
|
||||
| "featureValues"
|
||||
| "providerOptions"
|
||||
| "toolPolicy"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
>;
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
type RawStoredAgentRecord = z.infer<typeof RAW_STORED_AGENT_SCHEMA>;
|
||||
type RawStoredAgentConfig = NonNullable<RawStoredAgentRecord["config"]>;
|
||||
|
||||
/** Records carry `providerOptions` and `controls` only; legacy fields are folded when read. */
|
||||
export type StoredAgentRecord = Omit<RawStoredAgentRecord, "config" | "features"> & {
|
||||
config?: Omit<RawStoredAgentConfig, "featureValues"> | null;
|
||||
};
|
||||
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
function foldLegacyRecordFields(record: RawStoredAgentRecord): StoredAgentRecord {
|
||||
const { features, config, controls, ...rest } = record;
|
||||
return {
|
||||
...rest,
|
||||
...(config ? { config: foldFeatureValues(config) } : { config }),
|
||||
...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const STORED_AGENT_SCHEMA = RAW_STORED_AGENT_SCHEMA.transform(foldLegacyRecordFields);
|
||||
|
||||
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
||||
return STORED_AGENT_SCHEMA.parse(value);
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
|
||||
availableModes: availableModeIds,
|
||||
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
||||
}),
|
||||
featureValues: input.featureValues,
|
||||
providerOptions: input.providerOptions,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ test("session create applies the resolved mode from the provider create config",
|
||||
const stub = createProviderSnapshotManagerStub();
|
||||
stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
const dependencies: Parameters<typeof createAgentCommand>[0] = {
|
||||
agentManager: {
|
||||
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
}),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
|
||||
@@ -22,6 +22,8 @@ import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { foldFeatureValues, type LegacyFeatureValues } from "../option-controls.js";
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
finishSubscriptions?: FinishSubscriptions;
|
||||
@@ -47,7 +49,8 @@ export interface CreateAgentFromSessionInput {
|
||||
kind: "session";
|
||||
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
|
||||
config: AgentSessionConfig & LegacyFeatureValues;
|
||||
workspaceId: string;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
@@ -70,7 +73,7 @@ export interface CreateAgentFromMcpInput {
|
||||
cwd?: string;
|
||||
workspaceId?: string;
|
||||
thinking?: string;
|
||||
features?: Record<string, unknown>;
|
||||
providerOptions?: ProviderOptions;
|
||||
labels?: Record<string, string>;
|
||||
mode?: string;
|
||||
unattended?: boolean;
|
||||
@@ -218,19 +221,19 @@ async function resolveSessionCreateAgent(
|
||||
input: CreateAgentFromSessionInput,
|
||||
): Promise<ResolvedCreateAgent> {
|
||||
const trimmedPrompt = input.initialPrompt?.trim();
|
||||
const builtSessionConfig = input.config;
|
||||
const builtSessionConfig = foldFeatureValues(input.config);
|
||||
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: builtSessionConfig.cwd,
|
||||
provider: builtSessionConfig.provider,
|
||||
requestedMode: builtSessionConfig.modeId,
|
||||
featureValues: builtSessionConfig.featureValues,
|
||||
providerOptions: builtSessionConfig.providerOptions,
|
||||
parent: null,
|
||||
unattended: false,
|
||||
});
|
||||
const sessionConfig: AgentSessionConfig = {
|
||||
...builtSessionConfig,
|
||||
modeId: resolvedCreateConfig.modeId,
|
||||
featureValues: resolvedCreateConfig.featureValues,
|
||||
providerOptions: resolvedCreateConfig.providerOptions,
|
||||
};
|
||||
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
||||
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
||||
@@ -323,7 +326,7 @@ async function resolveMcpCreateAgent(
|
||||
resolvedCwd: intent.cwd,
|
||||
trimmedPrompt,
|
||||
resolvedMode: resolvedCreateConfig.modeId,
|
||||
resolvedFeatures: resolvedCreateConfig.featureValues,
|
||||
resolvedProviderOptions: resolvedCreateConfig.providerOptions,
|
||||
}),
|
||||
createOptions: {
|
||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||
@@ -361,18 +364,23 @@ async function resolveMcpProviderCreateConfig(params: {
|
||||
provider: string;
|
||||
resolvedCwd: string;
|
||||
parentAgent: ManagedAgent | null;
|
||||
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
|
||||
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
|
||||
const passthroughConfig = params.input.config;
|
||||
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: params.resolvedCwd,
|
||||
provider: params.provider,
|
||||
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
|
||||
featureValues: params.input.features ?? passthroughConfig?.featureValues,
|
||||
providerOptions: mergeMcpProviderOptions(params.input),
|
||||
parent: params.parentAgent,
|
||||
unattended: params.input.unattended ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeMcpProviderOptions(input: CreateAgentFromMcpInput): ProviderOptions | undefined {
|
||||
if (!input.providerOptions) return input.config?.providerOptions;
|
||||
return { ...input.config?.providerOptions, ...input.providerOptions };
|
||||
}
|
||||
|
||||
function buildMcpSessionConfig(params: {
|
||||
input: CreateAgentFromMcpInput;
|
||||
resolvedProviderModel: ResolvedProviderModel;
|
||||
@@ -380,14 +388,14 @@ function buildMcpSessionConfig(params: {
|
||||
resolvedCwd: string;
|
||||
trimmedPrompt: string;
|
||||
resolvedMode?: string;
|
||||
resolvedFeatures?: Record<string, unknown>;
|
||||
resolvedProviderOptions?: ProviderOptions;
|
||||
}): AgentSessionConfig {
|
||||
const passthroughConfig = params.input.config;
|
||||
const { provisionalTitle } = resolveCreateAgentTitles({
|
||||
configTitle: passthroughConfig?.title ?? params.input.title,
|
||||
initialPrompt: params.trimmedPrompt,
|
||||
});
|
||||
const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
|
||||
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
|
||||
const config: AgentSessionConfig = {
|
||||
...passthroughConfig,
|
||||
provider: params.provider,
|
||||
@@ -400,8 +408,8 @@ function buildMcpSessionConfig(params: {
|
||||
if (provisionalTitle) {
|
||||
config.title = provisionalTitle;
|
||||
}
|
||||
if (featureValues) {
|
||||
config.featureValues = featureValues;
|
||||
if (providerOptions) {
|
||||
config.providerOptions = providerOptions;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
|
||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentOptions: vi.fn().mockResolvedValue(undefined),
|
||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
|
||||
cancelAgentRun: vi.fn(),
|
||||
getPendingPermissions: vi.fn(),
|
||||
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||
listDraftFeatures: vi.fn(),
|
||||
listDraftControls: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -472,9 +472,9 @@ function configureOpenCodeProviderStub(
|
||||
const opts = input as {
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
};
|
||||
return { modeId: opts.requestedMode, featureValues: opts.featureValues };
|
||||
return { modeId: opts.requestedMode, providerOptions: opts.providerOptions };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1805,7 +1805,7 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Feature test", featureValues: { fast_mode: true } },
|
||||
config: { title: "Feature test", providerOptions: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1834,7 +1834,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
@@ -3039,12 +3039,12 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Child", featureValues: { fast_mode: true } },
|
||||
config: { title: "Child", providerOptions: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||
const opts = input as { featureValues: Record<string, unknown> | undefined };
|
||||
return { modeId: undefined, featureValues: opts.featureValues };
|
||||
const opts = input as { providerOptions: Record<string, unknown> | undefined };
|
||||
return { modeId: undefined, providerOptions: opts.providerOptions };
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -3072,7 +3072,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
@@ -3273,7 +3273,7 @@ describe("create_agent MCP tool", () => {
|
||||
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||
const opts = input as { requestedMode: string | undefined };
|
||||
expect(opts.requestedMode).toBe("dynamic");
|
||||
return { modeId: "dynamic", featureValues: undefined };
|
||||
return { modeId: "dynamic", providerOptions: undefined };
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -3307,12 +3307,12 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: "build",
|
||||
availableModes: [],
|
||||
config: { title: "Child", featureValues: { auto_accept: true } },
|
||||
config: { title: "Child", providerOptions: { auto_accept: true } },
|
||||
} as ManagedAgent);
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -3332,7 +3332,7 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
@@ -3360,7 +3360,7 @@ describe("create_agent MCP tool", () => {
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "resolver-mode",
|
||||
featureValues: { resolver_feature: true },
|
||||
providerOptions: { resolver_feature: true },
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -3384,7 +3384,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modeId: "resolver-mode",
|
||||
featureValues: { resolver_feature: true },
|
||||
providerOptions: { resolver_feature: true },
|
||||
}),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
@@ -4717,7 +4717,7 @@ describe("update_agent MCP tool", () => {
|
||||
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
||||
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true });
|
||||
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
||||
title: "Updated agent",
|
||||
labels: { role: "worker" },
|
||||
@@ -4742,12 +4742,12 @@ describe("update_agent MCP tool", () => {
|
||||
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update metadata when runtime settings fail", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||
spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature"));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -5065,7 +5065,7 @@ describe("create_schedule MCP tool", () => {
|
||||
config: {
|
||||
title: "Parent agent",
|
||||
model: "openai/gpt-5.5",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
},
|
||||
} as ManagedAgent);
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
@@ -5093,7 +5093,7 @@ describe("create_schedule MCP tool", () => {
|
||||
cwd: REPO_CWD,
|
||||
modeId: "build",
|
||||
model: "openai/gpt-5.5",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -5882,7 +5882,21 @@ describe("agent profile listing MCP tool", () => {
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
expect(response.structuredContent).toEqual({ profiles });
|
||||
// Profiles saved before options carry `featureValues`; the tool reports them as `options`.
|
||||
expect(response.structuredContent).toEqual({
|
||||
profiles: [
|
||||
{
|
||||
id: "ui-profile",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
model: "claude-test-model",
|
||||
modeId: "bypassPermissions",
|
||||
thinkingOptionId: "high",
|
||||
options: { fast_mode: true },
|
||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an empty array when no profiles are configured", async () => {
|
||||
@@ -5934,14 +5948,10 @@ describe("provider MCP tools", () => {
|
||||
|
||||
it("inspects provider features for a draft agent configuration", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
spies.agentManager.listDraftControls.mockResolvedValue({
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
|
||||
options: { fast_mode: true },
|
||||
});
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
const codexEntry = buildSnapshotEntry({
|
||||
@@ -5974,13 +5984,13 @@ describe("provider MCP tools", () => {
|
||||
|
||||
const response = await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
|
||||
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({
|
||||
provider: "codex",
|
||||
cwd: expect.stringContaining("repo"),
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
expect(response.structuredContent).toEqual({
|
||||
provider: "codex",
|
||||
@@ -5995,7 +6005,7 @@ describe("provider MCP tools", () => {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: false,
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -6211,14 +6221,8 @@ describe("agent snapshot MCP serialization", () => {
|
||||
description: "Default coding mode",
|
||||
},
|
||||
],
|
||||
features: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "web-search",
|
||||
label: "Web search",
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
|
||||
effectiveOptions: { "web-search": true },
|
||||
pendingPermissions: new Map(),
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
ProviderOptions,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/**
|
||||
* A patch over an agent's own options: top-level keys replace, `null` removes the key so the
|
||||
* `config.json` default (or the provider's own default) applies again.
|
||||
*/
|
||||
export type ProviderOptionsPatch = Record<string, unknown>;
|
||||
|
||||
export function applyProviderOptionsPatch(
|
||||
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.
|
||||
* Profiles saved before options carry `featureValues`; readers see `options`.
|
||||
*/
|
||||
export function foldProfileFeatureValues<
|
||||
T extends { options?: Record<string, unknown>; featureValues?: Record<string, unknown> },
|
||||
>(profile: T): Omit<T, "featureValues"> {
|
||||
const { featureValues, ...rest } = profile;
|
||||
if (!featureValues) return rest;
|
||||
return { ...rest, options: { ...featureValues, ...rest.options } };
|
||||
}
|
||||
|
||||
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
|
||||
const value = options[control.key];
|
||||
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
|
||||
return value;
|
||||
}
|
||||
return control.defaultValue ?? null;
|
||||
}
|
||||
|
||||
function describe(control: { description?: string; tooltip?: string; icon?: string }): {
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
} {
|
||||
return {
|
||||
...(control.description !== undefined ? { description: control.description } : {}),
|
||||
...(control.tooltip !== undefined ? { tooltip: control.tooltip } : {}),
|
||||
...(control.icon !== undefined ? { icon: control.icon } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature {
|
||||
if (control.type === "toggle") {
|
||||
const value = effective[control.key];
|
||||
return {
|
||||
type: "toggle",
|
||||
id: control.key,
|
||||
label: control.label,
|
||||
...describe(control),
|
||||
value: typeof value === "boolean" ? value : (control.defaultValue ?? false),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "select",
|
||||
id: control.key,
|
||||
label: control.label,
|
||||
...describe(control),
|
||||
...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}),
|
||||
value: selectedChoice(control, effective),
|
||||
options: control.choices.map((choice) => ({
|
||||
id: choice.value,
|
||||
label: choice.label,
|
||||
...(choice.description ? { description: choice.description } : {}),
|
||||
...(choice.value === control.defaultValue ? { isDefault: true } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Apps that predate controls render `features`; build them from controls + effective options.
|
||||
*/
|
||||
export function featuresFromControls(
|
||||
controls: readonly AgentOptionControl[] | undefined,
|
||||
options: ProviderOptions | undefined,
|
||||
): AgentFeature[] {
|
||||
const effective = options ?? {};
|
||||
return (controls ?? []).map((control) => featureFromControl(control, effective));
|
||||
}
|
||||
|
||||
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
|
||||
const descriptor = {
|
||||
key: feature.id,
|
||||
label: feature.label,
|
||||
...describe(feature),
|
||||
};
|
||||
if (feature.type === "toggle") {
|
||||
return { type: "toggle", ...descriptor };
|
||||
}
|
||||
const defaultChoice = feature.options.find((option) => option.isDefault);
|
||||
return {
|
||||
type: "select",
|
||||
...descriptor,
|
||||
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
|
||||
...(defaultChoice ? { defaultValue: defaultChoice.id } : {}),
|
||||
choices: feature.options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Agent records written before controls stored `features`; their descriptors become controls.
|
||||
*/
|
||||
export function controlsFromFeatures(
|
||||
features: readonly AgentFeature[] | undefined,
|
||||
): AgentOptionControl[] {
|
||||
return (features ?? []).map(controlFromFeature);
|
||||
}
|
||||
@@ -1511,12 +1511,14 @@ describe("PluginAgentClientRegistry", () => {
|
||||
});
|
||||
|
||||
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
expect(session.controls).toEqual([
|
||||
{
|
||||
type: "select",
|
||||
id: "voice",
|
||||
options: [{ id: "direct", label: "Direct", value: "direct" }],
|
||||
}),
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
defaultValue: "direct",
|
||||
choices: [{ value: "direct", label: "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 } from "@getpaseo/protocol/agent-types";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
@@ -31,10 +31,10 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentOptionControl,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
@@ -1246,14 +1246,29 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return this.bridge.config.settings.map((setting) =>
|
||||
setting.type === "toggle"
|
||||
? { ...setting }
|
||||
: {
|
||||
...setting,
|
||||
options: setting.options.map((option) => ({ ...option, id: option.value })),
|
||||
},
|
||||
/** The plugin's `settings` are its option controls; each setting id is an option key. */
|
||||
get controls(): AgentOptionControl[] {
|
||||
return this.bridge.config.settings.map(
|
||||
(setting): AgentOptionControl =>
|
||||
setting.type === "toggle"
|
||||
? {
|
||||
type: "toggle",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
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,
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1410,11 +1425,18 @@ class PluginAgentSession implements AgentSession {
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
await this.bridge.configure({
|
||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
||||
});
|
||||
const settings: Record<string, JsonValue> = {};
|
||||
for (const setting of this.bridge.config.settings) {
|
||||
const value = options[setting.id];
|
||||
if (value !== undefined && value !== setting.value) {
|
||||
settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`);
|
||||
}
|
||||
}
|
||||
if (Object.keys(settings).length > 0) {
|
||||
await this.bridge.configure({ settings });
|
||||
}
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
@@ -1852,7 +1874,8 @@ function mapSessionConfig(
|
||||
model: config.model,
|
||||
mode: config.modeId,
|
||||
thinkingOption: config.thinkingOptionId,
|
||||
settings: toJsonObject(config.featureValues ?? {}, "provider settings"),
|
||||
// The plugin SDK reads composer values from `settings`; they are options here.
|
||||
settings: toJsonObject(config.providerOptions ?? {}, "provider settings"),
|
||||
providerOptions: config.providerOptions,
|
||||
title: config.title ?? undefined,
|
||||
persist,
|
||||
|
||||
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"setFeature",
|
||||
"applyOptions",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
|
||||
this.recordedCalls.push("setThinkingOption");
|
||||
}
|
||||
|
||||
async setFeature() {
|
||||
this.recordedCalls.push("setFeature");
|
||||
async applyOptions() {
|
||||
this.recordedCalls.push("applyOptions");
|
||||
}
|
||||
|
||||
async revertConversation() {
|
||||
@@ -193,7 +193,7 @@ describe("wrapSessionProvider", () => {
|
||||
await wrapped.listCommands?.();
|
||||
await wrapped.setModel?.("sonnet");
|
||||
await wrapped.setThinkingOption?.("high");
|
||||
await wrapped.setFeature?.("feature-1", true);
|
||||
await wrapped.applyOptions?.({ "feature-1": true });
|
||||
await wrapped.revertConversation?.({ messageId: "message-1" });
|
||||
await wrapped.revertFiles?.({ messageId: "message-1" });
|
||||
await wrapped.revertBoth?.({ messageId: "message-1" });
|
||||
@@ -208,7 +208,7 @@ describe("wrapSessionProvider", () => {
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"setFeature",
|
||||
"applyOptions",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentModelDefinition,
|
||||
AgentMode,
|
||||
AgentSessionConfig,
|
||||
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
return true;
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
mockState.cursorListFeaturesConfigs.push(config);
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
id: "fast",
|
||||
key: "fast",
|
||||
label: "Fast",
|
||||
value: "false",
|
||||
options: [{ id: "false", label: "Off" }],
|
||||
defaultValue: "false",
|
||||
choices: [{ value: "false", label: "Off" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -823,7 +823,7 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
});
|
||||
|
||||
test("wrapped cursor client lists ACP features through the inner provider", async () => {
|
||||
test("wrapped cursor client lists ACP controls through the inner provider", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
cursor: {
|
||||
@@ -837,17 +837,17 @@ test("wrapped cursor client lists ACP features through the inner provider", asyn
|
||||
const client = registry.cursor.createClient(logger);
|
||||
|
||||
await expect(
|
||||
client.listFeatures?.({
|
||||
client.listControls?.({
|
||||
provider: "cursor",
|
||||
cwd: "/tmp/cursor",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "select",
|
||||
id: "fast",
|
||||
key: "fast",
|
||||
label: "Fast",
|
||||
value: "false",
|
||||
options: [{ id: "false", label: "Off" }],
|
||||
defaultValue: "false",
|
||||
choices: [{ value: "false", label: "Off" }],
|
||||
},
|
||||
]);
|
||||
expect(mockState.cursorListFeaturesConfigs).toEqual([
|
||||
|
||||
@@ -447,8 +447,8 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
get initialTimeline() {
|
||||
return inner.initialTimeline;
|
||||
},
|
||||
get features() {
|
||||
return inner.features;
|
||||
get controls() {
|
||||
return inner.controls;
|
||||
},
|
||||
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),
|
||||
setFeature: inner.setFeature?.bind(inner),
|
||||
applyOptions: inner.applyOptions?.bind(inner),
|
||||
revertConversation: inner.revertConversation?.bind(inner),
|
||||
revertFiles: inner.revertFiles?.bind(inner),
|
||||
revertBoth: inner.revertBoth?.bind(inner),
|
||||
@@ -491,7 +491,7 @@ function wrapClientProvider(
|
||||
): AgentClient {
|
||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||
const importSession = inner.importSession?.bind(inner);
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const listControls = inner.listControls?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
|
||||
@@ -557,9 +557,10 @@ function wrapClientProvider(
|
||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
|
||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||
listFeatures: listFeatures
|
||||
effectiveOptions: (own) => mergeProviderOptions(providerOptions, own) ?? {},
|
||||
listControls: listControls
|
||||
? async (config) =>
|
||||
await listFeatures({
|
||||
await listControls({
|
||||
...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,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent,
|
||||
unattended: false,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined });
|
||||
expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined });
|
||||
expect(resolverInputs).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "parent-unattended",
|
||||
@@ -1386,7 +1386,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
resolverInputs.push(input);
|
||||
return {
|
||||
modeId: input.unattended ? "worker" : undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -1397,17 +1397,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "worker", featureValues: undefined });
|
||||
expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined });
|
||||
expect(resolverInputs).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: modes,
|
||||
@@ -1456,7 +1456,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
config: {
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1464,12 +1464,12 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "opencode",
|
||||
requestedMode: "base",
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent,
|
||||
unattended: false,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
|
||||
expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeAgentModelCatalog, type ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { createHash } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { homedir } from "node:os";
|
||||
@@ -160,14 +160,14 @@ export interface ResolveProviderCreateConfigOptions {
|
||||
cwd?: string | null;
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
parent: ManagedAgent | null;
|
||||
unattended: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedProviderCreateConfig {
|
||||
modeId: string | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
}
|
||||
|
||||
interface ResolveDefaultModelOptions {
|
||||
@@ -563,7 +563,7 @@ export class ProviderSnapshotManager {
|
||||
return definition.resolveCreateConfig({
|
||||
provider: input.provider,
|
||||
requestedMode: input.requestedMode,
|
||||
featureValues: input.featureValues,
|
||||
providerOptions: input.providerOptions,
|
||||
parent,
|
||||
unattended: input.unattended || parent?.isUnattended === true,
|
||||
availableModes: entry.modes ?? [],
|
||||
@@ -745,7 +745,7 @@ export class ProviderSnapshotManager {
|
||||
isUnattended: definition.isCreateConfigUnattended({
|
||||
modeId: parent.currentModeId,
|
||||
config: parent.config,
|
||||
features: parent.features,
|
||||
controls: parent.controls,
|
||||
availableModes: parent.availableModes ?? definition.modes ?? [],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -198,7 +198,7 @@ function createSessionWithConfig(
|
||||
provider?: string;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
} = {},
|
||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
@@ -208,7 +208,7 @@ function createSessionWithConfig(
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: config.modeId ?? undefined,
|
||||
model: config.model ?? undefined,
|
||||
featureValues: config.featureValues,
|
||||
providerOptions: config.providerOptions,
|
||||
},
|
||||
{
|
||||
provider: config.provider ?? "claude-acp",
|
||||
@@ -300,14 +300,14 @@ function selectConfigOption(
|
||||
|
||||
function createCopilotSessionWithConfig(
|
||||
modeId?: string | null,
|
||||
featureValues?: Record<string, unknown>,
|
||||
providerOptions?: Record<string, unknown>,
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: modeId ?? undefined,
|
||||
...(featureValues ? { featureValues } : {}),
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
},
|
||||
{
|
||||
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",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { 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",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -1583,11 +1583,8 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
internals.sessionId = "session-1";
|
||||
internals.connection = {};
|
||||
|
||||
await session.setFeature("auto_accept", true);
|
||||
await session.applyOptions({ auto_accept: true });
|
||||
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ type: "toggle", id: "auto_accept", value: true }),
|
||||
);
|
||||
await expect(
|
||||
session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
@@ -1605,7 +1602,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
});
|
||||
|
||||
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
|
||||
const session = createSessionWithConfig({ featureValues: { auto_accept: true } });
|
||||
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } });
|
||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
|
||||
@@ -1765,35 +1762,19 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
internals.configOptions = [copilotAgentConfigOption("")];
|
||||
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||
{
|
||||
type: "select",
|
||||
id: "agent",
|
||||
key: "agent",
|
||||
label: "Agent",
|
||||
description: "Use a Copilot custom agent profile",
|
||||
tooltip: "Select Copilot agent",
|
||||
icon: undefined,
|
||||
value: "",
|
||||
options: [
|
||||
{
|
||||
id: "",
|
||||
label: "Default",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "Probe Agent",
|
||||
label: "Probe Agent",
|
||||
description: "Temporary probe agent",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
defaultValue: "",
|
||||
choices: [
|
||||
{ value: "", label: "Default", description: undefined },
|
||||
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1816,15 +1797,9 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "auto_accept" }),
|
||||
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1838,22 +1813,16 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
connection: { setSessionConfigOption },
|
||||
});
|
||||
|
||||
await session.setFeature("agent", "Probe Agent");
|
||||
await session.applyOptions({ agent: "Probe Agent" });
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "auto_accept" }),
|
||||
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -2084,14 +2053,14 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
featureValues: { provider_feature: "kept" },
|
||||
providerOptions: { provider_feature: "kept" },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({
|
||||
modeId: undefined,
|
||||
featureValues: { provider_feature: "kept", auto_accept: true },
|
||||
providerOptions: { provider_feature: "kept", auto_accept: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2106,12 +2075,12 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
featureValues: { auto_accept: false },
|
||||
providerOptions: { auto_accept: false },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: false } });
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: false } });
|
||||
});
|
||||
|
||||
test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
|
||||
@@ -2125,7 +2094,7 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "bypassPermissions",
|
||||
@@ -2134,7 +2103,7 @@ describe("ACPAgentClient config features", () => {
|
||||
unattended: false,
|
||||
availableModes: [{ id: "agent", label: "Agent" }],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("treats Auto Accept as an unattended ACP configuration", () => {
|
||||
@@ -2150,7 +2119,7 @@ describe("ACPAgentClient config features", () => {
|
||||
config: {
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
},
|
||||
availableModes: [],
|
||||
}),
|
||||
@@ -2165,22 +2134,17 @@ describe("ACPAgentClient config features", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
value: true,
|
||||
}),
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("derives features from configured ACP select options", async () => {
|
||||
test("derives controls from configured ACP select options", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
@@ -2206,23 +2170,19 @@ describe("ACPAgentClient config features", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/acp-features",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
options: [
|
||||
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
|
||||
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
|
||||
key: "agent",
|
||||
defaultValue: "Probe Agent",
|
||||
choices: [
|
||||
expect.objectContaining({ value: "", label: "Default" }),
|
||||
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
@@ -3997,7 +3957,7 @@ describe("ACPAgentClient probe cleanup", () => {
|
||||
terminateProcess: terminator.terminate,
|
||||
});
|
||||
|
||||
await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" });
|
||||
await client.listControls({ provider: "copilot", cwd: "/tmp/acp-features" });
|
||||
|
||||
expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" });
|
||||
expect(terminator.terminated).toContain(child);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
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";
|
||||
@@ -65,7 +66,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateConfigUnattendedInput,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -848,10 +849,10 @@ export function deriveModelDefinitionsFromACP(
|
||||
}));
|
||||
}
|
||||
|
||||
export function deriveFeaturesFromACP(
|
||||
export function deriveControlsFromACP(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
featureOptions: ACPConfigFeatureOption[],
|
||||
): AgentFeature[] {
|
||||
): AgentOptionControl[] {
|
||||
return featureOptions.flatMap((featureOption) => {
|
||||
const option = findSelectConfigFeatureOption(configOptions, featureOption);
|
||||
if (!option) {
|
||||
@@ -861,42 +862,44 @@ export function deriveFeaturesFromACP(
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
id: featureOption.id,
|
||||
key: featureOption.id,
|
||||
label: featureOption.label,
|
||||
description: featureOption.description,
|
||||
tooltip: featureOption.tooltip,
|
||||
icon: featureOption.icon,
|
||||
value: option.currentValue ?? null,
|
||||
options: deriveConfigFeatureSelectOptions(option, featureOption),
|
||||
// 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,
|
||||
})),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
|
||||
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: isACPAutoAcceptEnabled(config),
|
||||
};
|
||||
}
|
||||
const ACP_AUTO_ACCEPT_CONTROL: AgentOptionControl = {
|
||||
type: "toggle",
|
||||
key: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
};
|
||||
|
||||
function resolveACPCreateConfig(
|
||||
input: ResolveAgentCreateConfigInput,
|
||||
): ResolveAgentCreateConfigResult {
|
||||
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
|
||||
const featureValues =
|
||||
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: input.featureValues;
|
||||
const providerOptions =
|
||||
isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||
? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: input.providerOptions;
|
||||
|
||||
if (
|
||||
input.requestedMode === undefined &&
|
||||
@@ -904,22 +907,16 @@ function resolveACPCreateConfig(
|
||||
input.parent !== null &&
|
||||
input.parent.provider !== input.provider
|
||||
) {
|
||||
return { modeId: undefined, featureValues };
|
||||
return { modeId: undefined, providerOptions };
|
||||
}
|
||||
|
||||
return resolveDefaultAgentCreateConfig({ ...input, featureValues });
|
||||
return resolveDefaultAgentCreateConfig({ ...input, providerOptions });
|
||||
}
|
||||
|
||||
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
|
||||
return (
|
||||
isDefaultAgentCreateConfigUnattended(input) ||
|
||||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
|
||||
input.features?.some(
|
||||
(feature) =>
|
||||
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
|
||||
feature.type === "toggle" &&
|
||||
feature.value === true,
|
||||
) === true
|
||||
input.config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1198,10 +1195,9 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
if (this.configFeatureOptions.length === 0) {
|
||||
return [autoAcceptFeature];
|
||||
return [ACP_AUTO_ACCEPT_CONTROL];
|
||||
}
|
||||
|
||||
this.assertProvider(config);
|
||||
@@ -1217,8 +1213,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
probeSessionId = response.sessionId;
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
return [
|
||||
autoAcceptFeature,
|
||||
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||
ACP_AUTO_ACCEPT_CONTROL,
|
||||
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
} finally {
|
||||
await this.closeProbe(probe, probeSessionId);
|
||||
@@ -2054,10 +2050,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
buildACPAutoAcceptFeature(this.config),
|
||||
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
|
||||
ACP_AUTO_ACCEPT_CONTROL,
|
||||
...deriveControlsFromACP(this.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2395,14 +2391,26 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
if (ACP_AUTO_ACCEPT_FEATURE_ID in options) {
|
||||
await this.setConfigFeature(ACP_AUTO_ACCEPT_FEATURE_ID, options[ACP_AUTO_ACCEPT_FEATURE_ID]);
|
||||
}
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
if (!(featureOption.id in options)) continue;
|
||||
const value = options[featureOption.id];
|
||||
if (value === this.config.providerOptions?.[featureOption.id]) continue;
|
||||
await this.setConfigFeature(featureOption.id, value);
|
||||
}
|
||||
}
|
||||
|
||||
private async setConfigFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
this.config.providerOptions = {
|
||||
...this.config.providerOptions,
|
||||
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
|
||||
};
|
||||
return;
|
||||
@@ -2441,7 +2449,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue,
|
||||
label: featureOption.label,
|
||||
});
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
|
||||
this.config.providerOptions = { ...this.config.providerOptions, [featureId]: currentValue };
|
||||
}
|
||||
|
||||
private applyConfigOptionResponse({
|
||||
@@ -3018,13 +3026,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||
}
|
||||
const configuredFeatureValues = this.config.featureValues ?? {};
|
||||
const configuredFeatureValues = this.config.providerOptions ?? {};
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
await this.setConfigFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
} catch (error) {
|
||||
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
|
||||
throw error;
|
||||
|
||||
@@ -953,39 +953,39 @@ describe("ClaudeAgentSession features", () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8[1m]",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8-20260101",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-5",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "openrouter/anthropic/claude-opus-4-8",
|
||||
@@ -993,7 +993,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-5",
|
||||
@@ -1001,7 +1001,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-4-6",
|
||||
@@ -1020,7 +1020,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1492,7 +1492,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
ensureQuery(): Promise<unknown>;
|
||||
}
|
||||
).ensureQuery();
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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";
|
||||
@@ -65,7 +66,7 @@ import { foldSubagentObservations, type SubagentObservation } from "./subagents/
|
||||
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
|
||||
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
|
||||
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
|
||||
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
@@ -104,7 +105,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -1752,12 +1753,8 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
return buildClaudeFeatures({
|
||||
modelId: claudeConfig.model,
|
||||
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
|
||||
});
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
return buildClaudeControls(this.assertConfig(config).model);
|
||||
}
|
||||
|
||||
async listImportableSessions(
|
||||
@@ -2446,11 +2443,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
return this.claudeSessionId;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return buildClaudeFeatures({
|
||||
modelId: this.config.model,
|
||||
fastModeEnabled: this.config.featureValues?.fast_mode === true,
|
||||
});
|
||||
get controls(): AgentOptionControl[] {
|
||||
return buildClaudeControls(this.config.model);
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -2748,8 +2742,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await activeQuery.setModel(normalizedModelId ?? undefined);
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.reconcileThinkingOptionForModel(normalizedModelId);
|
||||
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
|
||||
await this.applyFastModeFeature(false, activeQuery);
|
||||
if (this.config.providerOptions.fast_mode) {
|
||||
// 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;
|
||||
}
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
@@ -2801,31 +2797,21 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
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)) {
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const providerOptions =
|
||||
validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {};
|
||||
if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) {
|
||||
throw new Error(
|
||||
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
|
||||
await this.applyFastModeFeature(enabled);
|
||||
}
|
||||
|
||||
private async applyFastModeFeature(enabled: boolean, query?: Query): Promise<void> {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
fast_mode: enabled,
|
||||
};
|
||||
const activeQuery = query ?? this.query;
|
||||
if (activeQuery) {
|
||||
await activeQuery.applyFlagSettings({ fastMode: enabled });
|
||||
const fastModeChanged =
|
||||
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
|
||||
this.config.providerOptions = providerOptions;
|
||||
if (fastModeChanged) {
|
||||
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
@@ -3654,10 +3640,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
|
||||
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||
const providerOptions = applyClaudeToolPolicy(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
// `fast_mode` is a composer control the provider applies through flag settings.
|
||||
const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
|
||||
const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
|
||||
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.harnessEnvironment;
|
||||
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
||||
@@ -3776,7 +3761,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!claudeModelSupportsFastMode(this.config.model)) {
|
||||
return null;
|
||||
}
|
||||
return this.config.featureValues?.fast_mode === true;
|
||||
return this.config.providerOptions.fast_mode === true;
|
||||
}
|
||||
|
||||
private normalizeMcpServers(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js";
|
||||
import type { AgentOptionControl, AgentOptionToggleControl } from "../../agent-sdk-types.js";
|
||||
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
|
||||
|
||||
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
key: "fast_mode",
|
||||
label: "Fast",
|
||||
description: "Lower latency Opus responses at higher token cost",
|
||||
tooltip: "Toggle fast mode",
|
||||
@@ -14,18 +14,6 @@ export function claudeModelSupportsFastMode(modelId: string | null | undefined):
|
||||
return claudeManifestModelSupportsFastMode(modelId);
|
||||
}
|
||||
|
||||
export function buildClaudeFeatures(input: {
|
||||
modelId: string | null | undefined;
|
||||
fastModeEnabled: boolean;
|
||||
}): AgentFeature[] {
|
||||
if (!claudeModelSupportsFastMode(input.modelId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...CLAUDE_FAST_MODE_FEATURE,
|
||||
value: input.fastModeEnabled,
|
||||
},
|
||||
];
|
||||
export function buildClaudeControls(modelId: string | null | undefined): AgentOptionControl[] {
|
||||
return claudeModelSupportsFastMode(modelId) ? [{ ...CLAUDE_FAST_MODE_CONTROL }] : [];
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ const SandboxFilesystemSchema = z
|
||||
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
|
||||
export const ClaudeProviderOptionsSchema = z
|
||||
.object({
|
||||
/** Composer control, not a Claude SDK option: the provider reads it and keeps it out of the SDK options. */
|
||||
fast_mode: z.boolean().optional(),
|
||||
allowedTools: z.array(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
additionalDirectories: z.array(z.string()).optional(),
|
||||
|
||||
+112
-149
@@ -37,23 +37,32 @@ const TEST_SPEED_MODELS = [
|
||||
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
|
||||
}));
|
||||
|
||||
function speedFeature(value: string) {
|
||||
function speedControl() {
|
||||
return {
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
description: "Choose processing speed. Faster tiers increase usage.",
|
||||
tooltip: "Select speed",
|
||||
icon: "zap",
|
||||
desktopTrigger: "icon",
|
||||
value,
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const PLAN_CONTROL = {
|
||||
type: "toggle",
|
||||
key: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
};
|
||||
|
||||
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
{
|
||||
name: "Code",
|
||||
@@ -132,7 +141,7 @@ async function createConnectedSession(
|
||||
return harness;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider features", () => {
|
||||
describe("Codex app-server provider controls", () => {
|
||||
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "gpt-6.1-sol" },
|
||||
@@ -149,19 +158,19 @@ describe("Codex app-server provider features", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "service_tier",
|
||||
key: "service_tier",
|
||||
type: "select",
|
||||
value: "default",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
{ id: "ultrafast", label: "Ultrafast" },
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
{ value: "ultrafast", label: "Ultrafast" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.setFeature?.("service_tier", "ultrafast");
|
||||
await session.applyOptions?.({ service_tier: "ultrafast" });
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "ultrafast",
|
||||
@@ -186,16 +195,16 @@ describe("Codex app-server provider features", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "service_tier",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "future-tier", label: "Future speed" },
|
||||
key: "service_tier",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "future-tier", label: "Future speed" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.setFeature?.("service_tier", "future-tier");
|
||||
await session.applyOptions?.({ service_tier: "future-tier" });
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model: "future-codex-model",
|
||||
@@ -210,7 +219,7 @@ describe("Codex app-server provider features", () => {
|
||||
"restores %s and Normal explicitly overrides an existing thread tier",
|
||||
async (tier) => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-model", featureValues: { service_tier: tier } },
|
||||
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
@@ -224,11 +233,8 @@ describe("Codex app-server provider features", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: tier }),
|
||||
);
|
||||
await session.getRuntimeInfo();
|
||||
await session.setFeature?.("service_tier", "default");
|
||||
await session.applyOptions?.({ service_tier: "default" });
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
@@ -239,17 +245,40 @@ describe("Codex app-server provider features", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test("rejects unadvertised tiers and non-string values", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
test.each(["priority", "ultrafast"])("restores %s from providerOptions", async (tier) => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "future-model",
|
||||
serviceTiers: [
|
||||
{ id: "priority", name: "Fast", description: "Fast processing" },
|
||||
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
try {
|
||||
for (const value of ["ultrafast", "bogus", true, null]) {
|
||||
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: tier });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unadvertised tiers and non-string values", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
try {
|
||||
for (const value of ["ultrafast", "bogus"]) {
|
||||
await expect(session.applyOptions?.({ service_tier: value })).rejects.toThrow(
|
||||
"is not available",
|
||||
);
|
||||
}
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
);
|
||||
await expect(session.applyOptions?.({ service_tier: true })).rejects.toThrow();
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -257,7 +286,7 @@ describe("Codex app-server provider features", () => {
|
||||
|
||||
test("switching to a model without the selected tier resets speed", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
|
||||
{ model: "ultra-model", providerOptions: { service_tier: "ultrafast" } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
@@ -270,8 +299,14 @@ describe("Codex app-server provider features", () => {
|
||||
);
|
||||
try {
|
||||
await session.setModel("gpt-6.1-sol");
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
@@ -283,7 +318,7 @@ describe("Codex app-server provider features", () => {
|
||||
test("maps legacy Fast commands to the catalog's request id", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
try {
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
@@ -307,13 +342,13 @@ describe("Codex app-server provider features", () => {
|
||||
])("exposes and sends Fast for %s", async (model) => {
|
||||
const { session, appServer } = await createConnectedSession({ model });
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "service_tier",
|
||||
value: "default",
|
||||
key: "service_tier",
|
||||
defaultValue: "default",
|
||||
}),
|
||||
);
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model,
|
||||
@@ -338,12 +373,13 @@ describe("Codex app-server provider features", () => {
|
||||
])("does not expose or restore Fast for %s", async (model) => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model,
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
const fresh = await createConnectedSession({ model });
|
||||
try {
|
||||
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]);
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
||||
`Codex fast mode is not available for model '${model}'`,
|
||||
expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]);
|
||||
await expect(fresh.session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
|
||||
`Codex speed 'fast' is not available for model '${model}'`,
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -351,21 +387,16 @@ describe("Codex app-server provider features", () => {
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
await fresh.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("restores Fast on Astra and preserves it when switching supported models", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model: "gpt-6-astra",
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { 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");
|
||||
@@ -378,72 +409,33 @@ describe("Codex app-server provider features", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("features returns speed selector and plan toggle when supported", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
test("controls return the speed selector and plan toggle when supported", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("default"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
|
||||
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
|
||||
await session.startTurn("hello");
|
||||
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("priority"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
collaborationMode: expect.objectContaining({ mode: "plan" }),
|
||||
});
|
||||
});
|
||||
|
||||
test("features returns only plan toggle when model does not support fast mode", async () => {
|
||||
test("controls return only the plan toggle when model does not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||
});
|
||||
|
||||
test("constructor ignores restored fast mode when model does not support it", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model: "gpt-3.5-turbo",
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -454,7 +446,7 @@ describe("Codex app-server provider features", () => {
|
||||
test("selecting Fast sends the catalog service tier", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -464,10 +456,10 @@ describe("Codex app-server provider features", () => {
|
||||
|
||||
test("legacy Fast off explicitly selects Normal", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true },
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
|
||||
await session.setFeature?.("fast_mode", false);
|
||||
await session.applyOptions?.({ fast_mode: false });
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -475,53 +467,34 @@ describe("Codex app-server provider features", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => {
|
||||
test("applyOptions({ fast_mode: true }) rejects models that do not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
||||
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
|
||||
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
|
||||
"Codex speed 'fast' is not available for model 'gpt-3.5-turbo'",
|
||||
);
|
||||
});
|
||||
|
||||
test("setFeature invalidates runtime info", async () => {
|
||||
test("applyOptions invalidates runtime info", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
});
|
||||
|
||||
test("setFeature throws for unknown feature ids", async () => {
|
||||
const { session } = createSessionHarness();
|
||||
|
||||
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
||||
"Unknown Codex feature: unknown_feature",
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor restores feature flags from config.featureValues", async () => {
|
||||
test("constructor restores composer options from config.providerOptions", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true, plan_mode: true },
|
||||
providerOptions: { fast_mode: true, plan_mode: 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,
|
||||
},
|
||||
]);
|
||||
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -535,7 +508,7 @@ describe("Codex app-server provider features", () => {
|
||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -547,7 +520,7 @@ describe("Codex app-server provider features", () => {
|
||||
const capture = createCapturedLogger();
|
||||
const prompt = "secret prompt text should not be logged";
|
||||
const { session } = await createConnectedSession(
|
||||
{ featureValues: { fast_mode: true } },
|
||||
{ providerOptions: { fast_mode: true } },
|
||||
{ logger: capture.logger },
|
||||
);
|
||||
|
||||
@@ -570,20 +543,10 @@ describe("Codex app-server provider features", () => {
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setModel("gpt-3.5-turbo");
|
||||
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -594,7 +557,7 @@ describe("Codex app-server provider features", () => {
|
||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
|
||||
+2
-2
@@ -38,7 +38,7 @@ async function withPendingPlan(
|
||||
cwd: workdir,
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
featureValues: { plan_mode: true },
|
||||
providerOptions: { plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -526,7 +526,7 @@ test("idle provider exit preserves a synthetic plan approval across reconnect",
|
||||
cwd: workdir,
|
||||
modeId: "auto",
|
||||
model: "gpt-5.4",
|
||||
featureValues: { plan_mode: true },
|
||||
providerOptions: { plan_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
|
||||
@@ -6188,7 +6188,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
providerOptions: { plan_mode: true, 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({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
providerOptions: { 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({
|
||||
featureValues: { plan_mode: true },
|
||||
providerOptions: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -6337,7 +6337,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
providerOptions: { plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -6379,7 +6379,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
providerOptions: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -6433,7 +6433,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
providerOptions: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -6483,7 +6483,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true },
|
||||
providerOptions: { plan_mode: true },
|
||||
});
|
||||
|
||||
await asInternals(session).handleNotification("turn/started", {
|
||||
@@ -7107,7 +7107,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
providerOptions: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -7138,9 +7138,10 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
expect(asInternals(session).serviceTier).toBe("fast");
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
expect(asInternals(session).config.featureValues).toEqual({
|
||||
plan_mode: false,
|
||||
fast_mode: true,
|
||||
expect(events).toContainEqual({
|
||||
type: "options_changed",
|
||||
provider: "codex",
|
||||
patch: { plan_mode: false },
|
||||
});
|
||||
// The session returns the follow-up prompt instead of calling startTurn directly.
|
||||
// The caller (session/agent-manager) is responsible for sending it through streamAgent.
|
||||
@@ -7161,7 +7162,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: false },
|
||||
providerOptions: { plan_mode: true, fast_mode: false },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
@@ -7192,9 +7193,10 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
expect(asInternals(session).serviceTier).toBeNull();
|
||||
expect(asInternals(session).planModeEnabled).toBe(false);
|
||||
expect(asInternals(session).config.featureValues).toEqual({
|
||||
plan_mode: false,
|
||||
fast_mode: false,
|
||||
expect(events).toContainEqual({
|
||||
type: "options_changed",
|
||||
provider: "codex",
|
||||
patch: { plan_mode: false },
|
||||
});
|
||||
expect(result?.followUpPrompt).toEqual(
|
||||
expect.stringContaining("The user approved the plan. Implement it now."),
|
||||
@@ -7203,7 +7205,7 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => {
|
||||
const session = createSession({
|
||||
featureValues: { plan_mode: true, fast_mode: true },
|
||||
providerOptions: { plan_mode: true, fast_mode: true },
|
||||
});
|
||||
asInternals(session).collaborationModes = [
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
@@ -5,7 +6,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentMode,
|
||||
@@ -75,7 +76,7 @@ import { createPathEquivalenceMatcher } from "../../../utils/path.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
|
||||
import {
|
||||
buildCodexFeatures,
|
||||
buildCodexControls,
|
||||
CodexServiceTierSchema,
|
||||
readCodexServiceTier,
|
||||
type CodexServiceTier,
|
||||
@@ -3429,7 +3430,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private readonly codexHome: string;
|
||||
private currentMode: string;
|
||||
private hasWorkflowModeOverride: boolean;
|
||||
private readonly providerOptions: CodexProviderOptions;
|
||||
private providerOptions: CodexProviderOptions;
|
||||
private resolvedWorkspaceWrite: NonNullable<
|
||||
CodexProviderOptions["sandbox_workspace_write"]
|
||||
> | null = null;
|
||||
@@ -3457,7 +3458,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private activeForegroundTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
|
||||
private serviceTier: string | null = null;
|
||||
/** The tier the options ask for; `serviceTier` is what the current model can honor. */
|
||||
private requestedServiceTier: string | null = null;
|
||||
private speedModels: CodexModel[] = [];
|
||||
private planModeEnabled = false;
|
||||
// History read by rewind or an archived resume, held until streamHistory consumes it.
|
||||
@@ -3568,10 +3570,8 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
|
||||
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
|
||||
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
|
||||
this.serviceTier = readCodexServiceTier(this.config.featureValues);
|
||||
if (this.config.featureValues?.plan_mode) {
|
||||
this.planModeEnabled = true;
|
||||
}
|
||||
this.requestedServiceTier = readCodexServiceTier(this.providerOptions);
|
||||
this.planModeEnabled = this.providerOptions.plan_mode === true;
|
||||
|
||||
if (this.resumeHandle?.sessionId) {
|
||||
this.currentThreadId = this.resumeHandle.sessionId;
|
||||
@@ -3586,11 +3586,9 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return this.currentThreadId;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return buildCodexFeatures({
|
||||
get controls(): AgentOptionControl[] {
|
||||
return buildCodexControls({
|
||||
serviceTiers: this.currentServiceTiers(),
|
||||
serviceTier: this.serviceTier ?? "default",
|
||||
planModeEnabled: this.planModeEnabled,
|
||||
planModeAvailable: this.hasPlanCollaborationMode(),
|
||||
});
|
||||
}
|
||||
@@ -3650,7 +3648,6 @@ 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();
|
||||
@@ -3863,24 +3860,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
return selectedModel?.serviceTiers ?? [];
|
||||
}
|
||||
|
||||
private reconcileServiceTier(): void {
|
||||
private get serviceTier(): string | null {
|
||||
const requested = this.requestedServiceTier;
|
||||
// Until the model list arrives there is nothing to check the request against.
|
||||
if (this.speedModels.length === 0 || requested === "default") return requested;
|
||||
const tiers = this.currentServiceTiers();
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
|
||||
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
|
||||
if (requested === "fast" && !tiers.some((tier) => tier.id === "fast")) {
|
||||
return tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
|
||||
}
|
||||
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
|
||||
this.serviceTier = null;
|
||||
}
|
||||
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
|
||||
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
|
||||
}
|
||||
|
||||
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
|
||||
this.planModeEnabled = value;
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
return tiers.some((tier) => tier.id === requested) ? requested : null;
|
||||
}
|
||||
|
||||
private rememberPlanResult(item: ToolCallTimelineItem): void {
|
||||
@@ -3945,7 +3934,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
const planText =
|
||||
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
|
||||
|
||||
this.applyFeatureValue("plan_mode", false);
|
||||
this.setPlanMode(false);
|
||||
// The user left plan mode; their options follow so controls and reopened sessions agree.
|
||||
this.emitEvent({
|
||||
type: "options_changed",
|
||||
provider: CODEX_PROVIDER,
|
||||
patch: { plan_mode: false },
|
||||
});
|
||||
|
||||
return buildCodexPlanImplementationPrompt(planText);
|
||||
}
|
||||
@@ -4645,7 +4640,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
async setModel(modelId: string | null): Promise<void> {
|
||||
this.config.model = modelId ?? undefined;
|
||||
this.reconcileServiceTier();
|
||||
this.refreshResolvedCollaborationMode();
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
@@ -4659,35 +4653,37 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
|
||||
if (featureId === "fast_mode") {
|
||||
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
|
||||
if (value && !fast)
|
||||
throw new Error(
|
||||
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
return this.setFeature("service_tier", value ? fast!.id : "default");
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const next = validateProviderOptions("codex", CodexProviderOptionsSchema, options) ?? {};
|
||||
const requestedTier = readCodexServiceTier(next);
|
||||
if (
|
||||
requestedTier !== this.requestedServiceTier &&
|
||||
!this.isServiceTierAvailable(requestedTier)
|
||||
) {
|
||||
throw new Error(
|
||||
`Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
if (featureId === "service_tier") {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value))
|
||||
) {
|
||||
throw new Error(
|
||||
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
this.serviceTier = value;
|
||||
this.reconcileServiceTier();
|
||||
this.cachedRuntimeInfo = null;
|
||||
return;
|
||||
}
|
||||
if (featureId === "plan_mode") {
|
||||
this.applyFeatureValue("plan_mode", Boolean(value));
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown Codex feature: ${featureId}`);
|
||||
this.providerOptions = next;
|
||||
this.requestedServiceTier = requestedTier;
|
||||
this.setPlanMode(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;
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
@@ -5340,7 +5336,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private async applyDefaultModelAndThinking(): Promise<string> {
|
||||
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
|
||||
this.config.model = model;
|
||||
this.reconcileServiceTier();
|
||||
this.config.thinkingOptionId = thinkingOptionId;
|
||||
return model;
|
||||
}
|
||||
@@ -5407,7 +5402,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
|
||||
private buildCodexInnerConfig(): Record<string, unknown> | null {
|
||||
const innerConfig: Record<string, unknown> = {};
|
||||
Object.assign(innerConfig, this.providerOptions);
|
||||
const {
|
||||
service_tier: _tier,
|
||||
plan_mode: _plan,
|
||||
fast_mode: _fast,
|
||||
...codexConfig
|
||||
} = this.providerOptions;
|
||||
Object.assign(innerConfig, codexConfig);
|
||||
if (this.deps.customCodexConfig) {
|
||||
Object.assign(innerConfig, this.deps.customCodexConfig);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js";
|
||||
import type { AgentOptionControl, AgentOptionToggleControl } from "../agent-sdk-types.js";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
@@ -9,51 +9,49 @@ export const CodexServiceTierSchema = z.object({
|
||||
});
|
||||
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
|
||||
|
||||
function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] {
|
||||
function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[] {
|
||||
if (tiers.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
description: "Choose processing speed. Faster tiers increase usage.",
|
||||
tooltip: "Select speed",
|
||||
icon: "zap",
|
||||
desktopTrigger: "icon",
|
||||
value,
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
...tiers.map((tier) => ({ value: tier.id, label: tier.name })),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = {
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
key: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
};
|
||||
|
||||
export function buildCodexFeatures(input: {
|
||||
export function buildCodexControls(input: {
|
||||
serviceTiers: CodexServiceTier[];
|
||||
serviceTier: string;
|
||||
planModeEnabled: boolean;
|
||||
planModeAvailable?: boolean;
|
||||
}): AgentFeature[] {
|
||||
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
|
||||
}): AgentOptionControl[] {
|
||||
const controls = buildCodexSpeedControl(input.serviceTiers);
|
||||
if (input.planModeAvailable !== false) {
|
||||
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled });
|
||||
controls.push({ ...CODEX_PLAN_MODE_CONTROL });
|
||||
}
|
||||
return features;
|
||||
return controls;
|
||||
}
|
||||
|
||||
export function readCodexServiceTier(values: Record<string, unknown> | undefined): string | null {
|
||||
const tier = values?.service_tier;
|
||||
export function readCodexServiceTier(options: Record<string, unknown> | undefined): string | null {
|
||||
const tier = options?.service_tier;
|
||||
if (typeof tier === "string") return tier;
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
return values?.fast_mode ? "fast" : null;
|
||||
return options?.fast_mode ? "fast" : null;
|
||||
}
|
||||
|
||||
@@ -37,6 +37,11 @@ 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
|
||||
|
||||
@@ -155,7 +155,7 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("exposes Cursor fast mode through provider features", async () => {
|
||||
test("exposes Cursor fast mode through provider controls", async () => {
|
||||
const client = new TestCursorACPAgentClient({
|
||||
sessionId: "session-1",
|
||||
models: null,
|
||||
@@ -163,43 +163,30 @@ describe("CursorACPAgentClient model discovery", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listFeatures({
|
||||
client.listControls({
|
||||
provider: "acp",
|
||||
cwd: "/tmp/cursor",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
key: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: false,
|
||||
},
|
||||
{
|
||||
type: "select",
|
||||
id: CURSOR_FAST_FEATURE_OPTION.id,
|
||||
key: CURSOR_FAST_FEATURE_OPTION.id,
|
||||
label: "Fast",
|
||||
description: "Cursor fast mode",
|
||||
tooltip: "Select Cursor fast mode",
|
||||
icon: "zap",
|
||||
value: "false",
|
||||
options: [
|
||||
{
|
||||
id: "false",
|
||||
label: "Off",
|
||||
isDefault: true,
|
||||
description: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "true",
|
||||
label: "Fast",
|
||||
isDefault: false,
|
||||
description: undefined,
|
||||
metadata: undefined,
|
||||
},
|
||||
defaultValue: "false",
|
||||
choices: [
|
||||
{ value: "false", label: "Off" },
|
||||
{ value: "true", label: "Fast" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -277,7 +264,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
// still stored after the draft moved to a model that has no fast variant.
|
||||
const warn = captureWarnings();
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||
warn.logger,
|
||||
);
|
||||
@@ -295,7 +282,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
// still listed after the model moves to one that has no fast variant.
|
||||
const warn = captureWarnings();
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||
@@ -319,7 +306,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
|
||||
test("still fails session start when a stored feature write fails for another reason", async () => {
|
||||
const session = createCursorSession(
|
||||
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
|
||||
@@ -334,7 +321,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
const session = createCursorSession({ model: "kimi-k3" });
|
||||
await session.initializeNewSession();
|
||||
|
||||
await expect(session.setFeature(CURSOR_FAST_FEATURE_OPTION.id, "true")).rejects.toThrow(
|
||||
await expect(session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" })).rejects.toThrow(
|
||||
"acp does not expose ACP feature 'fast'",
|
||||
);
|
||||
});
|
||||
@@ -343,7 +330,7 @@ describe("CursorACPAgentClient session start", () => {
|
||||
// No model switch, so the session's options came straight from session/new and the
|
||||
// provider disagreeing about a feature it just advertised is a real failure.
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false")],
|
||||
|
||||
@@ -68,7 +68,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
featureValues: { mockPromptRejections: 1 },
|
||||
providerOptions: { mockPromptRejections: 1 },
|
||||
});
|
||||
|
||||
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
|
||||
@@ -95,7 +95,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "ten-second-stream",
|
||||
featureValues: {
|
||||
providerOptions: {
|
||||
mockStreamingAssistantResponse: response,
|
||||
mockStreamingAssistantIntervalMs: 20,
|
||||
},
|
||||
@@ -467,7 +467,7 @@ describe("MockLoadTestAgentClient", () => {
|
||||
provider: "mock",
|
||||
cwd: process.cwd(),
|
||||
model: "e2e-fast-stream",
|
||||
featureValues: { mockAssistantResponses: ["First response", ""] },
|
||||
providerOptions: { 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,
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -787,7 +787,7 @@ function getConfiguredAssistantResponses(value: unknown): string[] {
|
||||
export class MockLoadTestAgentSession implements AgentSession {
|
||||
readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID;
|
||||
readonly capabilities = CAPABILITIES;
|
||||
readonly features: AgentFeature[] = [];
|
||||
readonly controls: AgentOptionControl[] = [];
|
||||
readonly id: string;
|
||||
private readonly usageSessionKey = randomUUID();
|
||||
|
||||
@@ -812,18 +812,18 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
|
||||
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
|
||||
this.assistantResponse =
|
||||
typeof options.config.featureValues?.mockAssistantResponse === "string"
|
||||
? options.config.featureValues.mockAssistantResponse
|
||||
typeof options.config.providerOptions?.mockAssistantResponse === "string"
|
||||
? options.config.providerOptions.mockAssistantResponse
|
||||
: null;
|
||||
this.assistantResponses = getConfiguredAssistantResponses(
|
||||
options.config.featureValues?.mockAssistantResponses,
|
||||
options.config.providerOptions?.mockAssistantResponses,
|
||||
);
|
||||
this.streamingAssistantResponse =
|
||||
typeof options.config.featureValues?.mockStreamingAssistantResponse === "string"
|
||||
? options.config.featureValues.mockStreamingAssistantResponse
|
||||
typeof options.config.providerOptions?.mockStreamingAssistantResponse === "string"
|
||||
? options.config.providerOptions.mockStreamingAssistantResponse
|
||||
: null;
|
||||
const requestedStreamingInterval =
|
||||
options.config.featureValues?.mockStreamingAssistantIntervalMs;
|
||||
options.config.providerOptions?.mockStreamingAssistantIntervalMs;
|
||||
this.streamingAssistantIntervalMs =
|
||||
typeof requestedStreamingInterval === "number" &&
|
||||
Number.isFinite(requestedStreamingInterval) &&
|
||||
@@ -831,10 +831,10 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
? Math.min(requestedStreamingInterval, 1_000)
|
||||
: MOCK_LOAD_TEST_INTERVAL_MS;
|
||||
this.rewindError =
|
||||
typeof options.config.featureValues?.mockRewindError === "string"
|
||||
? options.config.featureValues.mockRewindError
|
||||
typeof options.config.providerOptions?.mockRewindError === "string"
|
||||
? options.config.providerOptions.mockRewindError
|
||||
: null;
|
||||
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
|
||||
const requestedPromptRejections = options.config.providerOptions?.mockPromptRejections;
|
||||
this.remainingPromptRejections =
|
||||
typeof requestedPromptRejections === "number" &&
|
||||
Number.isSafeInteger(requestedPromptRejections) &&
|
||||
@@ -842,7 +842,7 @@ export class MockLoadTestAgentSession implements AgentSession {
|
||||
? requestedPromptRejections
|
||||
: 0;
|
||||
this.remainingSteerFailures = getPositiveFeatureInteger(
|
||||
options.config.featureValues?.mockSteerAmbiguousFailures,
|
||||
options.config.providerOptions?.mockSteerAmbiguousFailures,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -613,9 +613,9 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
|
||||
"openrouter/google/gemini-3.8-flash",
|
||||
);
|
||||
expect(omp.requireSession().features).toEqual([
|
||||
expect(omp.requireSession().controls).toEqual([
|
||||
expect.objectContaining({
|
||||
value: true,
|
||||
defaultValue: true,
|
||||
description: expect.stringMatching(/does not apply/i),
|
||||
}),
|
||||
]);
|
||||
@@ -626,12 +626,12 @@ describe("OMP agent client and session", () => {
|
||||
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
|
||||
const session = omp.requireSession();
|
||||
omp.runtime().fastModeResult = { enabled: true, active: false };
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(session.features).toEqual([
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "fast_mode",
|
||||
value: true,
|
||||
key: "fast_mode",
|
||||
defaultValue: true,
|
||||
description: expect.stringMatching(/does not apply to this model/i),
|
||||
tooltip: expect.stringMatching(/does not apply to this model/i),
|
||||
}),
|
||||
@@ -649,11 +649,11 @@ describe("OMP agent client and session", () => {
|
||||
} = omp.runtime().state;
|
||||
omp.runtime().state = olderState;
|
||||
await session.getRuntimeInfo();
|
||||
expect(session.features).toEqual([]);
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
|
||||
expect(session.controls).toEqual([]);
|
||||
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(/unavailable/i);
|
||||
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
|
||||
await session.getRuntimeInfo();
|
||||
expect(session.features).toHaveLength(1);
|
||||
expect(session.controls).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("switching model refreshes whether Fast applies", async () => {
|
||||
@@ -661,8 +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.setFeature?.("fast_mode", true);
|
||||
expect(session.features).toEqual([expect.objectContaining({ value: true })]);
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]);
|
||||
|
||||
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
|
||||
omp.runtime().queueStateReports([
|
||||
@@ -674,9 +674,9 @@ describe("OMP agent client and session", () => {
|
||||
},
|
||||
]);
|
||||
await session.setModel?.("openrouter/other/model");
|
||||
expect(session.features).toEqual([
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({
|
||||
value: true,
|
||||
defaultValue: true,
|
||||
description: expect.stringMatching(/does not apply/i),
|
||||
}),
|
||||
]);
|
||||
@@ -684,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({ featureValues: { fast_mode: true } });
|
||||
await created.start({ providerOptions: { fast_mode: true } });
|
||||
expect(created.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(created.runtime().getStateRequestCount).toBe(1);
|
||||
|
||||
const resumed = new OmpHarness();
|
||||
await resumed.resume(
|
||||
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
|
||||
{ featureValues: { fast_mode: true } },
|
||||
{ providerOptions: { fast_mode: true } },
|
||||
);
|
||||
expect(resumed.runtime().setFastModeRequests).toEqual([true]);
|
||||
expect(resumed.runtime().getStateRequestCount).toBe(1);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { mapCustomMessageToToolCall } from "../custom-message.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
@@ -9,7 +10,7 @@ import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -804,15 +805,16 @@ export class OmpAgentSession implements AgentSession {
|
||||
return this.state.sessionId;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
get controls(): AgentOptionControl[] {
|
||||
if (!hasOmpFastMode(this.state)) return [];
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
key: "fast_mode",
|
||||
label: "Fast",
|
||||
icon: "zap",
|
||||
value: this.state.fastModeEnabled === true,
|
||||
// OMP owns fast mode; an unset option stands for what the runtime reports.
|
||||
defaultValue: this.state.fastModeEnabled === true,
|
||||
...(this.state.fastModeEnabled && !this.state.fastModeActive
|
||||
? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
|
||||
: {}),
|
||||
@@ -820,9 +822,11 @@ export class OmpAgentSession implements AgentSession {
|
||||
];
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`);
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const value = options.fast_mode;
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
|
||||
if (value === this.config.providerOptions?.fast_mode) return;
|
||||
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
|
||||
const result = await this.runtimeSession.setFastMode(value);
|
||||
this.state = {
|
||||
@@ -830,7 +834,7 @@ export class OmpAgentSession implements AgentSession {
|
||||
fastModeEnabled: result.enabled,
|
||||
fastModeActive: result.active,
|
||||
};
|
||||
this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled };
|
||||
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: result.enabled };
|
||||
}
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
@@ -2295,7 +2299,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
config: AgentSessionConfig,
|
||||
state: OmpSessionState,
|
||||
): Promise<OmpSessionState> {
|
||||
const value = config.featureValues?.fast_mode;
|
||||
const value = config.providerOptions?.fast_mode;
|
||||
if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
|
||||
return state;
|
||||
const result = await runtimeSession.setFastMode(value);
|
||||
@@ -2530,7 +2534,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export { OMP_MODES };
|
||||
|
||||
export const OmpProviderOptionsSchema = z
|
||||
.object({
|
||||
// Composer control, not a runtime option: the session reads it and asks OMP to match.
|
||||
fast_mode: z.boolean().optional(),
|
||||
sessionDir: z.string().min(1).optional(),
|
||||
rpcTimeoutMs: z.number().int().positive().optional(),
|
||||
smolModel: z.string().min(1).optional(),
|
||||
|
||||
@@ -108,33 +108,22 @@ describe("OpenCode auto_accept feature", () => {
|
||||
expect(modes).toEqual([]);
|
||||
});
|
||||
|
||||
test("lists auto accept as a provider feature", async () => {
|
||||
test("lists auto accept as a provider control", async () => {
|
||||
const { runtime } = mockOpenCodeClient();
|
||||
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
const enabledFeatures = await client.listFeatures({
|
||||
const controls = await client.listControls({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const legacyFeatures = await client.listFeatures({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
modeId: "full-access",
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
|
||||
expect(enabledFeatures).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
value: true,
|
||||
}),
|
||||
expect(controls).toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
|
||||
]);
|
||||
expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
|
||||
});
|
||||
|
||||
test("keeps legacy full-access as an alias for build plus auto accept", async () => {
|
||||
@@ -151,7 +140,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
});
|
||||
|
||||
expect(await session.getCurrentMode()).toBe("build");
|
||||
expect(session.features).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
|
||||
expect(session.controls).toEqual([expect.objectContaining({ key: "auto_accept" })]);
|
||||
|
||||
await session.run("Implement the change");
|
||||
|
||||
@@ -170,7 +159,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: "full-access",
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent: null,
|
||||
unattended: false,
|
||||
availableModes: [
|
||||
@@ -178,7 +167,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "plan", label: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: "build", featureValues: { auto_accept: true } });
|
||||
).toEqual({ modeId: "build", providerOptions: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("inherits unattended callers as auto accept with OpenCode's default agent", () => {
|
||||
@@ -191,7 +180,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "bypassPermissions",
|
||||
@@ -203,7 +192,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "plan", label: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => {
|
||||
@@ -213,7 +202,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [
|
||||
@@ -221,7 +210,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "plan", label: "Plan" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => {
|
||||
@@ -231,7 +220,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent: {
|
||||
provider: "opencode",
|
||||
modeId: "paseo-custom",
|
||||
@@ -244,7 +233,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "paseo-custom", label: "Paseo Custom" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: "paseo-custom", featureValues: { auto_accept: true } });
|
||||
).toEqual({ modeId: "paseo-custom", providerOptions: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("inherits auto accept from an OpenCode parent when the child chooses a mode", () => {
|
||||
@@ -254,7 +243,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "opencode",
|
||||
requestedMode: "base",
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
parent: {
|
||||
provider: "opencode",
|
||||
modeId: "orchestrator",
|
||||
@@ -267,7 +256,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
{ id: "orchestrator", label: "Orchestrator" },
|
||||
],
|
||||
}),
|
||||
).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
|
||||
).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("auto-approves tool permissions when auto accept is enabled", async () => {
|
||||
@@ -283,7 +272,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
session.subscribe((event) => receivedEvents.push(event));
|
||||
|
||||
@@ -314,7 +303,7 @@ describe("OpenCode auto_accept feature", () => {
|
||||
const session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
session.subscribe((event) => receivedEvents.push(event));
|
||||
|
||||
|
||||
@@ -5274,7 +5274,7 @@ describe("OpenCode provider subagent contract", () => {
|
||||
const parent = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd: "/workspace/repo",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
parent.subscribe(() => undefined);
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { validateProviderOptions } from "../provider-options.js";
|
||||
import {
|
||||
createOpencodeClient,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentMode,
|
||||
type AgentModelDefinition,
|
||||
@@ -232,15 +233,15 @@ const DEFAULT_MODES: AgentMode[] = [
|
||||
];
|
||||
|
||||
function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||
return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
return config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
|
||||
function withOpenCodeAutoAcceptFeature(
|
||||
featureValues: Record<string, unknown> | undefined,
|
||||
function withOpenCodeAutoAccept(
|
||||
providerOptions: ProviderOptions | undefined,
|
||||
enabled: boolean,
|
||||
): Record<string, unknown> {
|
||||
): ProviderOptions {
|
||||
return {
|
||||
...featureValues,
|
||||
...providerOptions,
|
||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
|
||||
};
|
||||
}
|
||||
@@ -259,25 +260,25 @@ function resolveOpenCodeCreateConfig(
|
||||
const requestedMode = legacyFullAccess
|
||||
? OPENCODE_BUILD_MODE_ID
|
||||
: (input.requestedMode ?? inheritedOpenCodeMode);
|
||||
const featureValues =
|
||||
const providerOptions =
|
||||
legacyFullAccess ||
|
||||
(isUnattendedCreate && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
||||
? withOpenCodeAutoAcceptFeature(input.featureValues, true)
|
||||
: input.featureValues;
|
||||
(isUnattendedCreate && input.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
|
||||
? withOpenCodeAutoAccept(input.providerOptions, true)
|
||||
: input.providerOptions;
|
||||
|
||||
if (inheritsUnattended && requestedMode === undefined) {
|
||||
// Unattendedness for OpenCode is carried by auto_accept (set above), not
|
||||
// by any particular agent. Leave the mode unset so OpenCode uses its own
|
||||
// default agent — `build` may not exist in the user's OpenCode config.
|
||||
return { modeId: undefined, featureValues };
|
||||
return { modeId: undefined, providerOptions };
|
||||
}
|
||||
|
||||
const resolved = resolveDefaultAgentCreateConfig({
|
||||
...input,
|
||||
requestedMode,
|
||||
featureValues,
|
||||
providerOptions,
|
||||
});
|
||||
return { ...resolved, featureValues };
|
||||
return { ...resolved, providerOptions };
|
||||
}
|
||||
|
||||
function isOpenCodeCreateConfigUnattended(
|
||||
@@ -285,26 +286,18 @@ function isOpenCodeCreateConfigUnattended(
|
||||
): boolean {
|
||||
return (
|
||||
isDefaultAgentCreateConfigUnattended(input) ||
|
||||
input.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true ||
|
||||
input.features?.some(
|
||||
(feature) =>
|
||||
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
|
||||
(feature.value === true || feature.value === "true"),
|
||||
) === true
|
||||
input.config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves OpenCode tool permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: isOpenCodeAutoAcceptEnabled(config),
|
||||
};
|
||||
}
|
||||
const OPENCODE_AUTO_ACCEPT_CONTROL: AgentOptionControl = {
|
||||
type: "toggle",
|
||||
key: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves OpenCode tool permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
};
|
||||
|
||||
function buildOpenCodePermissionActions(): AgentPermissionAction[] {
|
||||
return [
|
||||
@@ -682,10 +675,7 @@ function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConf
|
||||
return {
|
||||
...normalized,
|
||||
modeId: OPENCODE_BUILD_MODE_ID,
|
||||
featureValues: {
|
||||
...normalized.featureValues,
|
||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
|
||||
},
|
||||
providerOptions: withOpenCodeAutoAccept(normalized.providerOptions, true),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1679,8 +1669,9 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
this.assertConfig(config);
|
||||
return [OPENCODE_AUTO_ACCEPT_CONTROL];
|
||||
}
|
||||
|
||||
async listImportableSessions(
|
||||
@@ -3565,8 +3556,8 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
return this.turnState.status === "running" ? this.turnState.turnId : null;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
return [buildOpenCodeAutoAcceptFeature(this.config)];
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [OPENCODE_AUTO_ACCEPT_CONTROL];
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -4956,7 +4947,15 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
const normalizedModeId = normalizeOpenCodeModeId(modeId);
|
||||
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
|
||||
this.currentMode = OPENCODE_BUILD_MODE_ID;
|
||||
await this.setFeature(OPENCODE_AUTO_ACCEPT_FEATURE_ID, true);
|
||||
this.setAutoAccept(true);
|
||||
this.notifySubscribers(
|
||||
{
|
||||
type: "options_changed",
|
||||
provider: "opencode",
|
||||
patch: { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true },
|
||||
},
|
||||
null,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4964,17 +4963,15 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.modeId = normalizedModeId ?? undefined;
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) {
|
||||
throw new Error(`Unsupported OpenCode feature '${featureId}'`);
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) {
|
||||
this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true);
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = value === true;
|
||||
private setAutoAccept(enabled: boolean): void {
|
||||
this.autoAcceptEnabled = enabled;
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
|
||||
};
|
||||
this.config.providerOptions = withOpenCodeAutoAccept(this.config.providerOptions, enabled);
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
|
||||
@@ -10,6 +10,8 @@ const PermissionRuleSchema = z.union([
|
||||
// OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46.
|
||||
export const OpenCodeProviderOptionsSchema = z
|
||||
.object({
|
||||
// Composer control: approves permission prompts without asking.
|
||||
auto_accept: z.boolean().optional(),
|
||||
permission: z
|
||||
.union([
|
||||
PermissionActionSchema,
|
||||
|
||||
@@ -100,11 +100,11 @@ test.each([
|
||||
});
|
||||
try {
|
||||
await writeFile(script, initialSource);
|
||||
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
"Auto Accept",
|
||||
);
|
||||
await writeFile(script, 'console.log("opencode v2.0.10")');
|
||||
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
|
||||
"Auto-accept",
|
||||
);
|
||||
} finally {
|
||||
@@ -202,7 +202,7 @@ test.each([
|
||||
const legacy = new OpenCodeAgentClient(logger);
|
||||
const config = { provider: "opencode", cwd: root };
|
||||
try {
|
||||
expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config));
|
||||
expect(await client.listControls(config)).toEqual(await legacy.listControls(config));
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
await legacy.shutdown();
|
||||
@@ -221,8 +221,8 @@ test("selects v2 without probing the binary when an external server is configure
|
||||
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
|
||||
});
|
||||
try {
|
||||
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
|
||||
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
|
||||
const controls = await client.listControls({ provider: "opencode", cwd: "/tmp/project" });
|
||||
expect(controls.map((control) => control.label)).toEqual(["Auto-accept"]);
|
||||
} finally {
|
||||
await client.shutdown();
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@ export class OpenCodeRuntimeClient implements AgentClient {
|
||||
async listCommands(config: AgentSessionConfig) {
|
||||
return (await this.client()).listCommands(config);
|
||||
}
|
||||
async listFeatures(config: AgentSessionConfig) {
|
||||
return (await this.client()).listFeatures(config);
|
||||
async listControls(config: AgentSessionConfig) {
|
||||
return (await this.client()).listControls(config);
|
||||
}
|
||||
async listImportableSessions(options?: ListImportableSessionsOptions) {
|
||||
return (await this.client()).listImportableSessions(options);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { OpenCodeV2Session } from "./session.js";
|
||||
import { V2_CAPABILITIES } from "./capabilities.js";
|
||||
import {
|
||||
features,
|
||||
controls,
|
||||
permissionRules,
|
||||
applyResumeOverrides,
|
||||
requiresDedicatedV2Server,
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentLaunchContext,
|
||||
AgentPersistenceHandle,
|
||||
AgentSession,
|
||||
@@ -222,8 +222,8 @@ export class OpenCodeV2AgentClient implements AgentClient {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
return features(config);
|
||||
async listControls(): Promise<AgentOptionControl[]> {
|
||||
return controls;
|
||||
}
|
||||
async listCommands(config: AgentSessionConfig) {
|
||||
const connection = await this.runtime.acquire();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
|
||||
import { controls, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
|
||||
|
||||
describe("OpenCode v2 permission rules", () => {
|
||||
test("maps authored native permissions and exact injected MCP grants", () => {
|
||||
@@ -49,17 +49,8 @@ describe("OpenCode v2 helper isolation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode v2 features", () => {
|
||||
test("reports the auto-accept toggle from the session value", () => {
|
||||
expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([
|
||||
{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: false },
|
||||
]);
|
||||
expect(
|
||||
features({
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
featureValues: { auto_accept: true },
|
||||
}),
|
||||
).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]);
|
||||
describe("OpenCode v2 controls", () => {
|
||||
test("describes the auto-accept toggle over the auto_accept option", () => {
|
||||
expect(controls).toEqual([{ type: "toggle", key: "auto_accept", label: "Auto-accept" }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,8 +2,8 @@ import type { SessionInfo, PermissionRuleset } from "@opencode/client";
|
||||
import type { V2Api } from "./api.js";
|
||||
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentOptionControl,
|
||||
AgentSessionConfig,
|
||||
} from "../../../agent-sdk-types.js";
|
||||
|
||||
@@ -11,16 +11,9 @@ import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../
|
||||
|
||||
import { modelRef } from "./mapping.js";
|
||||
|
||||
export function features(config: AgentSessionConfig): AgentFeature[] {
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto-accept",
|
||||
value: config.featureValues?.["auto_accept"] === true,
|
||||
},
|
||||
];
|
||||
}
|
||||
export const controls: AgentOptionControl[] = [
|
||||
{ type: "toggle", key: "auto_accept", label: "Auto-accept" },
|
||||
];
|
||||
export function permissionRules(config: AgentSessionConfig): PermissionRuleset {
|
||||
const rules =
|
||||
buildOpenCodePermissionRules(
|
||||
|
||||
@@ -93,7 +93,7 @@ export class SessionPermissions {
|
||||
};
|
||||
this.pending.set(request.id, pending);
|
||||
this.permissionOwners.set(request.id, sessionID);
|
||||
if (!this.config.toolPolicy && this.config.featureValues?.["auto_accept"] === true)
|
||||
if (!this.config.toolPolicy && this.config.providerOptions?.["auto_accept"] === true)
|
||||
await this.respondToPermission(request.id, { behavior: "allow" });
|
||||
else this.emit({ type: "permission_requested", provider: "opencode", request: pending });
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { SessionChildren } from "./children.js";
|
||||
import { SessionTurns } from "./turns.js";
|
||||
import { V2Timeline } from "./timeline.js";
|
||||
@@ -31,7 +32,7 @@ import { type V2Connection } from "./runtime.js";
|
||||
import { modelRef, modesFromV2 } from "./mapping.js";
|
||||
|
||||
import { V2_CAPABILITIES } from "./capabilities.js";
|
||||
import { features } from "./configuration.js";
|
||||
import { controls } from "./configuration.js";
|
||||
import { commands } from "./commands.js";
|
||||
import { messages } from "./history.js";
|
||||
import { SessionPermissions } from "./permissions.js";
|
||||
@@ -118,8 +119,8 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
get id() {
|
||||
return this.info.id;
|
||||
}
|
||||
get features() {
|
||||
return features(this.config);
|
||||
get controls() {
|
||||
return controls;
|
||||
}
|
||||
private get client() {
|
||||
return this.connection.client;
|
||||
@@ -358,10 +359,11 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
this.info = await this.client.session.get({ sessionID: this.id });
|
||||
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
|
||||
}
|
||||
async setFeature(id: string, value: unknown) {
|
||||
if (id !== "auto_accept" || typeof value !== "boolean")
|
||||
throw new Error("Unknown OpenCode feature");
|
||||
this.config.featureValues = { ...this.config.featureValues, [id]: value };
|
||||
async applyOptions(options: ProviderOptions) {
|
||||
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 };
|
||||
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 features without starting a Pi session", async () => {
|
||||
test("lists no draft controls without starting a Pi session", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
|
||||
await expect(
|
||||
client.listFeatures(createConfig({ model: "openrouter/test/model" })),
|
||||
client.listControls(createConfig({ model: "openrouter/test/model" })),
|
||||
).resolves.toEqual([]);
|
||||
|
||||
expect(pi.recordedLaunches).toHaveLength(0);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { z } from "zod";
|
||||
import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -2828,7 +2828,7 @@ export class PiRpcAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-
|
||||
import type { AgentManager } from "../agent-manager.js";
|
||||
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
|
||||
import type { DaemonConfigStore } from "../../daemon-config-store.js";
|
||||
import { featuresFromControls, foldProfileFeatureValues } from "../option-controls.js";
|
||||
import {
|
||||
AgentFeatureSchema,
|
||||
AgentPermissionRequestPayloadSchema,
|
||||
@@ -939,9 +940,6 @@ function createToolCatalog(
|
||||
...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions
|
||||
? { providerOptions: callerAgent.config.providerOptions }
|
||||
: {}),
|
||||
...(callerAgent.config.featureValues
|
||||
? { featureValues: callerAgent.config.featureValues }
|
||||
: {}),
|
||||
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
|
||||
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
|
||||
};
|
||||
@@ -1638,7 +1636,7 @@ function createToolCatalog(
|
||||
cwd: resolvedArgs.cwd,
|
||||
workspaceId: resolvedArgs.workspaceId,
|
||||
thinking: parsedArgs.settings?.thinkingOptionId,
|
||||
features: parsedArgs.settings?.features,
|
||||
providerOptions: parsedArgs.settings?.features,
|
||||
labels: {
|
||||
...parsedArgs.labels,
|
||||
...(callerAgentId && !resolvedArgs.detached && options.serverId
|
||||
@@ -2290,9 +2288,7 @@ function createToolCatalog(
|
||||
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
|
||||
}
|
||||
if (settings?.features) {
|
||||
for (const [featureId, value] of Object.entries(settings.features)) {
|
||||
await agentManager.setAgentFeature(agentId, featureId, value);
|
||||
}
|
||||
await agentManager.setAgentOptions(agentId, settings.features);
|
||||
}
|
||||
|
||||
await updateAgentCommand({ agentManager }, { agentId, name, labels });
|
||||
@@ -3070,15 +3066,17 @@ function createToolCatalog(
|
||||
"List agent profiles: named provider/model/mode bundles a human configured for specific " +
|
||||
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
|
||||
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
|
||||
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " +
|
||||
"list if none are configured.",
|
||||
"`options` into create_agent as `settings.features` (there is no `profile` parameter). " +
|
||||
"Returns an empty list if none are configured.",
|
||||
inputSchema: {},
|
||||
outputSchema: {
|
||||
profiles: z.array(AgentProfileSchema),
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
const profiles = daemonConfigStore?.get().agentProfiles ?? [];
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Stored profiles may
|
||||
// still carry `featureValues`.
|
||||
const profiles = (daemonConfigStore?.get().agentProfiles ?? []).map(foldProfileFeatureValues);
|
||||
return {
|
||||
content: [],
|
||||
structuredContent: ensureValidJson({ profiles }),
|
||||
@@ -3124,13 +3122,13 @@ function createToolCatalog(
|
||||
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
|
||||
}
|
||||
const selectedModel = settings?.model ?? resolvedProviderModel.model;
|
||||
const features = await agentManager.listDraftFeatures({
|
||||
const { controls, options: effectiveOptions } = await agentManager.listDraftControls({
|
||||
provider: providerId,
|
||||
cwd: resolvedCwd,
|
||||
...(settings?.modeId ? { modeId: settings.modeId } : {}),
|
||||
...(selectedModel ? { model: selectedModel } : {}),
|
||||
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
|
||||
...(settings?.features ? { featureValues: settings.features } : {}),
|
||||
...(settings?.features ? { providerOptions: settings.features } : {}),
|
||||
});
|
||||
return {
|
||||
content: [],
|
||||
@@ -3142,7 +3140,7 @@ function createToolCatalog(
|
||||
status: summary.status,
|
||||
modes: summary.modes,
|
||||
selectedModel: selectedModel ?? null,
|
||||
features,
|
||||
features: featuresFromControls(controls, effectiveOptions),
|
||||
}),
|
||||
};
|
||||
},
|
||||
|
||||
@@ -119,6 +119,7 @@ const INBOUND_PERMISSION = {
|
||||
list_available_providers_request: "daemon.read",
|
||||
list_commands_request: "daemon.read",
|
||||
list_provider_features_request: "daemon.read",
|
||||
"provider.controls.list.request": "daemon.read",
|
||||
list_provider_models_request: "daemon.read",
|
||||
list_provider_modes_request: "daemon.read",
|
||||
list_terminals_request: "workspace.read",
|
||||
@@ -356,6 +357,7 @@ const OUTBOUND_PERMISSION = {
|
||||
list_available_providers_response: "daemon.read",
|
||||
list_commands_response: "daemon.read",
|
||||
list_provider_features_response: "daemon.read",
|
||||
"provider.controls.list.response": "daemon.read",
|
||||
list_provider_models_response: "daemon.read",
|
||||
list_provider_modes_response: "daemon.read",
|
||||
list_terminals_response: "workspace.read",
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface HubExecutionAgentCreateInput {
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
env?: Record<string, string>;
|
||||
@@ -193,7 +192,6 @@ export class DaemonExecutions implements HubExecutionAgents {
|
||||
cwd: input.cwd,
|
||||
mode: input.modeId,
|
||||
thinking: input.thinkingOptionId,
|
||||
features: input.featureValues,
|
||||
env: input.env,
|
||||
...(input.mcpServers || input.providerOptions || input.toolPolicy
|
||||
? {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../agent/provider-options.js";
|
||||
|
||||
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
|
||||
import { foldFeatureValues } from "../agent/option-controls.js";
|
||||
|
||||
interface HubExecutionControllerOptions {
|
||||
agents: HubExecutionAgents;
|
||||
@@ -167,8 +168,8 @@ export class HubExecutionController {
|
||||
model: message.model,
|
||||
modeId: message.modeId,
|
||||
thinkingOptionId: message.thinkingOptionId,
|
||||
featureValues: message.featureValues,
|
||||
providerOptions: message.providerOptions,
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
providerOptions: foldFeatureValues(message).providerOptions,
|
||||
toolPolicy: message.toolPolicy,
|
||||
env: message.env,
|
||||
mcpServers: message.mcpServers,
|
||||
|
||||
@@ -440,7 +440,7 @@ type AcceptedCreate = Omit<
|
||||
|
||||
const providerCatalog = {
|
||||
async resolveCreateConfig(input) {
|
||||
return { modeId: input.requestedMode, featureValues: input.featureValues };
|
||||
return { modeId: input.requestedMode, providerOptions: input.providerOptions };
|
||||
},
|
||||
} satisfies CreateAgentCommandDependencies["providerSnapshotManager"];
|
||||
|
||||
|
||||
@@ -73,7 +73,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
|
||||
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
|
||||
model: record.config?.model ?? undefined,
|
||||
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
|
||||
featureValues: record.config?.featureValues ?? undefined,
|
||||
providerOptions: record.config?.providerOptions ?? undefined,
|
||||
toolPolicy: record.config?.toolPolicy ?? undefined,
|
||||
systemPrompt: record.config?.systemPrompt ?? undefined,
|
||||
@@ -95,7 +94,6 @@ export function buildSessionConfig(
|
||||
modeId: overrides.modeId,
|
||||
model: overrides.model,
|
||||
thinkingOptionId: overrides.thinkingOptionId,
|
||||
featureValues: overrides.featureValues,
|
||||
providerOptions: overrides.providerOptions,
|
||||
toolPolicy: overrides.toolPolicy,
|
||||
systemPrompt: overrides.systemPrompt,
|
||||
|
||||
@@ -88,7 +88,7 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
|
||||
async resolveCreateConfig(input) {
|
||||
return {
|
||||
modeId: input.unattended ? input.requestedMode : "interactive",
|
||||
featureValues: input.featureValues,
|
||||
providerOptions: input.providerOptions,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1911,7 +1911,7 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
return {
|
||||
modeId: input.unattended ? "bypassPermissions" : "interactive",
|
||||
featureValues: input.featureValues,
|
||||
providerOptions: input.providerOptions,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -1980,9 +1980,9 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
return {
|
||||
modeId: input.unattended ? "build" : "interactive",
|
||||
featureValues: input.unattended
|
||||
? { ...input.featureValues, auto_accept: true }
|
||||
: input.featureValues,
|
||||
providerOptions: input.unattended
|
||||
? { ...input.providerOptions, auto_accept: true }
|
||||
: input.providerOptions,
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -2010,7 +2010,7 @@ describe("ScheduleService", () => {
|
||||
expect(inspected.runs[0]?.error).toBeNull();
|
||||
expect(createdConfigs[0]).toMatchObject({
|
||||
modeId: "build",
|
||||
featureValues: { auto_accept: true },
|
||||
providerOptions: { auto_accept: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2052,7 +2052,7 @@ describe("ScheduleService", () => {
|
||||
});
|
||||
return {
|
||||
modeId: input.requestedMode,
|
||||
featureValues: { ...input.featureValues, resolved: true },
|
||||
providerOptions: { ...input.providerOptions, resolved: true },
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -2099,11 +2099,13 @@ describe("ScheduleService", () => {
|
||||
model: "test-model",
|
||||
modeId: "stored-mode",
|
||||
thinkingOptionId: "think-hard",
|
||||
// Stored schedules written before options carry `featureValues`; launch folds them.
|
||||
providerOptions: {
|
||||
allowedTools: ["Read"],
|
||||
sandbox: { enabled: true, network: { allowLocalBinding: true } },
|
||||
auto_accept: true,
|
||||
resolved: true,
|
||||
},
|
||||
featureValues: { auto_accept: true, resolved: true },
|
||||
systemPrompt: "Stay concise.",
|
||||
mcpServers: {
|
||||
docs: {
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
|
||||
import { ScheduleStore } from "./store.js";
|
||||
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
|
||||
import { foldFeatureValues } from "../agent/option-controls.js";
|
||||
import type {
|
||||
CreateScheduleInput,
|
||||
ScheduleExecutionResult,
|
||||
@@ -998,7 +999,6 @@ export class ScheduleService {
|
||||
},
|
||||
mode: config.modeId,
|
||||
thinking: config.thinkingOptionId,
|
||||
features: config.featureValues,
|
||||
unattended: true,
|
||||
promptFailure: "return-error",
|
||||
notifyOnFinish: false,
|
||||
@@ -1098,8 +1098,8 @@ function buildScheduleAgentConfig(
|
||||
model: config.model,
|
||||
thinkingOptionId: config.thinkingOptionId,
|
||||
title: config.title,
|
||||
providerOptions: config.providerOptions,
|
||||
featureValues: config.featureValues,
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
providerOptions: foldFeatureValues(config).providerOptions,
|
||||
systemPrompt: config.systemPrompt,
|
||||
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
|
||||
};
|
||||
|
||||
@@ -5816,7 +5816,7 @@ describe("agent config setters", () => {
|
||||
const messages: SessionOutboundMessage[] = [];
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }),
|
||||
agentManager: liveAgentManager({ setAgentOptions: vi.fn().mockResolvedValue(undefined) }),
|
||||
});
|
||||
|
||||
await session.handleMessage({
|
||||
@@ -5840,7 +5840,7 @@ describe("agent config setters", () => {
|
||||
const session = createSessionForTest({
|
||||
messages,
|
||||
agentManager: liveAgentManager({
|
||||
setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")),
|
||||
setAgentOptions: vi.fn().mockRejectedValue(new Error("feature boom")),
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1036,7 +1036,7 @@ export class Session {
|
||||
supportsProviderSnapshotReferences: () =>
|
||||
this.supports(CLIENT_CAPS.providerSnapshotReferences),
|
||||
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
|
||||
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
|
||||
listDraftControls: (config) => this.agentManager.listDraftControls(config),
|
||||
},
|
||||
providerSnapshotManager,
|
||||
logger: this.sessionLogger,
|
||||
@@ -1066,8 +1066,7 @@ export class Session {
|
||||
setMode: async (agentId, modeId) =>
|
||||
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
|
||||
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
|
||||
setFeature: (agentId, featureId, value) =>
|
||||
agentManager.setAgentFeature(agentId, featureId, value),
|
||||
setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
|
||||
setThinking: (agentId, thinkingOptionId) =>
|
||||
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
|
||||
},
|
||||
@@ -3181,6 +3180,8 @@ export class Session {
|
||||
return this.providerCatalogSession.handleListProviderModesRequest(msg);
|
||||
case "list_provider_features_request":
|
||||
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
|
||||
case "provider.controls.list.request":
|
||||
return this.providerCatalogSession.handleProviderControlsListRequest(msg);
|
||||
case "list_available_providers_request":
|
||||
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
|
||||
case "get_providers_snapshot_request":
|
||||
|
||||
@@ -12,7 +12,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
|
||||
readonly loadedAgentIds: string[] = [];
|
||||
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
|
||||
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
|
||||
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = [];
|
||||
readonly optionsCalls: Array<{ agentId: string; patch: Record<string, unknown> }> = [];
|
||||
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
|
||||
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
|
||||
readonly callLog: string[] = [];
|
||||
@@ -39,9 +39,9 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
|
||||
if (this.failWith) throw this.failWith;
|
||||
}
|
||||
|
||||
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
this.featureCalls.push({ agentId, featureId, value });
|
||||
this.callLog.push(`feature:${featureId}`);
|
||||
async setOptions(agentId: string, patch: Record<string, unknown>): Promise<void> {
|
||||
this.optionsCalls.push({ agentId, patch });
|
||||
this.callLog.push("options");
|
||||
if (this.failWith) throw this.failWith;
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ describe("AgentConfigSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("set feature: forwards the feature value and emits an accepted response with no notice", async () => {
|
||||
test("set feature: maps the feature value to a one-key options patch and emits an accepted response with no notice", async () => {
|
||||
const { subsystem, emitted, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleSetAgentFeatureRequest({
|
||||
@@ -217,9 +217,7 @@ describe("AgentConfigSession", () => {
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.featureCalls).toEqual([
|
||||
{ agentId: "agent-1", featureId: "web_search", value: true },
|
||||
]);
|
||||
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "set_agent_feature_response",
|
||||
@@ -309,7 +307,7 @@ describe("AgentConfigSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("apply: sets model before thinking and features last, in one accepted response", async () => {
|
||||
test("apply: sets model before thinking and options last, in one accepted response", async () => {
|
||||
const { subsystem, emitted, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
@@ -319,18 +317,16 @@ describe("AgentConfigSession", () => {
|
||||
modelId: "opus-5",
|
||||
modeId: "plan",
|
||||
thinkingOptionId: "high",
|
||||
featureValues: { webSearch: true },
|
||||
options: { webSearch: true },
|
||||
},
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]);
|
||||
expect(operations.callLog).toEqual(["model", "mode", "thinking", "options"]);
|
||||
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
|
||||
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
|
||||
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
|
||||
expect(operations.featureCalls).toEqual([
|
||||
{ agentId: "agent-1", featureId: "webSearch", value: true },
|
||||
]);
|
||||
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { webSearch: true } }]);
|
||||
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
@@ -346,6 +342,39 @@ describe("AgentConfigSession", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply: passes an options patch through, null included, in one call", async () => {
|
||||
const { subsystem, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
type: "agent.config.apply.request",
|
||||
agentId: "agent-1",
|
||||
config: { options: { fast_mode: true, service_tier: null } },
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.optionsCalls).toEqual([
|
||||
{ agentId: "agent-1", patch: { fast_mode: true, service_tier: null } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply: folds legacy featureValues into the options patch, options winning", async () => {
|
||||
const { subsystem, operations } = makeSubsystem();
|
||||
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
type: "agent.config.apply.request",
|
||||
agentId: "agent-1",
|
||||
config: {
|
||||
featureValues: { fast_mode: true, service_tier: "fast" },
|
||||
options: { fast_mode: false },
|
||||
},
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.optionsCalls).toEqual([
|
||||
{ agentId: "agent-1", patch: { fast_mode: false, service_tier: "fast" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("apply: leaves omitted fields alone", async () => {
|
||||
const { subsystem, operations } = makeSubsystem();
|
||||
|
||||
@@ -381,11 +410,11 @@ describe("AgentConfigSession", () => {
|
||||
await subsystem.handleAgentConfigApplyRequest({
|
||||
type: "agent.config.apply.request",
|
||||
agentId: "agent-1",
|
||||
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } },
|
||||
config: { modeId: "plan", thinkingOptionId: "high", options: { webSearch: true } },
|
||||
requestId: "req-1",
|
||||
});
|
||||
|
||||
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]);
|
||||
expect(operations.callLog).toEqual(["mode", "thinking", "options"]);
|
||||
expect(emitted).toEqual([
|
||||
{
|
||||
type: "agent.config.apply.response",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from "uuid";
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
|
||||
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
|
||||
import type { ProviderOptionsPatch } from "../../agent/option-controls.js";
|
||||
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
|
||||
|
||||
/**
|
||||
@@ -22,13 +23,13 @@ export interface AgentConfigSessionHost {
|
||||
* The per-agent config mutations this subsystem drives. The shell adapts these
|
||||
* onto the AgentManager and loads a closed agent before mutation (mode still
|
||||
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
|
||||
* thinking yield a provider notice; model and feature do not.
|
||||
* thinking yield a provider notice; model and options do not.
|
||||
*/
|
||||
export interface AgentConfigOperations {
|
||||
ensureLoaded(agentId: string): Promise<void>;
|
||||
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
|
||||
setModel(agentId: string, modelId: string | null): Promise<void>;
|
||||
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>;
|
||||
setOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void>;
|
||||
setThinking(
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null,
|
||||
@@ -112,8 +113,9 @@ export class AgentConfigSession {
|
||||
logLabel: "set_agent_feature_request",
|
||||
logFields: { agentId, featureId, value, requestId },
|
||||
failureText: "Failed to set agent feature",
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A feature is a one-key options patch.
|
||||
run: async () => {
|
||||
await this.operations.setFeature(agentId, featureId, value);
|
||||
await this.operations.setOptions(agentId, { [featureId]: value });
|
||||
return undefined;
|
||||
},
|
||||
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
|
||||
@@ -152,7 +154,7 @@ export class AgentConfigSession {
|
||||
|
||||
/**
|
||||
* Model first, because a thinking option only means anything against the model
|
||||
* that offers it; features last so they settle on top of the rest. An omitted
|
||||
* that offers it; options last so they settle on top of the rest. An omitted
|
||||
* field is left alone. The first notice produced is the one the client sees —
|
||||
* later steps still run, so a notice never truncates the bundle.
|
||||
*
|
||||
@@ -189,8 +191,10 @@ export class AgentConfigSession {
|
||||
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
|
||||
notice ??= thinkingNotice;
|
||||
}
|
||||
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) {
|
||||
await this.operations.setFeature(agentId, featureId, value);
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch.
|
||||
const optionsPatch = { ...config.featureValues, ...config.options };
|
||||
if (Object.keys(optionsPatch).length > 0) {
|
||||
await this.operations.setOptions(agentId, optionsPatch);
|
||||
}
|
||||
|
||||
return notice;
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type ProviderSnapshot,
|
||||
type ProviderSnapshotTransition,
|
||||
} from "../../agent/provider-snapshot-manager.js";
|
||||
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
|
||||
import type { AgentSessionConfig, ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
|
||||
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
|
||||
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
|
||||
@@ -58,7 +58,7 @@ function makeSubsystem(options: MakeOptions = {}) {
|
||||
},
|
||||
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
|
||||
listProviderAvailability: async () => [],
|
||||
listDraftFeatures: async () => [],
|
||||
listDraftControls: async () => ({ controls: [], options: {} }),
|
||||
...options.host,
|
||||
};
|
||||
const providerSnapshotManager = createStub<ProviderSnapshotManager>({
|
||||
@@ -296,10 +296,100 @@ describe("ProviderCatalogSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("lists controls and effective options for a draft config", async () => {
|
||||
const drafts: AgentSessionConfig[] = [];
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftControls: async (config) => {
|
||||
drafts.push(config);
|
||||
return {
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: true },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleProviderControlsListRequest({
|
||||
type: "provider.controls.list.request",
|
||||
requestId: "c1",
|
||||
draftConfig: {
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
});
|
||||
|
||||
expect(drafts).toEqual([
|
||||
{
|
||||
provider: "claude",
|
||||
cwd: "/tmp/project",
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { fast_mode: true },
|
||||
},
|
||||
]);
|
||||
expect(findByType(emitted, "provider.controls.list.response")?.payload).toEqual({
|
||||
provider: "claude",
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: true },
|
||||
error: null,
|
||||
fetchedAt: expect.any(String),
|
||||
requestId: "c1",
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces a controls failure inline, not as an rpc_error", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftControls: async () => {
|
||||
throw new Error("controls probe failed");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleProviderControlsListRequest({
|
||||
type: "provider.controls.list.request",
|
||||
requestId: "c2",
|
||||
draftConfig: { provider: "codex", cwd: "/tmp/project" },
|
||||
});
|
||||
|
||||
expect(findByType(emitted, "rpc_error")).toBeUndefined();
|
||||
expect(findByType(emitted, "provider.controls.list.response")?.payload.error).toBe(
|
||||
"controls probe failed",
|
||||
);
|
||||
});
|
||||
|
||||
it("answers list_provider_features_request from controls, folding legacy featureValues", async () => {
|
||||
const drafts: AgentSessionConfig[] = [];
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftControls: async (config) => {
|
||||
drafts.push(config);
|
||||
return {
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
|
||||
options: { fast_mode: true },
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await subsystem.handleListProviderFeaturesRequest({
|
||||
type: "list_provider_features_request",
|
||||
requestId: "f0",
|
||||
draftConfig: { provider: "claude", cwd: "/tmp/project", featureValues: { fast_mode: true } },
|
||||
});
|
||||
|
||||
expect(drafts[0]?.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(findByType(emitted, "list_provider_features_response")?.payload.features).toEqual([
|
||||
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces a feature-list failure inline, not as an rpc_error", async () => {
|
||||
const { subsystem, emitted } = makeSubsystem({
|
||||
host: {
|
||||
listDraftFeatures: async () => {
|
||||
listDraftControls: async () => {
|
||||
throw new Error("feature probe failed");
|
||||
},
|
||||
},
|
||||
@@ -379,7 +469,7 @@ it("announces shared content without retransmitting models or hashing discovery
|
||||
},
|
||||
supportsProviderSnapshotReferences: () => references,
|
||||
listProviderAvailability: async () => [],
|
||||
listDraftFeatures: async () => [],
|
||||
listDraftControls: async () => ({ controls: [], options: {} }),
|
||||
},
|
||||
});
|
||||
const emitted: SessionOutboundMessage[] = [];
|
||||
|
||||
@@ -13,12 +13,14 @@ import {
|
||||
} from "../../agent/provider-snapshot-manager.js";
|
||||
import {
|
||||
filterSelectableAgentModels,
|
||||
type AgentFeature,
|
||||
type AgentOptionControl,
|
||||
type AgentProvider,
|
||||
type AgentSessionConfig,
|
||||
type ProviderSnapshotEntry,
|
||||
} from "../../agent/agent-sdk-types.js";
|
||||
import type { ProviderAvailability } from "../../agent/agent-manager.js";
|
||||
import { featuresFromControls, foldFeatureValues } from "../../agent/option-controls.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { expandTilde } from "../../../utils/path.js";
|
||||
|
||||
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
|
||||
@@ -49,7 +51,9 @@ export interface ProviderCatalogSessionHost {
|
||||
supportsProviderSnapshotReferences(): boolean;
|
||||
publishSnapshot(project: () => SessionOutboundMessage | null): void;
|
||||
listProviderAvailability(): Promise<ProviderAvailability[]>;
|
||||
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||
listDraftControls(
|
||||
config: AgentSessionConfig,
|
||||
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }>;
|
||||
}
|
||||
|
||||
export interface ProviderCatalogSessionOptions {
|
||||
@@ -340,29 +344,59 @@ export class ProviderCatalogSession {
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
providerOptions?: ProviderOptions;
|
||||
}): AgentSessionConfig {
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
const { providerOptions } = foldFeatureValues(draftConfig);
|
||||
return {
|
||||
provider: draftConfig.provider,
|
||||
cwd: expandTilde(draftConfig.cwd),
|
||||
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
|
||||
...(draftConfig.model ? { model: draftConfig.model } : {}),
|
||||
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
|
||||
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}),
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async handleProviderControlsListRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "provider.controls.list.request" }>,
|
||||
): Promise<void> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
const provider = msg.draftConfig.provider;
|
||||
try {
|
||||
const { controls, options } = await this.host.listDraftControls(
|
||||
this.buildDraftAgentSessionConfig(msg.draftConfig),
|
||||
);
|
||||
this.host.emit({
|
||||
type: "provider.controls.list.response",
|
||||
payload: { provider, controls, options, error: null, fetchedAt, requestId: msg.requestId },
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
{ err: error, provider, draftConfig: msg.draftConfig },
|
||||
`Failed to list controls for ${provider}`,
|
||||
);
|
||||
this.host.emit({
|
||||
type: "provider.controls.list.response",
|
||||
payload: { provider, error: getErrorMessage(error), fetchedAt, requestId: msg.requestId },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Answered from controls.
|
||||
async handleListProviderFeaturesRequest(
|
||||
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
|
||||
): Promise<void> {
|
||||
const fetchedAt = new Date().toISOString();
|
||||
try {
|
||||
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
|
||||
const features = await this.host.listDraftFeatures(sessionConfig);
|
||||
const { controls, options } = await this.host.listDraftControls(
|
||||
this.buildDraftAgentSessionConfig(msg.draftConfig),
|
||||
);
|
||||
this.host.emit({
|
||||
type: "list_provider_features_response",
|
||||
payload: {
|
||||
provider: msg.draftConfig.provider,
|
||||
features,
|
||||
features: featuresFromControls(controls, options),
|
||||
error: null,
|
||||
fetchedAt,
|
||||
requestId: msg.requestId,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
|
||||
import { appendFile, mkdir, readFile } from "node:fs/promises";
|
||||
@@ -6,7 +7,7 @@ import path from "node:path";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
@@ -379,14 +380,13 @@ class FakeAgentSession implements AgentSession {
|
||||
return this.providerName;
|
||||
}
|
||||
|
||||
get features(): AgentFeature[] {
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
{
|
||||
type: "toggle",
|
||||
id: TEST_FEATURE_ID,
|
||||
key: TEST_FEATURE_ID,
|
||||
label: "Test feature",
|
||||
description: "Deterministic provider feature used by MCP integration tests.",
|
||||
value: this.config.featureValues?.[TEST_FEATURE_ID] === true,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -873,11 +873,8 @@ class FakeAgentSession implements AgentSession {
|
||||
this.config.modeId = modeId;
|
||||
}
|
||||
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[featureId]: value,
|
||||
};
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
this.config.providerOptions = { ...this.config.providerOptions, ...options };
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
|
||||
@@ -223,7 +223,7 @@ export function createProviderSnapshotManagerStub(): {
|
||||
const listModes = vi.fn<ProviderSnapshotManager["listModes"]>(async () => []);
|
||||
const resolveCreateConfig = vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>(async () => ({
|
||||
modeId: undefined,
|
||||
featureValues: undefined,
|
||||
providerOptions: undefined,
|
||||
}));
|
||||
const resolveDefaultModel = vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>(
|
||||
async () => undefined,
|
||||
|
||||
@@ -2007,6 +2007,7 @@ export class VoiceAssistantWebSocketServer {
|
||||
usageSetting: true,
|
||||
usageSources: true,
|
||||
ownedSubscriptions: true,
|
||||
providerOptionControls: true,
|
||||
binaryChunkingV1: true,
|
||||
agentRequestReceipts: true,
|
||||
workspaceRequestReceipts: true,
|
||||
|
||||
Reference in New Issue
Block a user