diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index f2993e18eb..b80c637458 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2429,6 +2429,18 @@ test("listDraftControls uses client feature listing without a model", async () = providerOptions: { auto_accept: true }, }, ]); + + // Options without a control come back too. + await expect( + manager.listDraftControls({ + provider: "codex", + cwd: workdir, + providerOptions: { auto_accept: true, sandbox_mode: "workspace-write" }, + }), + ).resolves.toEqual({ + controls: [draftControl], + options: { auto_accept: true, sandbox_mode: "workspace-write" }, + }); }); test("listDraftControls uses explicit model config without default model fetching", async () => { @@ -12741,8 +12753,8 @@ describe("agent options", () => { 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 }); + expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true }); + expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true }); }); test("setAgentOptions on a running agent replaces top-level keys and applies the effective options", async () => { @@ -12772,6 +12784,54 @@ describe("agent options", () => { expect(agent?.effectiveOptions).toEqual({ service_tier: "default" }); }); + test("shows the option as the session resolved it while the stored intent stays", async () => { + class ResolvingSession extends OptionsSession { + displayOptions(options: ProviderOptions): ProviderOptions { + return options.service_tier === "fast" ? { ...options, service_tier: "priority" } : options; + } + } + class ResolvingClient extends OptionsClient { + override async createSession(config: AgentSessionConfig): Promise { + return new ResolvingSession(config); + } + } + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-")); + const manager = new AgentManager({ + clients: { codex: new ResolvingClient() }, + registry: new AgentStorage(join(workdir, "agents"), logger), + logger, + }); + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, providerOptions: { service_tier: "fast" } }, + undefined, + { workspaceId: undefined }, + ); + + const agent = manager.getAgent(created.id); + expect(agent?.config.providerOptions).toEqual({ service_tier: "fast" }); + expect(agent?.effectiveOptions).toEqual({ service_tier: "priority" }); + }); + + test("an option without a control is settable through the patch and readable in the snapshot", async () => { + const { manager, client, storage, agentId } = await createOptionsAgent(); + + await manager.setAgentOptions(agentId, { sandbox_mode: "read-only" }); + + expect(client.session?.applied).toEqual([ + { service_tier: "default", sandbox_mode: "read-only" }, + ]); + const agent = manager.getAgent(agentId); + expect(agent?.config.providerOptions).toEqual({ sandbox_mode: "read-only" }); + expect(toAgentPayload(agent!).options).toEqual({ + service_tier: "default", + sandbox_mode: "read-only", + }); + await manager.flush(); + expect((await storage.get(agentId))?.config?.providerOptions).toEqual({ + sandbox_mode: "read-only", + }); + }); + test("a provider that rejects an option leaves the stored options unchanged", async () => { const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false }); client.session!.rejectWith = new Error("fast mode unavailable"); @@ -12807,4 +12867,75 @@ describe("agent options", () => { expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ fast_mode: true }); expect(manager.getAgent(created.id)?.config).not.toHaveProperty("featureValues"); }); + + test("agent.create.before hook featureValues edit overrides an option the create already set", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new OptionsClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + pluginLifecycle: { + emit: () => {}, + // A plugin written against the previous SDK: flips one value, leaves the rest. + before: async (name, request) => + name === "agent.create.before" + ? { + ...request, + config: { + ...request.config, + featureValues: { ...request.config.featureValues, fast_mode: true }, + }, + } + : request, + }, + }); + + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } }, + undefined, + { workspaceId: undefined }, + ); + + expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ + fast_mode: true, + plan_mode: true, + }); + }); + + test("agent.create.before hook leaving featureValues untouched keeps providerOptions edits", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new OptionsClient(); + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + pluginLifecycle: { + emit: () => {}, + before: async (name, request) => + name === "agent.create.before" + ? { + ...request, + config: { + ...request.config, + providerOptions: { ...request.config.providerOptions, plan_mode: false }, + }, + } + : request, + }, + }); + + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } }, + undefined, + { workspaceId: undefined }, + ); + + expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ + fast_mode: false, + plan_mode: false, + }); + }); }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index f43820c94a..84c9ec99bc 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -63,7 +63,8 @@ import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; import { toStoredAgentRecord } from "./agent-projections.js"; import { applyProviderOptionsPatch, - foldFeatureValues, + exposeFeatureValuesToHook, + foldHookFeatureValues, type ProviderOptionsPatch, } from "./option-controls.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; @@ -1270,7 +1271,10 @@ export class AgentManager { const session = await client.createSession(normalizedConfig); try { - return { controls: session.controls ?? [], options }; + return { + controls: session.controls ?? [], + options: session.displayOptions?.(options) ?? options, + }; } finally { try { await session.close(); @@ -1369,13 +1373,14 @@ export class AgentManager { this.assertAcceptingAgentRegistrations(); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); if (this.pluginLifecycle && !config.internal) { + const sent = exposeFeatureValuesToHook(config); const request = await this.pluginLifecycle.before("agent.create.before", { - config, + config: sent, env: options.env, }); // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written // against the previous SDK edits `config.featureValues`. - config = { ...foldFeatureValues(request.config), internal: config.internal }; + config = { ...foldHookFeatureValues(request.config, sent), internal: config.internal }; options = { ...options, env: request.env }; } await this.deleteAgentState(resolvedAgentId); @@ -5245,7 +5250,11 @@ export class AgentManager { if ("session" in agent && agent.session?.controls) { agent.controls = agent.session.controls; } - agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions); + const effective = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions); + agent.effectiveOptions = + "session" in agent && agent.session?.displayOptions + ? agent.session.displayOptions(effective) + : effective; } 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 fad4c3df0d..67809aef60 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -479,16 +479,16 @@ describe("toAgentPayload", () => { }, ]; // `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. + // Options without a control are part of the snapshot too. const agent = createManagedAgent({ controls, - effectiveOptions: { fast_mode: true, sandbox_workspace_write: { writable_roots: ["/x"] } }, + effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" }, }); const payload = toAgentPayload(agent); expect(payload.controls).toEqual(controls); - expect(payload.options).toEqual({ fast_mode: true }); + expect(payload.options).toEqual({ fast_mode: true, sandbox_mode: "workspace-write" }); expect(payload.features).toEqual([ { type: "toggle", id: "fast_mode", label: "Fast mode", value: true }, { diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 6dd1ff81b8..caeffe25e7 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -148,7 +148,7 @@ export function toAgentPayload( // 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) } + ? { options: sanitizeEffectiveOptions(agent.effectiveOptions) } : {}), controls: normalizeControls(agent.controls), pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions), @@ -463,20 +463,9 @@ function normalizeControls( 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 sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions { + const sanitized = sanitizeOptionalJson(options); + return sanitized && isJsonObject(sanitized) ? sanitized : {}; } 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 6067ed997c..ce0193fbdd 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -722,6 +722,12 @@ export interface AgentSession { * the daemon stores the patch only when this resolves. */ applyOptions?(options: ProviderOptions): Promise; + /** + * The effective options as this session honors them, for display. The daemon keeps the + * user's stored intent; a value the provider resolves differently (a renamed tier, an + * unavailable choice) is shown as the provider resolved it. + */ + displayOptions?(options: ProviderOptions): ProviderOptions; revertConversation?(input: { messageId: string }): Promise; revertFiles?(input: { messageId: string }): Promise; revertBoth?(input: { messageId: string }): Promise; diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index e5a54c6820..28b1914bf3 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -5867,7 +5867,7 @@ describe("agent profile listing MCP tool", () => { model: "claude-test-model", modeId: "bypassPermissions", thinkingOptionId: "high", - featureValues: { fast_mode: true }, + options: { fast_mode: true }, notes: "Use for UI work: components, layout, design tokens. Not for backend.", }, ]; @@ -5882,7 +5882,7 @@ describe("agent profile listing MCP tool", () => { const response = await tool.handler({}); - // Profiles saved before options carry `featureValues`; the tool reports them as `options`. + // The config store holds profiles with `options` only; the tool reports them as stored. expect(response.structuredContent).toEqual({ profiles: [ { diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 8bba34ec19..21c27c0d58 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from "node:util"; import type { AgentFeature, AgentOptionControl, @@ -52,14 +53,64 @@ export function foldFeatureValues< /** * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - * Profiles saved before options carry `featureValues`; readers see `options`. + * A plugin written against the previous SDK reads and edits `config.featureValues`, so a + * hook sees the config's options there. Keys the hook changed come back as options and + * override what the config already held; keys it left alone do not. */ -export function foldProfileFeatureValues< - T extends { options?: Record; featureValues?: Record }, ->(profile: T): Omit { +export function exposeFeatureValuesToHook< + T extends { providerOptions?: ProviderOptions | null; featureValues?: Record }, +>(config: T): T { + if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config; + return { ...config, featureValues: { ...config.providerOptions } }; +} + +export function foldHookFeatureValues< + T extends { providerOptions?: ProviderOptions | null; featureValues?: Record }, +>(returned: T, sent: T): Omit { + const { featureValues, ...rest } = returned; + if (!featureValues) return rest; + const before = sent.featureValues ?? {}; + const edits: Record = {}; + for (const [key, value] of Object.entries(featureValues)) { + if (!isDeepStrictEqual(value, before[key])) edits[key] = value; + } + return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions, ...edits } }; +} + +interface ProfileOptionFields { + options?: Record; + featureValues?: Record; +} + +/** + * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. + * Profiles saved before options carry `featureValues`. When the profile comes from storage, + * `options` wins over a stale `featureValues` key. When it comes from a client write, the + * client that sent `featureValues` is the unchanged app, which only knows that field, so its + * `featureValues` win over the `options` it received from the previous read. + */ +export function foldProfileFeatureValues( + profile: T, + source: "stored" | "client write" = "stored", +): Omit { const { featureValues, ...rest } = profile; if (!featureValues) return rest; - return { ...rest, options: { ...featureValues, ...rest.options } }; + return { + ...rest, + options: + source === "stored" + ? { ...featureValues, ...rest.options } + : { ...rest.options, ...featureValues }, + }; +} + +/** + * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. + * The unchanged app reads a profile's options from `featureValues`; the config API answers with + * both fields. + */ +export function withLegacyProfileFeatureValues(profile: T): T { + return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile; } function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) { diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 90af6506d9..38ab8ec3f8 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -474,6 +474,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession setModel: inner.setModel?.bind(inner), setThinkingOption: inner.setThinkingOption?.bind(inner), applyOptions: inner.applyOptions?.bind(inner), + displayOptions: inner.displayOptions?.bind(inner), revertConversation: inner.revertConversation?.bind(inner), revertFiles: inner.revertFiles?.bind(inner), revertBoth: inner.revertBoth?.bind(inner), 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 f4804a8ada..844aadd19d 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1601,6 +1601,29 @@ describe("ACPAgentSession Zed parity", () => { }); }); + test("stops auto-accepting permissions once auto_accept is removed from the options", async () => { + const session = createSessionWithConfig({ provider: "generic-acp" }); + const internals = asInternals(session); + internals.sessionId = "session-1"; + internals.connection = {}; + + await session.applyOptions({ auto_accept: true }); + await session.applyOptions({}); + + const permission = session.requestPermission({ + sessionId: "session-1", + toolCall: { + toolCallId: "tool-1", + title: "Run command", + kind: "execute", + status: "pending", + }, + options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }], + } satisfies RequestPermissionRequest); + await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1)); + void permission; + }); + test("surfaces an ACP permission when auto-accept has no allow option", async () => { const session = createSessionWithConfig({ providerOptions: { auto_accept: true } }); const events: Array<{ type: string; request?: { id: string } }> = []; @@ -1803,6 +1826,27 @@ describe("ACPAgentSession Zed parity", () => { ]); }); + test("returns a select to its session default once its option is removed", async () => { + const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({ + configOptions: [copilotAgentConfigOption(request.value)], + })); + const session = createCopilotSessionWithConfig(); + const { internals } = prepareConfiguredOverrideSession(session, { + configOptions: [copilotAgentConfigOption("")], + connection: { setSessionConfigOption }, + }); + asInternals<{ recordConfigFeatureDefaults(): void }>(internals).recordConfigFeatureDefaults(); + + await session.applyOptions({ agent: "Probe Agent" }); + await session.applyOptions({}); + + expect(setSessionConfigOption).toHaveBeenLastCalledWith({ + sessionId: "session-1", + configId: "agent", + value: "", + }); + }); + test("sets Copilot custom agent through ACP config options", async () => { const setSessionConfigOption = vi.fn(async () => ({ configOptions: [copilotAgentConfigOption("Probe Agent")], diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 718f58e517..8e3cba69e2 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -1757,6 +1757,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { private currentTitle: string | null = null; private lastActivityAt: string | null = null; private configOptions: SessionConfigOption[] = []; + private readonly configFeatureDefaults = new Map(); private cachedCommands: AgentSlashCommand[] = []; /** When the last command batch arrived; null while nothing has been published. */ private commandsUpdatedAt: number | null = null; @@ -2391,14 +2392,38 @@ export class ACPAgentSession implements AgentSession, ACPClient { }); } - 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]); - } + /** + * The value a select reported before Paseo wrote to it. An option removed from the agent's + * options returns the select to this value. + */ + private recordConfigFeatureDefaults(): void { 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; + if (this.configFeatureDefaults.has(featureOption.id)) continue; + const current = findSelectConfigFeatureOption( + this.configOptions, + featureOption, + )?.currentValue; + if (current != null) this.configFeatureDefaults.set(featureOption.id, current); + } + } + + async applyOptions(options: ProviderOptions): Promise { + await this.setConfigFeature( + ACP_AUTO_ACCEPT_FEATURE_ID, + options[ACP_AUTO_ACCEPT_FEATURE_ID] === true, + ); + for (const featureOption of this.configFeatureOptions) { + const explicit = Object.prototype.hasOwnProperty.call(options, featureOption.id); + const value = explicit + ? options[featureOption.id] + : this.configFeatureDefaults.get(featureOption.id); + if (value === undefined) continue; + const option = findSelectConfigFeatureOption(this.configOptions, featureOption); + if (option && value === option.currentValue) continue; + if (!explicit && (!option || !findSelectConfigChoice({ option, value: String(value) }))) { + // A default recorded under a previous model may not exist on the current one. + continue; + } await this.setConfigFeature(featureOption.id, value); } } @@ -2972,6 +2997,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { : response; this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []); + this.recordConfigFeatureDefaults(); const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions); this.availableModes = modeInfo.modes; 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 d2daa89d29..99a8056349 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -18,6 +18,9 @@ import { resolveClaudeCodeVersion, toClaudeSdkMcpConfig, } from "./agent.js"; +import { AgentManager } from "../../agent-manager.js"; +import { ensureAgentLoaded } from "../../agent-loading.js"; +import { AgentStorage } from "../../agent-storage.js"; import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js"; import { claudeProjectDirSync } from "./project-dir.js"; import { streamSession } from "../test-utils/session-stream-adapter.js"; @@ -1501,6 +1504,90 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + + test("retries a fast mode change the runtime rejected", async () => { + const { queryFactory, queryMock } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-4-8", + }); + await (session as unknown as { ensureQuery(): Promise }).ensureQuery(); + queryMock.applyFlagSettings.mockClear(); + queryMock.applyFlagSettings.mockRejectedValueOnce(new Error("runtime unavailable")); + + await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow( + "runtime unavailable", + ); + await session.applyOptions?.({ fast_mode: true }); + + expect(queryMock.applyFlagSettings).toHaveBeenCalledTimes(2); + expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true }); + + await session.close(); + }); + + test("fast mode survives a daemon restart through the stored providerOptions", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-")); + const storagePath = path.join(root, "agents"); + const makeClient = () => { + const mock = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory: mock.queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + return { ...mock, client }; + }; + const agentId = "00000000-0000-4000-8000-0000000000f1"; + const before = makeClient(); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { claude: before.client }, + registry: storage, + logger, + }); + try { + await manager.createAgent( + { provider: "claude", cwd: root, model: "claude-opus-4-8" }, + agentId, + { workspaceId: undefined }, + ); + await manager.setAgentOptions(agentId, { fast_mode: true }); + await manager.closeAgent(agentId); + await manager.flush(); + await storage.flush(); + + const after = makeClient(); + const restartedStorage = new AgentStorage(storagePath, logger); + const restarted = new AgentManager({ + clients: { claude: after.client }, + registry: restartedStorage, + logger, + }); + const agent = await ensureAgentLoaded(agentId, { + agentManager: restarted, + agentStorage: restartedStorage, + logger, + }); + const session = (agent as unknown as { session: { ensureQuery(): Promise } | null }) + .session; + await session?.ensureQuery(); + + expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true }); + expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({ + fastMode: true, + }); + await restarted.closeAgent(agentId); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); }); describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index fac5e7211c..9c8f70bd1c 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2807,11 +2807,12 @@ class ClaudeAgentSession implements AgentSession { } 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; } + // Held until the runtime accepts, so a rejected change is retried instead of skipped. + this.config.providerOptions = providerOptions; } getPendingPermissions(): AgentPermissionRequest[] { 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 081fd33133..77e83d7412 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 @@ -505,6 +505,12 @@ describe("Codex app-server provider controls", () => { }); }); + test("applyOptions accepts an option that has no control", async () => { + const { session } = await createConnectedSession(); + + await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).resolves.toBeUndefined(); + }); + test("startTurn includes serviceTier when fast mode is enabled", async () => { const { session, appServer } = await createConnectedSession(); @@ -516,6 +522,28 @@ describe("Codex app-server provider controls", () => { }); }); + test("shows a saved Fast preference as the tier the model resolves it to", async () => { + const { session, appServer } = await createConnectedSession({ + providerOptions: { service_tier: "fast" }, + }); + + // Stored intent stays "fast"; the model's Fast tier is "priority", which is what runs and what is shown. + expect(session.displayOptions?.({ service_tier: "fast" })).toEqual({ + service_tier: "priority", + }); + expect(session.displayOptions?.({ fast_mode: true })).toMatchObject({ + service_tier: "priority", + }); + expect(session.displayOptions?.({ plan_mode: true })).toEqual({ plan_mode: true }); + expect(session.displayOptions?.({ service_tier: "default" })).toEqual({ + service_tier: "default", + }); + expect(session.displayOptions?.({ service_tier: "gone" })).toEqual({}); + + await session.startTurn("hello"); + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" }); + }); + test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => { const capture = createCapturedLogger(); const prompt = "secret prompt text should not be logged"; 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 ebdf3d3e31..85b02231fc 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 @@ -3861,7 +3861,10 @@ export class CodexAppServerAgentSession implements AgentSession { } private get serviceTier(): string | null { - const requested = this.requestedServiceTier; + return this.resolveServiceTier(this.requestedServiceTier); + } + + private resolveServiceTier(requested: string | null): string | null { // Until the model list arrives there is nothing to check the request against. if (this.speedModels.length === 0 || requested === "default") return requested; const tiers = this.currentServiceTiers(); @@ -4670,6 +4673,13 @@ export class CodexAppServerAgentSession implements AgentSession { this.cachedRuntimeInfo = null; } + displayOptions(options: ProviderOptions): ProviderOptions { + if (readCodexServiceTier(options) === null) return options; + const { service_tier: _stored, ...rest } = options; + const resolved = this.resolveServiceTier(readCodexServiceTier(options)); + return resolved === null ? rest : { ...rest, service_tier: resolved }; + } + private isServiceTierAvailable(tier: string | null): boolean { if (tier === null || tier === "default") return true; const tiers = this.currentServiceTiers(); 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 af2b6e714e..b7601bb3bb 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 @@ -290,6 +290,33 @@ describe("OpenCode auto_accept feature", () => { await session.close(); }); + test("stops auto-approving once auto_accept is removed from the options", async () => { + const { openCodeClient, runtime } = mockOpenCodeClient({ + events: [toolPermissionEvent(), idleEvent()], + }); + + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + providerOptions: { auto_accept: true }, + }); + + // The daemon passes effective options; with the key removed and no config default it is absent. + await session.applyOptions?.({}); + await session.run("Run verification"); + + expect(openCodeClient.calls.permissionReply).toEqual([]); + expect(session.getPendingPermissions()).toEqual([ + expect.objectContaining({ id: "permission-1" }), + ]); + + await session.close(); + }); + test("keeps questions separate from auto accept tool approval", async () => { const { openCodeClient, runtime } = mockOpenCodeClient({ events: [questionEvent(), idleEvent()], diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d67a2f877d..ea541f4542 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4964,9 +4964,7 @@ class OpenCodeAgentSession implements AgentSession { } async applyOptions(options: ProviderOptions): Promise { - if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) { - this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true); - } + this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true); } private setAutoAccept(enabled: boolean): void { 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 867de53c90..4bb3a2877f 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -360,8 +360,8 @@ export class OpenCodeV2Session implements AgentSession { this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); } async applyOptions(options: ProviderOptions) { - const value = options["auto_accept"]; - if (value === undefined) return; + // An absent key is the default: approvals are asked for. + const value = options["auto_accept"] ?? false; 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) diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index e6ce1dbbf0..3309a2415e 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -18,7 +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 { featuresFromControls } from "../option-controls.js"; import { AgentFeatureSchema, AgentPermissionRequestPayloadSchema, @@ -3074,9 +3074,7 @@ function createToolCatalog( }, }, async () => { - // 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); + const profiles = daemonConfigStore?.get().agentProfiles ?? []; return { content: [], structuredContent: ensureValidJson({ profiles }), diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index d9b2e94181..ab0c125336 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -3,7 +3,11 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; +import { + DaemonConfigStore, + applyMutableProviderConfigToOverrides, + toWireDaemonConfig, +} from "./daemon-config-store.js"; import { loadConfig } from "./config.js"; import { loadPersistedConfig } from "./persisted-config.js"; import { readSpeechSettings } from "./speech/speech-settings.js"; @@ -196,13 +200,83 @@ describe("DaemonConfigStore", () => { model: "claude-opus-5", modeId: "plan", thinkingOptionId: "think-hard", - featureValues: { webSearch: true }, + options: { webSearch: true }, notes: "Use for components, layout and design tokens.", }, ]); expect(store.get().agentProfiles).toHaveLength(1); }); + test("a profile written with featureValues is stored as options, and the config API answers with both", async () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + }); + // The unchanged app sends back the `options` it read plus the `featureValues` it edited. + await store.patch({ + agentProfiles: [ + { + id: "p", + name: "P", + provider: "claude", + options: { fast_mode: false }, + featureValues: { fast_mode: true }, + }, + ], + }); + + expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([ + { id: "p", name: "P", provider: "claude", options: { fast_mode: true } }, + ]); + expect(store.get().agentProfiles).toEqual([ + { id: "p", name: "P", provider: "claude", options: { fast_mode: true } }, + ]); + expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([ + { + id: "p", + name: "P", + provider: "claude", + options: { fast_mode: true }, + featureValues: { fast_mode: true }, + }, + ]); + }); + + test("a profile stored with featureValues loads as options", () => { + const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); + tempDirs.push(paseoHome); + const store = new DaemonConfigStore(paseoHome, { + relay: { enabled: false }, + mcp: { injectIntoAgents: false }, + browserTools: { enabled: false }, + providers: {}, + metadataGeneration: { providers: [] }, + autoArchiveAfterMerge: false, + enableTerminalAgentHooks: false, + appendSystemPrompt: "", + agentProfiles: [ + { + id: "legacy", + name: "Legacy", + provider: "codex", + featureValues: { service_tier: "fast" }, + }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } }, + ]); + }); + test("patch replaces the whole agent profile list rather than merging entries", async () => { const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); tempDirs.push(paseoHome); diff --git a/packages/server/src/server/daemon-config-store.ts b/packages/server/src/server/daemon-config-store.ts index e83874235f..9713082b93 100644 --- a/packages/server/src/server/daemon-config-store.ts +++ b/packages/server/src/server/daemon-config-store.ts @@ -5,6 +5,11 @@ import { type PersistedConfig, } from "./persisted-config.js"; import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; +import { + foldProfileFeatureValues, + withLegacyProfileFeatureValues, +} from "./agent/option-controls.js"; +import type { AgentProfile } from "@getpaseo/protocol/agent-profile"; import { MutableDaemonConfigSchema, MutableDaemonConfigPatchSchema, @@ -168,6 +173,24 @@ function omitProvidersFromOverrides( return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; } +/** Profiles are held and persisted with `options` only. */ +function foldConfigProfiles( + config: T, + source: "stored" | "client write", +): T { + if (!config.agentProfiles) return config; + return { + ...config, + agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues(profile, source)), + }; +} + +/** The config API boundary: the unchanged app reads profile options from `featureValues`. */ +export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig { + if (!config.agentProfiles) return config; + return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) }; +} + function getValueAtPath(config: MutableDaemonConfig, path: string): unknown { return path .split(".") @@ -380,7 +403,7 @@ export class DaemonConfigStore { this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); this.lastKnownPersisted = this.startupPersisted; this.current = MutableDaemonConfigSchema.parse({ - ...initial, + ...foldConfigProfiles(initial, "stored"), relay: initial.relay ?? { enabled: true }, ...this.readSpeech(this.startupPersisted), }); @@ -418,7 +441,8 @@ export class DaemonConfigStore { "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", ); } - const { removeProviders = [], ...patch } = parsedPatch; + const { removeProviders = [], ...submittedPatch } = parsedPatch; + const patch = foldConfigProfiles(submittedPatch, "client write"); const { features: speechPatch, ...configPatch } = patch; const removedProviders = Array.from(new Set(removeProviders)); const merged = deepMerge(this.current, configPatch); @@ -495,7 +519,7 @@ export class DaemonConfigStore { // Plugin source changes require the plugin lifecycle operation or a daemon // restart. The global switch is independently reloadable. const desired = MutableDaemonConfigSchema.parse({ - ...resolved.mutable, + ...foldConfigProfiles(resolved.mutable, "stored"), plugins: this.current.plugins, ...this.readSpeech(persisted), }); diff --git a/packages/server/src/server/hub/daemon-executions.test.ts b/packages/server/src/server/hub/daemon-executions.test.ts index d78b29d732..65c2a9617b 100644 --- a/packages/server/src/server/hub/daemon-executions.test.ts +++ b/packages/server/src/server/hub/daemon-executions.test.ts @@ -86,7 +86,10 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent cwd: response.payload.agent.cwd, }); expect(JSON.stringify(response.payload.agent)).not.toContain(bearer); - expect(JSON.stringify(response.payload.agent)).not.toContain("private-build"); + // Provider options are not secret and appear in the snapshot's `options`. + expect(response.payload.agent.options).toMatchObject({ + sandbox_workspace_write: { writable_roots: ["/var/cache/private-build"] }, + }); expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution"); const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update"); @@ -104,7 +107,6 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent cwd: update.payload.agent.cwd, }); expect(JSON.stringify(update.payload.agent)).not.toContain(bearer); - expect(JSON.stringify(update.payload.agent)).not.toContain("private-build"); expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution"); }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4770d76c7f..7c4f3405a2 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -80,7 +80,7 @@ import { createWorkspaceScriptsService, type WorkspaceScriptsService, } from "./session/workspace-scripts/workspace-scripts-service.js"; -import type { DaemonConfigStore } from "./daemon-config-store.js"; +import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; @@ -2949,7 +2949,10 @@ export class Session { case "get_daemon_config_request": this.emit({ type: "get_daemon_config_response", - payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() }, + payload: { + requestId: msg.requestId, + config: toWireDaemonConfig(this.daemonConfigStore.get()), + }, }); return undefined; case "daemon.get_status.request": @@ -2973,7 +2976,7 @@ export class Session { type: "set_daemon_config_response", payload: { requestId: msg.requestId, - config, + config: toWireDaemonConfig(config), }, }); });