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)); }); 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..44f5fa3913 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,60 @@ 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 () => { + 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"; + 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(), + requestHeaders, + undefined, + { + translatorBudget: budget, + tools: [{ + type: "namespace", + name: namespace, + tools: [{ type: "function", name, parameters: { type: "object" } }], + }], + }, + ); + + 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(); + } + }); + 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: {