Keep stored-record reads tolerant of a legacy option the conversion rejects

This commit is contained in:
Mohamed Boudra
2026-11-08 14:34:01 +01:00
parent e054b905b5
commit e2a2535f92
3 changed files with 47 additions and 3 deletions
@@ -13600,6 +13600,27 @@ describe("agent options", () => {
]);
});
test("a stored record with a malformed legacy speed still reads; opening it is what rejects it", () => {
const manager = new AgentManager({ clients: { codex: new OptionsClient() }, logger });
// An older daemon accepted any value here; this one rejects it on create and on change.
const record = parseStoredAgentRecord({
id: "agent-legacy-malformed",
provider: "codex",
cwd: "/repo",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
lastStatus: "closed",
config: { providerOptions: { fast_mode: "false" } },
persistence: { provider: "codex", sessionId: "thread-2" },
});
const payload = buildStoredAgentPayload(record, ["codex"], (provider, own) =>
manager.effectiveOptionsFor(provider, own),
);
expect(payload.options).toEqual({ service_tier: "default", fast_mode: "false" });
});
test("setAgentOptions hands the provider only the keys the patch sets", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent({ web_search: true });
@@ -68,6 +68,7 @@ import {
applyProviderOptionsPatch,
exposeFeatureValuesToHook,
convertLegacyOptions,
MalformedLegacyOptionError,
resolveAdapter,
foldHookFeatureValues,
type ProviderOptionsPatch,
@@ -2578,14 +2579,28 @@ export class AgentManager {
/**
* What an agent of this provider runs with, given its own options: the defaults under them.
* Stored records are read through here before they are reopened, so the legacy conversion
* runs here too; a record converted on open passes through unchanged.
* runs here too; a record converted on open passes through unchanged. A legacy value the
* conversion cannot read stays as stored: this describes the record, and opening or changing
* the agent is what rejects the value.
*/
effectiveOptionsFor(provider: AgentProvider, own: ProviderOptions | undefined): ProviderOptions {
const client = this.clients.get(provider);
const converted = this.convertLegacyOptions(provider, own);
const converted = this.convertStoredOptions(provider, own);
return client ? this.launchOptionsFor({ client, own: converted }) : (converted ?? {});
}
private convertStoredOptions(
provider: AgentProvider,
own: ProviderOptions | undefined,
): ProviderOptions | undefined {
try {
return this.convertLegacyOptions(provider, own);
} catch (error) {
if (error instanceof MalformedLegacyOptionError) return own;
throw error;
}
}
async setTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim();
@@ -40,6 +40,14 @@ interface LegacyOptionsInput<O> {
options: O;
}
/** A legacy option value the conversion cannot read. */
export class MalformedLegacyOptionError extends Error {
constructor(message: string) {
super(message);
this.name = "MalformedLegacyOptionError";
}
}
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown>>,
): Record<string, unknown>;
@@ -53,7 +61,7 @@ export function convertLegacyOptions(
if (provider !== "codex" || !options || !("fast_mode" in options)) return options;
const { fast_mode: legacy, ...rest } = options;
if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") {
throw new Error("Codex fast_mode must be true, false or null");
throw new MalformedLegacyOptionError("Codex fast_mode must be true, false or null");
}
if ("service_tier" in rest || legacy === undefined) return rest;
if (legacy === null) return { ...rest, service_tier: null };