mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 22:23:07 +00:00
fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces
- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone. - OMP: removing fast_mode restores the runtime's starting setting. - ACP/Cursor removal tests run through real session initialization. - agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e). - Daemon config store persists legacy-featureValues profiles as options on the next profile save.
This commit is contained in:
@@ -158,6 +158,11 @@ export interface AgentOptionChoice {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/**
|
||||
* Other stored option values that select this choice. A saved value the provider has since
|
||||
* renamed still reads back as this choice.
|
||||
*/
|
||||
aliases?: string[];
|
||||
}
|
||||
|
||||
interface AgentOptionControlBase {
|
||||
|
||||
@@ -365,6 +365,7 @@ export const AgentOptionChoiceSchema = z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
aliases: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const AgentOptionToggleControlSchema = z.object({
|
||||
|
||||
@@ -12784,20 +12784,31 @@ 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;
|
||||
test("the snapshot reports the configured options and resolves aliases through controls", async () => {
|
||||
class AliasSession extends OptionsSession {
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
class ResolvingClient extends OptionsClient {
|
||||
class AliasClient extends OptionsClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new ResolvingSession(config);
|
||||
return new AliasSession(config);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new ResolvingClient() },
|
||||
clients: { codex: new AliasClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
@@ -12807,9 +12818,11 @@ describe("agent options", () => {
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
const agent = manager.getAgent(created.id);
|
||||
expect(agent?.config.providerOptions).toEqual({ service_tier: "fast" });
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "priority" });
|
||||
const payload = toAgentPayload(manager.getAgent(created.id)!);
|
||||
expect(payload.options).toEqual({ service_tier: "fast" });
|
||||
expect(payload.features).toEqual([
|
||||
expect.objectContaining({ id: "service_tier", value: "priority" }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("an option without a control is settable through the patch and readable in the snapshot", async () => {
|
||||
|
||||
@@ -1271,10 +1271,7 @@ export class AgentManager {
|
||||
|
||||
const session = await client.createSession(normalizedConfig);
|
||||
try {
|
||||
return {
|
||||
controls: session.controls ?? [],
|
||||
options: session.displayOptions?.(options) ?? options,
|
||||
};
|
||||
return { controls: session.controls ?? [], options };
|
||||
} finally {
|
||||
try {
|
||||
await session.close();
|
||||
@@ -5250,11 +5247,7 @@ export class AgentManager {
|
||||
if ("session" in agent && agent.session?.controls) {
|
||||
agent.controls = agent.session.controls;
|
||||
}
|
||||
const effective = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
|
||||
agent.effectiveOptions =
|
||||
"session" in agent && agent.session?.displayOptions
|
||||
? agent.session.displayOptions(effective)
|
||||
: effective;
|
||||
agent.effectiveOptions = this.effectiveOptionsFor(agent.provider, agent.config.providerOptions);
|
||||
}
|
||||
|
||||
private checkAndSetAttention(agent: ManagedAgent): void {
|
||||
|
||||
@@ -722,12 +722,6 @@ 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>;
|
||||
|
||||
@@ -115,8 +115,11 @@ export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(pr
|
||||
|
||||
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
|
||||
const value = options[control.key];
|
||||
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
|
||||
return value;
|
||||
if (typeof value === "string") {
|
||||
const choice = control.choices.find(
|
||||
(candidate) => candidate.value === value || candidate.aliases?.includes(value),
|
||||
);
|
||||
if (choice) return choice.value;
|
||||
}
|
||||
return control.defaultValue ?? null;
|
||||
}
|
||||
|
||||
@@ -474,7 +474,6 @@ 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,29 +1601,6 @@ 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 } }> = [];
|
||||
@@ -1826,27 +1803,6 @@ 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")],
|
||||
|
||||
+17
-15
@@ -1,3 +1,4 @@
|
||||
import { featuresFromControls } from "../option-controls.js";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
@@ -49,7 +50,7 @@ function speedControl() {
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -165,7 +166,7 @@ describe("Codex app-server provider controls", () => {
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
{ value: "ultrafast", label: "Ultrafast" },
|
||||
],
|
||||
}),
|
||||
@@ -304,7 +305,7 @@ describe("Codex app-server provider controls", () => {
|
||||
key: "service_tier",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
{ value: "priority", label: "Fast", aliases: ["fast"] },
|
||||
],
|
||||
}),
|
||||
);
|
||||
@@ -522,23 +523,24 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("shows a saved Fast preference as the tier the model resolves it to", async () => {
|
||||
test("a saved Fast preference selects the model's Fast tier without rewriting the stored option", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { service_tier: "fast" },
|
||||
});
|
||||
|
||||
// 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",
|
||||
// The model's Fast tier is "priority". The choice names "fast" as an alias, so a client reading
|
||||
// `options` + `controls` selects it, and the legacy features projection agrees.
|
||||
const speed = session.controls.find((control) => control.key === "service_tier");
|
||||
expect(speed).toMatchObject({
|
||||
choices: expect.arrayContaining([
|
||||
expect.objectContaining({ value: "priority", aliases: ["fast"] }),
|
||||
]),
|
||||
});
|
||||
expect(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({});
|
||||
expect(
|
||||
featuresFromControls(session.controls, { service_tier: "fast" }).find(
|
||||
(feature) => feature.id === "service_tier",
|
||||
),
|
||||
).toMatchObject({ value: "priority" });
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
|
||||
|
||||
@@ -4673,13 +4673,6 @@ 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();
|
||||
|
||||
@@ -23,7 +23,14 @@ function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[]
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
...tiers.map((tier) => ({ value: tier.id, label: tier.name })),
|
||||
...tiers.map((tier) => ({
|
||||
value: tier.id,
|
||||
label: tier.name,
|
||||
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
|
||||
...(tier.id !== "fast" && tier.name.toLowerCase() === "fast"
|
||||
? { aliases: ["fast"] }
|
||||
: {}),
|
||||
})),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -215,7 +215,9 @@ describe("CursorACPAgentClient session start", () => {
|
||||
session: {
|
||||
currentModelId: string;
|
||||
configOptions: SessionConfigOption[];
|
||||
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>;
|
||||
setSessionConfigOption?: (request: {
|
||||
value: string;
|
||||
}) => Promise<{ configOptions: SessionConfigOption[] }>;
|
||||
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
|
||||
logger = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
@@ -326,6 +328,52 @@ describe("CursorACPAgentClient session start", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("removing auto_accept stops answering permission requests on its own", async () => {
|
||||
const session = createCursorSession({ model: "composer-2.5" });
|
||||
await session.initializeNewSession();
|
||||
const request = {
|
||||
sessionId: "session-1",
|
||||
toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" },
|
||||
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
|
||||
} as const;
|
||||
|
||||
await session.applyOptions({ auto_accept: true });
|
||||
await expect(session.requestPermission({ ...request })).resolves.toEqual({
|
||||
outcome: { outcome: "selected", optionId: "allow-always" },
|
||||
});
|
||||
|
||||
await session.applyOptions({});
|
||||
void session.requestPermission({ ...request });
|
||||
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
|
||||
});
|
||||
|
||||
test("removing the Fast option returns the session to the value it started with", async () => {
|
||||
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
|
||||
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
|
||||
}));
|
||||
const session = createCursorSession(
|
||||
{ model: "composer-2.5" },
|
||||
{
|
||||
currentModelId: "composer-2.5",
|
||||
configOptions: [fastConfigOption("false")],
|
||||
setSessionConfigOption,
|
||||
},
|
||||
);
|
||||
await session.initializeNewSession();
|
||||
|
||||
await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" });
|
||||
// `{ fast: null }` with no config.json default reaches the provider as no key at all.
|
||||
await session.applyOptions({});
|
||||
|
||||
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual([
|
||||
"true",
|
||||
"false",
|
||||
]);
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({ key: "fast", defaultValue: "false" }),
|
||||
);
|
||||
});
|
||||
|
||||
test("still fails session start when the provider rejects a write it should accept", async () => {
|
||||
// No model switch, so the session's options came straight from session/new and the
|
||||
// provider disagreeing about a feature it just advertised is a real failure.
|
||||
|
||||
@@ -638,6 +638,27 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("removing the fast_mode option restores the runtime's own setting", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
const session = omp.requireSession();
|
||||
expect(omp.runtime().state.fastModeEnabled).toBe(false);
|
||||
|
||||
omp.runtime().fastModeResult = { enabled: true, active: true };
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([true]);
|
||||
|
||||
// `{ fast_mode: null }` with no config.json default reaches the provider as no key at all.
|
||||
omp.runtime().fastModeResult = { enabled: false, active: false };
|
||||
await session.applyOptions?.({});
|
||||
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([true, false]);
|
||||
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: false })]);
|
||||
// A later change to an unrelated option leaves the runtime alone.
|
||||
await session.applyOptions?.({});
|
||||
expect(omp.runtime().setFastModeRequests).toEqual([true, false]);
|
||||
});
|
||||
|
||||
test("shows Fast only when OMP reports its state fields", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
@@ -203,6 +203,8 @@ interface OmpAgentSessionOptions {
|
||||
}>;
|
||||
config: AgentSessionConfig;
|
||||
initialState: OmpSessionState;
|
||||
/** Fast mode as the runtime reported it before any option was applied; removal restores it. */
|
||||
fastModeBaseline?: boolean;
|
||||
currentModeId?: string | null;
|
||||
logger: Logger;
|
||||
providerIdleScheduler?: OmpProviderIdleScheduler;
|
||||
@@ -501,6 +503,10 @@ function isOmpSteerTransportFailure(error: unknown): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function fastModeBaselineOf(state: OmpSessionState): boolean | undefined {
|
||||
return typeof state.fastModeEnabled === "boolean" ? state.fastModeEnabled : undefined;
|
||||
}
|
||||
|
||||
function hasOmpFastMode(state: OmpSessionState): boolean {
|
||||
return typeof state.fastModeEnabled === "boolean" && typeof state.fastModeActive === "boolean";
|
||||
}
|
||||
@@ -751,6 +757,7 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.restartRuntime = options.restartRuntime;
|
||||
this.config = options.config;
|
||||
this.state = options.initialState;
|
||||
this.fastModeBaseline = options.fastModeBaseline;
|
||||
this.currentModeId = options.currentModeId ?? null;
|
||||
this.logger = options.logger;
|
||||
this.live = options.live ?? true;
|
||||
@@ -823,20 +830,28 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
const value = options.fast_mode;
|
||||
if (value === undefined) return;
|
||||
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
|
||||
if (value === this.config.providerOptions?.fast_mode) return;
|
||||
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
|
||||
const result = await this.runtimeSession.setFastMode(value);
|
||||
this.state = {
|
||||
...this.state,
|
||||
fastModeEnabled: result.enabled,
|
||||
fastModeActive: result.active,
|
||||
};
|
||||
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: result.enabled };
|
||||
const requested = options.fast_mode;
|
||||
if (requested !== undefined && typeof requested !== "boolean") {
|
||||
throw new Error("OMP fast mode requires a boolean");
|
||||
}
|
||||
// An absent option means the runtime's own setting at session start.
|
||||
const target = requested ?? this.fastModeBaseline;
|
||||
if (target !== undefined && target !== this.state.fastModeEnabled) {
|
||||
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
|
||||
const result = await this.runtimeSession.setFastMode(target);
|
||||
this.state = {
|
||||
...this.state,
|
||||
fastModeEnabled: result.enabled,
|
||||
fastModeActive: result.active,
|
||||
};
|
||||
}
|
||||
const { fast_mode: _previous, ...others } = this.config.providerOptions ?? {};
|
||||
this.config.providerOptions =
|
||||
requested === undefined ? others : { ...others, fast_mode: requested };
|
||||
}
|
||||
|
||||
private readonly fastModeBaseline: boolean | undefined;
|
||||
|
||||
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
|
||||
return runProviderTurn({
|
||||
prompt,
|
||||
@@ -2333,12 +2348,10 @@ export class OmpAgentClient implements AgentClient {
|
||||
config,
|
||||
startInput.env,
|
||||
);
|
||||
const initialState = await this.restoreFastMode(
|
||||
runtimeSession,
|
||||
config,
|
||||
await runtimeSession.getState(),
|
||||
);
|
||||
const startState = await runtimeSession.getState();
|
||||
const initialState = await this.restoreFastMode(runtimeSession, config, startState);
|
||||
return new OmpAgentSession({
|
||||
fastModeBaseline: fastModeBaselineOf(startState),
|
||||
runtimeSession,
|
||||
hostTools,
|
||||
restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
|
||||
@@ -2410,6 +2423,7 @@ export class OmpAgentClient implements AgentClient {
|
||||
};
|
||||
const initialState = await this.restoreFastMode(runtimeSession, config, resumedState);
|
||||
return new OmpAgentSession({
|
||||
fastModeBaseline: fastModeBaselineOf(resumedState),
|
||||
runtimeSession,
|
||||
hostTools,
|
||||
restartRuntime: this.buildRestartRuntime(startInput, config, launchContext),
|
||||
|
||||
@@ -180,6 +180,56 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => {
|
||||
const clients = createTestAgentClients();
|
||||
const codex = clients.codex as AgentClient;
|
||||
const applied: unknown[] = [];
|
||||
// Records what reaches the provider session; behavior is the fake client's.
|
||||
const recordingCodex: AgentClient = Object.create(codex, {
|
||||
createSession: {
|
||||
value: async (...args: Parameters<AgentClient["createSession"]>) => {
|
||||
const session = await codex.createSession(...args);
|
||||
const apply = session.applyOptions?.bind(session);
|
||||
session.applyOptions = async (options) => {
|
||||
applied.push(options);
|
||||
await apply?.(options);
|
||||
};
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: { ...clients, codex: recordingCodex },
|
||||
});
|
||||
const client = new DaemonClient({
|
||||
url: `ws://127.0.0.1:${daemon.port}/ws`,
|
||||
appVersion: "0.1.82",
|
||||
});
|
||||
|
||||
try {
|
||||
await client.connect();
|
||||
await client.fetchAgents({ subscribe: {} });
|
||||
const agent = await client.createAgent({
|
||||
provider: "codex",
|
||||
cwd: tmpCwd(),
|
||||
title: "Options agent",
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4-mini",
|
||||
});
|
||||
expect(agent.options?.sandbox_mode).toBeUndefined();
|
||||
|
||||
await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } });
|
||||
|
||||
expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]);
|
||||
const next = await client.fetchAgent(agent.id);
|
||||
expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" });
|
||||
expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode");
|
||||
} finally {
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({
|
||||
|
||||
@@ -1348,6 +1348,35 @@ describe("DaemonConfigStore reload", () => {
|
||||
expect(store.get().agentProfiles).toBeUndefined();
|
||||
});
|
||||
|
||||
test("reload folds a profile file written with featureValues into options, and saving it persists options", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore();
|
||||
writeConfig(paseoHome, {
|
||||
...persisted,
|
||||
daemon: {
|
||||
...persisted.daemon,
|
||||
agentProfiles: [
|
||||
{
|
||||
id: "legacy",
|
||||
name: "Legacy",
|
||||
provider: "codex",
|
||||
featureValues: { service_tier: "fast" },
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await store.reload();
|
||||
|
||||
expect(store.get().agentProfiles).toEqual([
|
||||
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
|
||||
]);
|
||||
// The app reads the profile and writes the list back on its next edit.
|
||||
await store.patch({ agentProfiles: store.get().agentProfiles });
|
||||
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
|
||||
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("reports a launch-controlled edit without changing live state", async () => {
|
||||
const { paseoHome, store, persisted } = createReloadableStore({
|
||||
overrideControlledPaths: ["daemon.relay.enabled"],
|
||||
|
||||
@@ -461,7 +461,16 @@ export class DaemonConfigStore {
|
||||
const configChanged = !isEqualValue(this.current, next);
|
||||
|
||||
// A replaced API key leaves the speech view unchanged, so a speech patch always persists.
|
||||
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) {
|
||||
// Profiles held folded in memory may still be on disk with `featureValues`; saving them writes `options`.
|
||||
const profilesStaleOnDisk =
|
||||
patch.agentProfiles !== undefined &&
|
||||
!isEqualValue(patch.agentProfiles, this.lastKnownPersisted.daemon?.agentProfiles);
|
||||
if (
|
||||
!configChanged &&
|
||||
removedProviders.length === 0 &&
|
||||
speechPatch === undefined &&
|
||||
!profilesStaleOnDisk
|
||||
) {
|
||||
return this.current;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user