From c50bd49cb563347b137f7b2c7043670ae5309eb4 Mon Sep 17 00:00:00 2001 From: Thierno Bah Date: Wed, 12 Aug 2026 20:05:50 +0200 Subject: [PATCH 1/3] fix(agents): preserve recovery tool maps Recovery reparsing changes task content, not tool definitions. Keep the original bridge maps to avoid charging the translator budget twice, and make the plaintext retention warning explicit. Refs #92 --- src/server/index.ts | 2 +- src/server/responses/core.ts | 1 - tests/agent-task-recovery.test.ts | 46 ++++++++++++++++++++++++++++ tests/helpers/agent-task-recovery.ts | 6 ++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/src/server/index.ts b/src/server/index.ts index b9be581e23..0a5a8a36f3 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -485,7 +485,7 @@ export function warnAgentTaskRecoveryStartup(config: { if (config.agentTaskRecovery?.enabled !== true) return; console.warn("⚠️ Experimental encrypted V2 task recovery is enabled."); console.warn(" A scoped cache miss may send an additional authenticated request to ChatGPT and may consume quota or add latency; concurrent misses can share one request."); - console.warn(" Recovered model output is retained only in a bounded in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); + console.warn(" Recovered plaintext assignment data is retained only in a bounded, process-local in-memory cache; exact fidelity is not guaranteed and the path depends on undocumented backend behavior."); } export function startServer(port?: number, deps: StartServerDeps = {}): Server { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 89800f1cb8..7fd2b87b03 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -1779,7 +1779,6 @@ async function handleResponsesInner( ); } } - toolBridgeMaps = buildToolBridgeMaps(parsed, translatorBudget); } catch { unreadableEncryptedAgentTask = true; } diff --git a/tests/agent-task-recovery.test.ts b/tests/agent-task-recovery.test.ts index f0e1ae05c8..5e40dda367 100644 --- a/tests/agent-task-recovery.test.ts +++ b/tests/agent-task-recovery.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { createTranslatorBudget } from "../src/lib/translator-budget"; import { warnAgentTaskRecoveryStartup } from "../src/server"; import { resetAgentTaskRecoveryState } from "../src/server/responses/agent-task-recovery"; import { agentTaskRecoveryWaiterCountForTests } from "../src/server/responses/agent-task-recovery-cache"; @@ -116,6 +117,8 @@ describe("agent task recovery (opt-in, default off)", () => { const warnings = capture({ enabled: true }); expect(warnings).toHaveLength(3); expect(warnings.join("\n")).toContain("Experimental encrypted V2 task recovery is enabled"); + expect(warnings.join("\n")).toContain("Recovered plaintext assignment data"); + expect(warnings.join("\n")).toContain("process-local in-memory cache"); expect(warnings.join("\n")).not.toContain(secret); } finally { console.warn = originalWarn; @@ -176,6 +179,49 @@ describe("agent task recovery (opt-in, default off)", () => { expect(forwardedBodies[1].match(/Message Type: NEW_TASK/g)).toHaveLength(1); }); + test("charges namespaced tool bridge maps only once across recovery reparse", async () => { + globalThis.fetch = (async (input) => { + if (String(input).includes("chatgpt.com")) { + return new Response(recoverySse("Use the advertised tool."), { status: 200 }); + } + return providerResponse(); + }) as typeof fetch; + const namespace = "mcp__review"; + const name = "read_file"; + const wireName = `${namespace}__${name}`; + const mappingBytes = new TextEncoder().encode(JSON.stringify([wireName, namespace, name])).byteLength; + const budget = createTranslatorBudget(); + const originalCharge = budget.chargeRetained.bind(budget); + const mappingCharges: number[] = []; + budget.chargeRetained = (bytes, scope) => { + if (scope.kind === "retained_collectors" && bytes === mappingBytes) mappingCharges.push(bytes); + originalCharge(bytes, scope); + }; + + try { + const response = await post( + routedConfig(), + "xai/grok-4.5", + encryptedInput(), + codexHeaders(), + undefined, + { + translatorBudget: budget, + tools: [{ + type: "namespace", + name: namespace, + tools: [{ type: "function", name, parameters: { type: "object" } }], + }], + }, + ); + + expect(response.status).toBe(200); + expect(mappingCharges).toHaveLength(1); + } finally { + budget.dispose(); + } + }); + test("accepts function-call-arguments SSE events", async () => { const assignment = "Handle the recovered task."; let providerBody = ""; diff --git a/tests/helpers/agent-task-recovery.ts b/tests/helpers/agent-task-recovery.ts index 1e0e953694..bf8a173f70 100644 --- a/tests/helpers/agent-task-recovery.ts +++ b/tests/helpers/agent-task-recovery.ts @@ -1,4 +1,5 @@ import { handleResponses } from "../../src/server/responses"; +import type { TranslatorBudget } from "../../src/lib/translator-budget"; import type { OcxConfig } from "../../src/types"; export const originalFetch = globalThis.fetch; @@ -146,6 +147,7 @@ export async function post( input: unknown[], headers: HeadersInit = {}, abortSignal?: AbortSignal, + options: { tools?: unknown[]; translatorBudget?: TranslatorBudget } = {}, ): Promise { return handleResponses(new Request("http://localhost/v1/responses", { method: "POST", @@ -153,8 +155,8 @@ export async function post( "content-type": "application/json", ...Object.fromEntries(new Headers(headers)), }, - body: JSON.stringify({ model, input, stream: false }), - }), config, { model: "", provider: "" }, { abortSignal }); + body: JSON.stringify({ model, input, stream: false, ...(options.tools ? { tools: options.tools } : {}) }), + }), config, { model: "", provider: "" }, { abortSignal, translatorBudget: options.translatorBudget }); } export function encryptedInput(options: { From b7f468ed4c4ce5bc8aed36505a2d236942a069d0 Mon Sep 17 00:00:00 2001 From: Thierno Bah Date: Wed, 12 Aug 2026 20:54:14 +0200 Subject: [PATCH 2/3] test(agents): prove recovery dispatch path --- tests/agent-task-recovery.test.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/tests/agent-task-recovery.test.ts b/tests/agent-task-recovery.test.ts index 5e40dda367..44f5fa3913 100644 --- a/tests/agent-task-recovery.test.ts +++ b/tests/agent-task-recovery.test.ts @@ -180,10 +180,16 @@ describe("agent task recovery (opt-in, default off)", () => { }); test("charges namespaced tool bridge maps only once across recovery reparse", async () => { - globalThis.fetch = (async (input) => { - if (String(input).includes("chatgpt.com")) { + const recoveryRequests: Request[] = []; + const providerRequests: Request[] = []; + const requestHeaders = codexHeaders(); + globalThis.fetch = (async (input, init) => { + const request = new Request(input, init); + if (request.url.includes("chatgpt.com")) { + recoveryRequests.push(request); return new Response(recoverySse("Use the advertised tool."), { status: 200 }); } + providerRequests.push(request); return providerResponse(); }) as typeof fetch; const namespace = "mcp__review"; @@ -203,7 +209,7 @@ describe("agent task recovery (opt-in, default off)", () => { routedConfig(), "xai/grok-4.5", encryptedInput(), - codexHeaders(), + requestHeaders, undefined, { translatorBudget: budget, @@ -216,6 +222,11 @@ describe("agent task recovery (opt-in, default off)", () => { ); expect(response.status).toBe(200); + expect(recoveryRequests).toHaveLength(1); + expect(recoveryRequests[0]?.headers.get("authorization")) + .toBe(requestHeaders.get("authorization")); + expect(recoveryRequests[0]?.headers.get("chatgpt-account-id")).toBe("acct-caller"); + expect(providerRequests).toHaveLength(1); expect(mappingCharges).toHaveLength(1); } finally { budget.dispose(); From 23112646cf682179a5454176062fc4eb8e029a8b Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:40:20 +0200 Subject: [PATCH 3/3] test(gui): flush delete confirmation effect before waiting --- gui/tests/apikeys-refresh-preserve.test.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/gui/tests/apikeys-refresh-preserve.test.tsx b/gui/tests/apikeys-refresh-preserve.test.tsx index 7cad114aa3..9a621c9089 100644 --- a/gui/tests/apikeys-refresh-preserve.test.tsx +++ b/gui/tests/apikeys-refresh-preserve.test.tsx @@ -207,6 +207,8 @@ test("successful key delete keeps last-good keys visible when follow-up refresh expect(deleteBtn).toBeTruthy(); await act(async () => { deleteBtn!.click(); + }); + await act(async () => { await new Promise((resolve) => testWindow.setTimeout(resolve, 310)); });