diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ccf29b17b0..f2993e18eb 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 { + async listControls(config: AgentSessionConfig): Promise { 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 { + async listControls(config: AgentSessionConfig): Promise { 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 { 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 { + 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 { + 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 { + 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"); + }); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 94250e5cdd..f43820c94a 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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; bufferedPermissionResolutions: Map< @@ -1243,11 +1248,14 @@ export class AgentManager { } } - async listDraftFeatures(config: AgentSessionConfig): Promise { + 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 { + /** + * 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 { 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 { 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 { diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index ec4186b022..fad4c3df0d 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -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 = {}): Agen return { ...base, ...overrides }; } -function createFeature(overrides: Partial = {}): 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" }, + ], + }, + ]); }); }); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index b388143d8e..6dd1ff81b8 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -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 { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 903bbe5c99..6067ed997c 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -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 | 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 | 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 } | { type: "turn_failed"; provider: AgentProvider; @@ -610,7 +612,6 @@ export interface AgentSessionConfig { modeId?: string; model?: string; thinkingOptionId?: string; - featureValues?: Record; 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; setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; - setFeature?(featureId: string, value: unknown): Promise; + /** + * 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; revertConversation?(input: { messageId: string }): Promise; revertFiles?(input: { messageId: string }): Promise; revertBoth?(input: { messageId: string }): Promise; @@ -797,7 +804,9 @@ export interface AgentClient { resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult; isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; listCommands?(config: AgentSessionConfig): Promise; - listFeatures?(config: AgentSessionConfig): Promise; + listControls?(config: AgentSessionConfig): Promise; + /** Effective options: provider defaults from `config.json` deep-merged with the agent's own. */ + effectiveOptions?(own: ProviderOptions | undefined): ProviderOptions; listImportableSessions?( options?: ListImportableSessionsOptions, ): Promise; diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 723f612085..9c8296d0f6 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -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"); diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index a097bd7f82..07dd93bfa3 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -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; +type RawStoredAgentRecord = z.infer; +type RawStoredAgentConfig = NonNullable; + +/** Records carry `providerOptions` and `controls` only; legacy fields are folded when read. */ +export type StoredAgentRecord = Omit & { + config?: Omit | 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); } diff --git a/packages/server/src/server/agent/create-agent-mode.ts b/packages/server/src/server/agent/create-agent-mode.ts index 973642e475..041ed0fc2c 100644 --- a/packages/server/src/server/agent/create-agent-mode.ts +++ b/packages/server/src/server/agent/create-agent-mode.ts @@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig( availableModes: availableModeIds, targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id, }), - featureValues: input.featureValues, + providerOptions: input.providerOptions, }; } diff --git a/packages/server/src/server/agent/create-agent/create.test.ts b/packages/server/src/server/agent/create-agent/create.test.ts index daa5ba9ec1..4943628b17 100644 --- a/packages/server/src/server/agent/create-agent/create.test.ts +++ b/packages/server/src/server/agent/create-agent/create.test.ts @@ -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[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(), diff --git a/packages/server/src/server/agent/create-agent/create.ts b/packages/server/src/server/agent/create-agent/create.ts index 77cdde673b..3e47eb0978 100644 --- a/packages/server/src/server/agent/create-agent/create.ts +++ b/packages/server/src/server/agent/create-agent/create.ts @@ -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; 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; + providerOptions?: ProviderOptions; labels?: Record; mode?: string; unattended?: boolean; @@ -218,19 +221,19 @@ async function resolveSessionCreateAgent( input: CreateAgentFromSessionInput, ): Promise { 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 }> { +}): 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; + 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; } diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index ecf2d912a5..e5a54c6820 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -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 | undefined; + providerOptions: Record | 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 | undefined }; - return { modeId: undefined, featureValues: opts.featureValues }; + const opts = input as { providerOptions: Record | 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", diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts new file mode 100644 index 0000000000..8bba34ec19 --- /dev/null +++ b/packages/server/src/server/agent/option-controls.ts @@ -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; + +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; +} + +/** + * 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 | null; + }, +>(input: T): Omit { + 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; featureValues?: Record }, +>(profile: T): Omit { + 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); +} diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index a9cf3b0ed5..1d474b513a 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -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", diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 2d20056930..a4db0f2ed6 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -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 { + async applyOptions(options: ProviderOptions): Promise { this.assertReplayComplete(); - await this.bridge.configure({ - settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, - }); + const settings: Record = {}; + 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 { @@ -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, diff --git a/packages/server/src/server/agent/provider-registry-wrap.test.ts b/packages/server/src/server/agent/provider-registry-wrap.test.ts index 04d123ead1..cfe7b56fe0 100644 --- a/packages/server/src/server/agent/provider-registry-wrap.test.ts +++ b/packages/server/src/server/agent/provider-registry-wrap.test.ts @@ -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", diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 9a2cb768f0..5cf0d67f25 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -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 { + async listControls(config: AgentSessionConfig): Promise { 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([ diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 0f3c39868a..90af6506d9 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -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), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 66d86f8931..cd30b81909 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -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(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index b10a4f8e02..7590806407 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -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 | undefined; + providerOptions: ProviderOptions | undefined; parent: ManagedAgent | null; unattended: boolean; } export interface ResolvedProviderCreateConfig { modeId: string | undefined; - featureValues: Record | 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 ?? [], }), }; diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index 7e50b32cf5..f4804a8ada 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -198,7 +198,7 @@ function createSessionWithConfig( provider?: string; modeId?: string | null; model?: string | null; - featureValues?: Record; + providerOptions?: Record; } = {}, logger: ReturnType = 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, + providerOptions?: Record, ): 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(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(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 { 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); diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 339c491e46..718f58e517 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -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 { - const autoAcceptFeature = buildACPAutoAcceptFeature(config); + async listControls(config: AgentSessionConfig): Promise { 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 { + async applyOptions(options: ProviderOptions): Promise { + 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 { 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; diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 758fad2e8f..d2daa89d29 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -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; } ).ensureQuery(); - await session.setFeature?.("fast_mode", true); + await session.applyOptions?.({ fast_mode: true }); expect(queryFactory).toHaveBeenCalledTimes(1); expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index d232185cbb..fac5e7211c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -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 { - const claudeConfig = this.assertConfig(config); - return buildClaudeFeatures({ - modelId: claudeConfig.model, - fastModeEnabled: claudeConfig.featureValues?.fast_mode === true, - }); + async listControls(config: AgentSessionConfig): Promise { + 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 { @@ -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 { - 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 { + 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 { - 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 { 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( diff --git a/packages/server/src/server/agent/providers/claude/feature-definitions.ts b/packages/server/src/server/agent/providers/claude/feature-definitions.ts index 63feb4088c..0606860745 100644 --- a/packages/server/src/server/agent/providers/claude/feature-definitions.ts +++ b/packages/server/src/server/agent/providers/claude/feature-definitions.ts @@ -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 = { +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 }] : []; } diff --git a/packages/server/src/server/agent/providers/claude/options.ts b/packages/server/src/server/agent/providers/claude/options.ts index 591accce19..3159a960cd 100644 --- a/packages/server/src/server/agent/providers/claude/options.ts +++ b/packages/server/src/server/agent/providers/claude/options.ts @@ -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(), diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index 8f3f8fc76d..081fd33133 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -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({ diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts index 9cfeca60d3..67d0230c0a 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.process-exit.test.ts @@ -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 }, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 9986357cf4..d1437fbc7c 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -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 = [ { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 2aecd18e21..ebdf3d3e31 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -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 { 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 { - // 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 { + 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 { 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 | null { const innerConfig: Record = {}; - 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); } diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index 5eb11d8a0c..d2ffdece1c 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -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; -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 = { +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 | undefined): string | null { - const tier = values?.service_tier; +export function readCodexServiceTier(options: Record | 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; } diff --git a/packages/server/src/server/agent/providers/codex/options.ts b/packages/server/src/server/agent/providers/codex/options.ts index 8efd42d783..8d540d4130 100644 --- a/packages/server/src/server/agent/providers/codex/options.ts +++ b/packages/server/src/server/agent/providers/codex/options.ts @@ -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 diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index 97b4c1cc86..c1ca1a2cdc 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -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")], diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts index da10deb869..21a2499cd8 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.test.ts @@ -68,7 +68,7 @@ describe("MockLoadTestAgentClient", () => { provider: "mock", cwd: process.cwd(), model: "ten-second-stream", - featureValues: { mockPromptRejections: 1 }, + providerOptions: { mockPromptRejections: 1 }, }); await expect(session.startTurn("Reject this prompt.")).rejects.toThrow( @@ -95,7 +95,7 @@ describe("MockLoadTestAgentClient", () => { provider: "mock", cwd: process.cwd(), model: "ten-second-stream", - featureValues: { + providerOptions: { mockStreamingAssistantResponse: response, mockStreamingAssistantIntervalMs: 20, }, @@ -467,7 +467,7 @@ describe("MockLoadTestAgentClient", () => { provider: "mock", cwd: process.cwd(), model: "e2e-fast-stream", - featureValues: { mockAssistantResponses: ["First response", "![Second](second.png)"] }, + providerOptions: { mockAssistantResponses: ["First response", "![Second](second.png)"] }, }); await session.run("Emit a synthetic turn failure."); expect((await session.run("First prompt")).finalText).toBe("First response"); diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 14322c6ccf..29b43a20f9 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -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, ); } diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 15392fc07c..9bcf54a56b 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -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); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c8b44c0bd8..3e2465b5fe 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -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 { - if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`); + async applyOptions(options: ProviderOptions): Promise { + 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 { @@ -2295,7 +2299,7 @@ export class OmpAgentClient implements AgentClient { config: AgentSessionConfig, state: OmpSessionState, ): Promise { - 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 { + async listControls(_config: AgentSessionConfig): Promise { return []; } diff --git a/packages/server/src/server/agent/providers/omp/provider-config.ts b/packages/server/src/server/agent/providers/omp/provider-config.ts index 0bcd369a1c..85da5068e3 100644 --- a/packages/server/src/server/agent/providers/omp/provider-config.ts +++ b/packages/server/src/server/agent/providers/omp/provider-config.ts @@ -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(), diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index d1a5de6f8b..af2b6e714e 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -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)); diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 8beed24be0..01c8b7fe5a 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -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); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 6fef290d91..d67a2f877d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -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 | undefined, +function withOpenCodeAutoAccept( + providerOptions: ProviderOptions | undefined, enabled: boolean, -): Record { +): 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 { - return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))]; + async listControls(config: AgentSessionConfig): Promise { + 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 { @@ -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 { - if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) { - throw new Error(`Unsupported OpenCode feature '${featureId}'`); + async applyOptions(options: ProviderOptions): Promise { + 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[] { diff --git a/packages/server/src/server/agent/providers/opencode/options.ts b/packages/server/src/server/agent/providers/opencode/options.ts index df55bedc0b..bbd05620fc 100644 --- a/packages/server/src/server/agent/providers/opencode/options.ts +++ b/packages/server/src/server/agent/providers/opencode/options.ts @@ -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, diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts index 07f312da0a..7b2ab8882b 100644 --- a/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.test.ts @@ -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(); } diff --git a/packages/server/src/server/agent/providers/opencode/runtime-client.ts b/packages/server/src/server/agent/providers/opencode/runtime-client.ts index e5fa17021a..6e65c7637e 100644 --- a/packages/server/src/server/agent/providers/opencode/runtime-client.ts +++ b/packages/server/src/server/agent/providers/opencode/runtime-client.ts @@ -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); diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.ts index 119fa0ade7..2953328c95 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.ts @@ -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 { - return features(config); + async listControls(): Promise { + return controls; } async listCommands(config: AgentSessionConfig) { const connection = await this.runtime.acquire(); diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts index 6479ca3851..cde2866681 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.test.ts @@ -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" }]); }); }); diff --git a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts index 9a565e6fca..ac41063b8c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/configuration.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/configuration.ts @@ -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( diff --git a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts index cb33e0652e..f71092bb77 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/permissions.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/permissions.ts @@ -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 }); } diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index a167ec0713..867de53c90 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -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") diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 7dc3fd184c..77e2d88775 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -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); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index 8051c57fa9..ae33170803 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -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 { + async listControls(_config: AgentSessionConfig): Promise { return []; } diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 0ca6c5a3e4..e6ce1dbbf0 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -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), }), }; }, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index f7a5b17857..c972771bf8 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -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", diff --git a/packages/server/src/server/hub/daemon-executions.ts b/packages/server/src/server/hub/daemon-executions.ts index ec629c3fc9..c55b6e3258 100644 --- a/packages/server/src/server/hub/daemon-executions.ts +++ b/packages/server/src/server/hub/daemon-executions.ts @@ -23,7 +23,6 @@ export interface HubExecutionAgentCreateInput { model?: string; modeId?: string; thinkingOptionId?: string; - featureValues?: Record; providerOptions?: ProviderOptions; toolPolicy?: ToolPolicy; env?: Record; @@ -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 ? { diff --git a/packages/server/src/server/hub/execution-controller.ts b/packages/server/src/server/hub/execution-controller.ts index 4199433d3d..23343b4e61 100644 --- a/packages/server/src/server/hub/execution-controller.ts +++ b/packages/server/src/server/hub/execution-controller.ts @@ -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, diff --git a/packages/server/src/server/hub/test-utils/relationship-harness.ts b/packages/server/src/server/hub/test-utils/relationship-harness.ts index 84726d0b02..fc77e6f446 100644 --- a/packages/server/src/server/hub/test-utils/relationship-harness.ts +++ b/packages/server/src/server/hub/test-utils/relationship-harness.ts @@ -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"]; diff --git a/packages/server/src/server/persistence-hooks.ts b/packages/server/src/server/persistence-hooks.ts index e95743d6be..31d8c45ed0 100644 --- a/packages/server/src/server/persistence-hooks.ts +++ b/packages/server/src/server/persistence-hooks.ts @@ -73,7 +73,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial { }); 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: { diff --git a/packages/server/src/server/schedule/service.ts b/packages/server/src/server/schedule/service.ts index 6121b7e6bd..c8c9db2263 100644 --- a/packages/server/src/server/schedule/service.ts +++ b/packages/server/src/server/schedule/service.ts @@ -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"], }; diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index d1408f9880..ced45f9623 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -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")), }), }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 00c8529482..4770d76c7f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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": diff --git a/packages/server/src/server/session/agent-config/agent-config-session.test.ts b/packages/server/src/server/session/agent-config/agent-config-session.test.ts index 1592c5bb3d..d6589683a7 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.test.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.test.ts @@ -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 }> = []; 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 { - this.featureCalls.push({ agentId, featureId, value }); - this.callLog.push(`feature:${featureId}`); + async setOptions(agentId: string, patch: Record): Promise { + 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", diff --git a/packages/server/src/server/session/agent-config/agent-config-session.ts b/packages/server/src/server/session/agent-config/agent-config-session.ts index 8cf2394192..b7ba697670 100644 --- a/packages/server/src/server/session/agent-config/agent-config-session.ts +++ b/packages/server/src/server/session/agent-config/agent-config-session.ts @@ -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; setMode(agentId: string, modeId: string): Promise; setModel(agentId: string, modelId: string | null): Promise; - setFeature(agentId: string, featureId: string, value: unknown): Promise; + setOptions(agentId: string, patch: ProviderOptionsPatch): Promise; 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; diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index c1cec0b42a..704b6fd21b 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -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({ @@ -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[] = []; diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index af04199bca..7a4f3200fa 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -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; - listDraftFeatures(config: AgentSessionConfig): Promise; + listDraftControls( + config: AgentSessionConfig, + ): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }>; } export interface ProviderCatalogSessionOptions { @@ -340,29 +344,59 @@ export class ProviderCatalogSession { model?: string; thinkingOptionId?: string; featureValues?: Record; + 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, + ): Promise { + 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, ): Promise { 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, diff --git a/packages/server/src/server/test-utils/fake-agent-client.ts b/packages/server/src/server/test-utils/fake-agent-client.ts index 4304a767e1..01e17836e4 100644 --- a/packages/server/src/server/test-utils/fake-agent-client.ts +++ b/packages/server/src/server/test-utils/fake-agent-client.ts @@ -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 { - this.config.featureValues = { - ...this.config.featureValues, - [featureId]: value, - }; + async applyOptions(options: ProviderOptions): Promise { + this.config.providerOptions = { ...this.config.providerOptions, ...options }; } getPendingPermissions(): AgentPermissionRequest[] { diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index 922f673935..2e1349b906 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -223,7 +223,7 @@ export function createProviderSnapshotManagerStub(): { const listModes = vi.fn(async () => []); const resolveCreateConfig = vi.fn(async () => ({ modeId: undefined, - featureValues: undefined, + providerOptions: undefined, })); const resolveDefaultModel = vi.fn( async () => undefined, diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 7089812d21..02a66dd64b 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2007,6 +2007,7 @@ export class VoiceAssistantWebSocketServer { usageSetting: true, usageSources: true, ownedSubscriptions: true, + providerOptionControls: true, binaryChunkingV1: true, agentRequestReceipts: true, workspaceRequestReceipts: true,