diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 1db98357d3..4dcce78ae8 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -100,6 +100,13 @@ collision-safe public function tool. Matching request history and JSON/SSE funct translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward keeps the native private type unchanged. +For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other +than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient +metadata. This conversion leaves encrypted or unknown content unchanged and does not apply +to other destinations. Providers using `authMode: "forward"` retain these items unchanged. +See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages) +for the separate opt-in encrypted-task recovery behavior. + The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that its stricter backend rejects: fully textual `system` messages inside `input` are appended to the top-level `instructions` string in request order, and the top-level `truncation` field is removed. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index ab8a154ecb..6ada74f0d7 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -810,3 +810,32 @@ ids with context `922000` and max input `922000`; OpenRouter seeds `openai/gpt-5 "visionSidecar": { "enabled": true } } ``` + +## OpenCode Go session and agent messages + +With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and +base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items +become user messages when `authMode` is not `"forward"` (for example, `"key"`). +Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including +renamed provider entries; other Responses destinations keep their input unchanged. +Author and recipient remain explicit text metadata, and the content parts are preserved. +Encrypted and unknown content is not normalized; native encrypted tasks still require the +separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). + +With task recovery enabled, replayed `NEW_TASK` and `MESSAGE` items reuse a cached assignment only +after validating the caller and matching the parent-thread scope. Replay restoration +does not make a new recovery request or extend cache expiry. Expired or unseen +ciphertext is not replaced. Fresh encrypted `NEW_TASK` and `MESSAGE` items use the same +opt-in recovery path, including native-parent `send_message` delivery. Message type, +sender, recipient, parent scope and caller credentials remain part of validation or cache identity. + +When a request contains several agent messages, cached replay restoration checks each +message independently. The cache separates message type, sender, recipient and ciphertext +within the admitted caller/account and parent scope. Fresh recovery only handles the +current tail message (ignoring trailing `compaction_trigger` or `additional_tools` metadata). +It does not batch-recover unseen historical messages; those remain unchanged. A cache miss +or expiry does not extend the history-recovery contract. + +Sender and recipient on Go Responses are context for the receiving model, not a new +machine-readable routing protocol. Tool routing continues to use the existing collaboration +contracts. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29dd2c5f1c..b740dcbf20 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -889,6 +889,7 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", + "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -1064,6 +1065,7 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index e10fa7d20e..52e75937f3 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,3 +1,4 @@ +import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; @@ -2354,6 +2355,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); + if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. diff --git a/src/adapters/opencode-go.ts b/src/adapters/opencode-go.ts new file mode 100644 index 0000000000..94055a292a --- /dev/null +++ b/src/adapters/opencode-go.ts @@ -0,0 +1,35 @@ +/** Match the Go destination, including user-renamed provider entries. */ +export function isOpenCodeGo(baseUrl: string): boolean { + try { + const url = new URL(baseUrl); + return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1"; + } catch { return false; } +} + +/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */ +export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { + if (!body || typeof body !== "object" || Array.isArray(body)) return body; + const record = body as Record; + if (!Array.isArray(record.input)) return body; + let changed = false; + const input = record.input.map((item: unknown) => { + if (!item || typeof item !== "object" || Array.isArray(item)) return item; + const message = item as Record; + if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item; + // Genuine ciphertext and unknown part types must retain their existing fail-closed path. + if (!message.content.every(part => part && typeof part === "object" + && ["input_text", "input_image", "input_file"].includes(part.type))) return item; + const identities = Object.fromEntries(["author", "recipient"] + .filter(key => typeof message[key] === "string") + .map(key => [key, message[key]])); + changed = true; + return { + type: "message", role: "user", + content: [ + ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), + ...message.content, + ], + }; + }); + return changed ? { ...record, input } : body; +} diff --git a/src/server/responses/agent-task-recovery-cache.ts b/src/server/responses/agent-task-recovery-cache.ts index 44e32c62bb..93d0c1778b 100644 --- a/src/server/responses/agent-task-recovery-cache.ts +++ b/src/server/responses/agent-task-recovery-cache.ts @@ -149,3 +149,14 @@ export function agentTaskRecoveryWaiterCountForTests(): number { export function agentTaskRecoveryCacheSnapshotForTests(): { entries: number; bytes: number } { return { entries: RECOVERY_CACHE.size, bytes: recoveryCacheBytes }; } + +/** Read an existing recovery without starting a request or extending its lifetime. */ +export function cachedAgentTaskRecovery(key: string): string | null { + const entry = RECOVERY_CACHE.get(key); + if (!entry) return null; + if (entry.expiresAt <= Date.now()) { + deleteRecoveryCacheEntry(key, entry); + return null; + } + return entry.assignment; +} diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index e1c35932ff..89af59a1ef 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -5,6 +5,7 @@ import { readBoundedResponseBody } from "../../lib/bounded-body"; import { isApiAuthRequired, isProxyAdmissionSecret } from "../auth-cors"; import { structurallyValidFernetTokens } from "./encrypted-payload"; import { + cachedAgentTaskRecovery, discardCachedAgentTaskRecovery, resetAgentTaskRecoveryCache, resolveCachedAgentTaskRecovery, @@ -61,7 +62,7 @@ interface AgentEnvelope { itemIndex: number; encryptedIndex: number; headerText: string; - messageType: "NEW_TASK"; + messageType: "NEW_TASK" | "MESSAGE"; taskName: string; sender: string; ciphertext: string; @@ -69,7 +70,7 @@ interface AgentEnvelope { recipient: string; } -const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; +const ROUTING_HEADER = /(?:^|\n)Message Type\s*:\s*(NEW_TASK|MESSAGE)\s*\nTask name\s*:\s*(\S+)\s*\nSender\s*:\s*(\S+)\s*\nPayload\s*:\s*(?:\n|$)/; function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(input)) return null; @@ -90,7 +91,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(content)) return null; let headerText: string | null = null; - let messageType: "NEW_TASK" | null = null; + let messageType: "NEW_TASK" | "MESSAGE" | null = null; let taskName: string | null = null; let sender: string | null = null; let encryptedIndex = -1; @@ -113,7 +114,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { || part.text.slice(match.index + match[0].length).trim().length > 0 ) return null; headerText = match[0].startsWith("\n") ? match[0].slice(1) : match[0]; - messageType = "NEW_TASK"; + messageType = match[1] as "NEW_TASK" | "MESSAGE"; taskName = match[2]!; sender = match[3]!; } @@ -496,3 +497,22 @@ export function discardEncryptedAgentTaskRecovery( export function resetAgentTaskRecoveryState(): void { resetAgentTaskRecoveryCache(); } + +/** Codex replays the original encrypted agent messages after tool calls. Reuse only an admitted cache hit. */ +export function restoreCachedEncryptedAgentTasks( + req: Request, input: unknown, config: OcxConfig, + context: { parentThreadId?: string | null } = {}, +): number { + if (!Array.isArray(input)) return 0; + let restored = 0; + for (const item of input) { + if (!item || typeof item !== "object" || item.type !== "agent_message") continue; + const single = [item]; + // Revalidates caller credentials and the exact supported agent envelope before cache access. + const admitted = admittedRecovery(req, single, config, context.parentThreadId); + if (!admitted) continue; + const assignment = cachedAgentTaskRecovery(admitted.cacheKey); + if (assignment && injectAssignment(single, admitted.envelope, assignment)) restored += 1; + } + return restored; +} diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 684931c9f9..9930af234b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -318,6 +318,7 @@ import { agentTaskRecoveryConfig, discardEncryptedAgentTaskRecovery, recoverEncryptedAgentTask, + restoreCachedEncryptedAgentTasks, } from "./agent-task-recovery"; import { relaySseEagerBounded } from "../relay-eager"; import { @@ -3234,14 +3235,18 @@ async function handleResponsesInner( inboundWire === "responses" && threadSpawn - && unreadableEncryptedAgentTask && agentTaskRecovery && !isCanonicalOpenAiForwardProvider(route.provider) && !options.comboAttempt && !canPassThroughEncryptedV2AgentTask(route, inboundWire) ) { - let recovered = false; - try { + let recovered = restoreCachedEncryptedAgentTasks( + req, (body as { input?: unknown } | undefined)?.input, config, { parentThreadId }, + ) > 0; + unreadableEncryptedAgentTask = hasUnreadableEncryptedAgentTask( + (body as { input?: unknown } | undefined)?.input, + ); + if (unreadableEncryptedAgentTask) try { recovered = await recoverEncryptedAgentTask( req, (body as { input?: unknown } | undefined)?.input, diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 114c699eaf..0b79ffe21d 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -726,6 +726,7 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", + "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -901,6 +902,7 @@ "selected-models.test.ts": "codex-integration", "self-launch-argv.test.ts": "lib", "server-403-permission-e2e.test.ts": "server", + "server-agent-task-recovery-replay.test.ts": "server", "server-auth.test.ts": "server", "server-background-lifecycle.test.ts": "server", "server-clickjacking-headers.test.ts": "server", diff --git a/tests/providers/opencode-go-agent-messages.test.ts b/tests/providers/opencode-go-agent-messages.test.ts new file mode 100644 index 0000000000..e12860d0e0 --- /dev/null +++ b/tests/providers/opencode-go-agent-messages.test.ts @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; +import { parseRequest } from "../../src/responses/parser"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; + +const base: OcxProviderConfig = { adapter: "openai-responses", baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key", apiKey: "synthetic-key" }; +const body = () => ({ model: "muse-spark-1.3-contributor", input: [{ type: "agent_message", id: "amsg_test", author: "/root/reader", recipient: "/root/checker", content: [{ type: "input_text", text: "Exact assignment\nwith lines." }] }], stream: true }); + +test("Responses converts plaintext task and peer messages without mutating replay or losing routing identities", async () => { + const raw = body(); const original = structuredClone(raw); const budget = createTranslatorBudget(); + const request = await createResponsesPassthroughAdapter(base).buildRequest(parseRequest(raw), { headers: new Headers(), translatorBudget: budget }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0].type).toBe("message"); + expect(sent.input[0].role).toBe("user"); + expect(sent.input[0].content[0].text).toContain('"author":"/root/reader"'); + expect(sent.input[0].content[0].text).toContain('"recipient":"/root/checker"'); + expect(sent.input[0].content[1]).toEqual(raw.input[0]!.content[0]); + expect(sent.input[0].id).toBeUndefined(); + expect(raw).toEqual(original); + budget.dispose(); +}); + +test("ciphertext and unknown content are never reclassified as plaintext", () => { + for (const part of [{ type: "encrypted_content", encrypted_content: "opaque" }, { type: "future_type", text: "opaque" }]) { + const raw = { input: [{ type: "agent_message", content: [part] }] }; + expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + } +}); + +test("image parts stay intact beside the assignment", () => { + const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; + const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] }; + const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw; + expect(result.input[0]!.content[1]).toBe(image); +}); + +test("native forward keeps agent_message and auth/session headers unchanged", async () => { + const budget = createTranslatorBudget(); + const provider = { ...base, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }; + const request = await createResponsesPassthroughAdapter(provider).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "native-id", authorization: "Bearer native-test" }), translatorBudget: budget }); + expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + expect(new Headers(request.headers).get("session-id")).toBe("native-id"); + expect(new Headers(request.headers).get("authorization")).toBe("Bearer native-test"); + budget.dispose(); +}); + +test("other destinations do not get Go normalization or session identity", async () => { + const budget = createTranslatorBudget(); + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget }); + expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + budget.dispose(); +}); diff --git a/tests/server/server-agent-task-recovery-replay.test.ts b/tests/server/server-agent-task-recovery-replay.test.ts new file mode 100644 index 0000000000..cfb735af01 --- /dev/null +++ b/tests/server/server-agent-task-recovery-replay.test.ts @@ -0,0 +1,142 @@ +import { afterEach, expect, test } from "bun:test"; +import { recoverEncryptedAgentTask, resetAgentTaskRecoveryState, restoreCachedEncryptedAgentTasks } from "../../src/server/responses/agent-task-recovery"; +import { codexHeaders, encryptedInput, FERNET_TASK, SECOND_FERNET_TASK, originalFetch, recoverySse, routedConfig } from "../helpers/agent-task-recovery"; +afterEach(() => { globalThis.fetch = originalFetch; resetAgentTaskRecoveryState(); }); + +test("replay reuses admitted recovery after a tool result without another network call", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Read nonce.txt exactly.")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, { parentThreadId: "parent" })).toBe(true); + const replay = [...encryptedInput(), { type: "function_call_output", call_id: "tool", output: "result" }]; + expect(restoreCachedEncryptedAgentTasks(req, replay, config, { parentThreadId: "parent" })).toBe(1); + expect(JSON.stringify(replay)).toContain("Read nonce.txt exactly."); + expect(JSON.stringify(replay)).not.toContain(FERNET_TASK); + expect(calls).toBe(1); +}); + +test("replay does not recover unseen envelopes, other parents, or other callers", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private assignment.")); }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config, { parentThreadId: "parent" })).toBe(0); + expect(calls).toBe(0); + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, { parentThreadId: "parent" })).toBe(true); + for (const [request, parent] of [[req, "another-parent"], [new Request("http://localhost/v1/responses", { headers: codexHeaders("another-account") }), "parent"], [new Request("http://localhost/v1/responses"), "parent"]] as const) { + const input = encryptedInput(); + expect(restoreCachedEncryptedAgentTasks(request, input, config, { parentThreadId: parent })).toBe(0); + expect(JSON.stringify(input)).toContain(FERNET_TASK); + } + expect(calls).toBe(1); +}); + +test("Responses handler restores a cached task in a continued child turn", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + let recoveries = 0; + const bodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + recoveries++; + return new Response(recoverySse("Read nonce.txt exactly.")); + } + bodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + expect((await post(config, "xai/grok-4.5", encryptedInput(), codexHeaders())).status).toBe(200); + expect((await post(config, "xai/grok-4.5", [...encryptedInput(), { type: "message", role: "user", content: "Continue the original task." }], codexHeaders())).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + expect(bodies[1]).toContain("Read nonce.txt exactly."); + expect(bodies[1]).not.toContain(FERNET_TASK); +}); + +function encryptedMessage(): unknown[] { + return JSON.parse(JSON.stringify(encryptedInput()).replace("Message Type: NEW_TASK", "Message Type: MESSAGE")); +} + +test("MESSAGE recovery reaches the provider and survives tool-result replay", async () => { + const { post, providerResponse } = await import("../helpers/agent-task-recovery"); + let recoveries = 0; + const bodies: string[] = []; + globalThis.fetch = (async (url: unknown, init?: RequestInit) => { + if (String(url).includes("chatgpt.com")) { + expect(String(init?.body)).toContain("Message Type: MESSAGE"); + recoveries++; + return new Response(recoverySse("Stop waiting and report your result.")); + } + bodies.push(String(init?.body)); + return providerResponse(); + }) as typeof fetch; + const config = routedConfig({ enabled: true }); + expect((await post(config, "xai/grok-4.5", encryptedMessage(), codexHeaders())).status).toBe(200); + expect((await post(config, "xai/grok-4.5", [...encryptedMessage(), { + type: "message", role: "user", content: "Continue after the tool result.", + }], codexHeaders())).status).toBe(200); + expect(recoveries).toBe(1); + expect(bodies).toHaveLength(2); + for (const body of bodies) { + expect(body).toContain("Stop waiting and report your result."); + expect(body).not.toContain(FERNET_TASK); + } +}); + +test("MESSAGE cache remains isolated by message type, account, parent and sender", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Private message.")); }) as typeof fetch; + const config = routedConfig({ enabled: true }); + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + expect(await recoverEncryptedAgentTask(req, encryptedMessage(), {}, config, { parentThreadId: "parent" })).toBe(true); + expect(restoreCachedEncryptedAgentTasks(req, encryptedInput(), config, { parentThreadId: "parent" })).toBe(0); + for (const [request, parent] of [[req, "other-parent"], [new Request("http://localhost/v1/responses", { headers: codexHeaders("other-account") }), "parent"]] as const) { + expect(restoreCachedEncryptedAgentTasks(request, encryptedMessage(), config, { parentThreadId: parent })).toBe(0); + } + const malformed = JSON.parse(JSON.stringify(encryptedMessage())); + malformed[0].author = "/root/wrong-sender"; + expect(await recoverEncryptedAgentTask(req, malformed, {}, config)).toBe(false); + const unknown = JSON.parse(JSON.stringify(encryptedMessage()).replace("Message Type: MESSAGE", "Message Type: UNKNOWN")); + expect(await recoverEncryptedAgentTask(req, unknown, {}, config)).toBe(false); + expect(calls).toBe(1); +}); + + +test("mixed history restores cached NEW_TASK and MESSAGE separately before recovering only the new tail", async () => { + let calls = 0; + const payloads = ["Initial assignment.", "First message.", "Second message."]; + globalThis.fetch = (async () => new Response(recoverySse(payloads[calls++]!))) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + const scope = { parentThreadId: "parent" }; + const nextMessage = () => JSON.parse(JSON.stringify(encryptedMessage()).replace(FERNET_TASK, SECOND_FERNET_TASK)); + + expect(await recoverEncryptedAgentTask(req, encryptedInput(), {}, config, scope)).toBe(true); + expect(await recoverEncryptedAgentTask(req, encryptedMessage(), {}, config, scope)).toBe(true); + const input = [...encryptedInput(), ...encryptedMessage(), ...nextMessage()]; + expect(restoreCachedEncryptedAgentTasks(req, input, config, scope)).toBe(2); + expect(calls).toBe(2); + expect(await recoverEncryptedAgentTask(req, input, {}, config, scope)).toBe(true); + expect(calls).toBe(3); + for (const payload of payloads) expect(JSON.stringify(input)).toContain(payload); + expect(JSON.stringify(input)).not.toContain(SECOND_FERNET_TASK); + + const replay = [...encryptedInput(), ...encryptedMessage(), ...nextMessage(), { + type: "function_call_output", call_id: "tool", output: "done", + }]; + expect(restoreCachedEncryptedAgentTasks(req, replay, config, scope)).toBe(3); + expect(calls).toBe(3); +}); + +test("fresh recovery only handles the current tail, leaving uncached history unchanged", async () => { + let calls = 0; + globalThis.fetch = (async () => { calls++; return new Response(recoverySse("Current message.")); }) as typeof fetch; + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig({ enabled: true }); + const historical = encryptedInput(); + const input = [...historical, ...encryptedMessage()]; + expect(await recoverEncryptedAgentTask(req, input, {}, config)).toBe(true); + expect(input[0]).toEqual(encryptedInput()[0]); + expect(JSON.stringify(input[1])).toContain("Current message."); + expect(calls).toBe(1); +});