mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
fix(server): address Phase 1 audit findings on option controls
- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default. - agent.create.before hook featureValues edits override options the create already set. - Claude commits fast_mode only after the runtime accepts it. - Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions). - Profiles are folded into options in the daemon config store; the config API still answers with featureValues. - Snapshot options and provider.controls.list carry all effective options. - Test: Claude fast_mode survives a restart through stored providerOptions.
This commit is contained in:
@@ -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<AgentSession> {
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -722,6 +722,12 @@ export interface AgentSession {
|
||||
* the daemon stores the patch only when this resolves.
|
||||
*/
|
||||
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||
/**
|
||||
* 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<void>;
|
||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||
revertBoth?(input: { messageId: string }): Promise<void>;
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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<string, unknown>; featureValues?: Record<string, unknown> },
|
||||
>(profile: T): Omit<T, "featureValues"> {
|
||||
export function exposeFeatureValuesToHook<
|
||||
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
|
||||
>(config: T): T {
|
||||
if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config;
|
||||
return { ...config, featureValues: { ...config.providerOptions } };
|
||||
}
|
||||
|
||||
export function foldHookFeatureValues<
|
||||
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
|
||||
>(returned: T, sent: T): Omit<T, "featureValues"> {
|
||||
const { featureValues, ...rest } = returned;
|
||||
if (!featureValues) return rest;
|
||||
const before = sent.featureValues ?? {};
|
||||
const edits: Record<string, unknown> = {};
|
||||
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<string, unknown>;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Profiles saved before options carry `featureValues`. When the profile comes from storage,
|
||||
* `options` wins over a stale `featureValues` key. When it comes from a client write, the
|
||||
* client that sent `featureValues` is the unchanged app, which only knows that field, so its
|
||||
* `featureValues` win over the `options` it received from the previous read.
|
||||
*/
|
||||
export function foldProfileFeatureValues<T extends ProfileOptionFields>(
|
||||
profile: T,
|
||||
source: "stored" | "client write" = "stored",
|
||||
): Omit<T, "featureValues"> {
|
||||
const { featureValues, ...rest } = profile;
|
||||
if (!featureValues) return rest;
|
||||
return { ...rest, options: { ...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<T extends ProfileOptionFields>(profile: T): T {
|
||||
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
|
||||
}
|
||||
|
||||
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<ACPSessionInternals>(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")],
|
||||
|
||||
@@ -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<string, string>();
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
|
||||
@@ -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<unknown> }).ensureQuery();
|
||||
queryMock.applyFlagSettings.mockClear();
|
||||
queryMock.applyFlagSettings.mockRejectedValueOnce(new Error("runtime unavailable"));
|
||||
|
||||
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
|
||||
"runtime unavailable",
|
||||
);
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenCalledTimes(2);
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("fast mode survives a daemon restart through the stored providerOptions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-"));
|
||||
const storagePath = path.join(root, "agents");
|
||||
const makeClient = () => {
|
||||
const mock = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: mock.queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
return { ...mock, client };
|
||||
};
|
||||
const agentId = "00000000-0000-4000-8000-0000000000f1";
|
||||
const before = makeClient();
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { claude: before.client },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await manager.createAgent(
|
||||
{ provider: "claude", cwd: root, model: "claude-opus-4-8" },
|
||||
agentId,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||
await manager.closeAgent(agentId);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
const after = makeClient();
|
||||
const restartedStorage = new AgentStorage(storagePath, logger);
|
||||
const restarted = new AgentManager({
|
||||
clients: { claude: after.client },
|
||||
registry: restartedStorage,
|
||||
logger,
|
||||
});
|
||||
const agent = await ensureAgentLoaded(agentId, {
|
||||
agentManager: restarted,
|
||||
agentStorage: restartedStorage,
|
||||
logger,
|
||||
});
|
||||
const session = (agent as unknown as { session: { ensureQuery(): Promise<unknown> } | null })
|
||||
.session;
|
||||
await session?.ensureQuery();
|
||||
|
||||
expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
|
||||
fastMode: true,
|
||||
});
|
||||
await restarted.closeAgent(agentId);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()],
|
||||
|
||||
@@ -4964,9 +4964,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<T extends { agentProfiles?: AgentProfile[] }>(
|
||||
config: T,
|
||||
source: "stored" | "client write",
|
||||
): T {
|
||||
if (!config.agentProfiles) return config;
|
||||
return {
|
||||
...config,
|
||||
agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues(profile, source)),
|
||||
};
|
||||
}
|
||||
|
||||
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
|
||||
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
|
||||
if (!config.agentProfiles) return config;
|
||||
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
|
||||
}
|
||||
|
||||
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
|
||||
return path
|
||||
.split(".")
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user