diff --git a/src/server/responses/agent-task-recovery.ts b/src/server/responses/agent-task-recovery.ts index a15a2563cad..8bab1dfc7a3 100644 --- a/src/server/responses/agent-task-recovery.ts +++ b/src/server/responses/agent-task-recovery.ts @@ -75,7 +75,7 @@ interface AgentEnvelope { itemIndex: number; encryptedIndex: number; headerText: string; - messageType: "NEW_TASK" | "MESSAGE"; + messageType: "NEW_TASK"; taskName: string; sender: string; ciphertext: string; @@ -83,7 +83,7 @@ interface AgentEnvelope { recipient: string; } -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|$)/; +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|$)/; function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(input)) return null; @@ -104,7 +104,7 @@ function findEnvelope(input: unknown): AgentEnvelope | null { if (!Array.isArray(content)) return null; let headerText: string | null = null; - let messageType: "NEW_TASK" | "MESSAGE" | null = null; + let messageType: "NEW_TASK" | null = null; let taskName: string | null = null; let sender: string | null = null; let encryptedIndex = -1; @@ -127,7 +127,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 = match[1] as "NEW_TASK" | "MESSAGE"; + messageType = match[1] as "NEW_TASK"; taskName = match[2]!; sender = match[3]!; } diff --git a/tests/server/agent-task-recovery.test.ts b/tests/server/agent-task-recovery.test.ts index 939fe189eb7..ec7eb96fe76 100644 --- a/tests/server/agent-task-recovery.test.ts +++ b/tests/server/agent-task-recovery.test.ts @@ -36,48 +36,62 @@ describe("agent task recovery (opt-in, default off)", () => { resetAgentTaskRecoveryState(); }); - for (const messageType of ["NEW_TASK", "MESSAGE"] as const) { - test(`typed ${messageType} recovery preserves boolean, replay and discard contracts`, async () => { - const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); - const config = routedConfig(); - const context = { parentThreadId: "parent-diagnostics" }; - const input = () => agentMessage([ - { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType) }, - { type: "encrypted_content", encrypted_content: FERNET_TASK }, - ]); - let fetches = 0; - globalThis.fetch = (async () => { - fetches += 1; - return new Response(recoverySse("Recovered diagnostic fixture.")); - }) as typeof fetch; + test("typed NEW_TASK recovery preserves boolean, replay and discard contracts", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + const context = { parentThreadId: "parent-diagnostics" }; + const input = () => agentMessage([ + { type: "input_text", text: ROUTING_ENVELOPE }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("Recovered diagnostic fixture.")); + }) as typeof fetch; - const typedInput = input(); - expect(await recoverEncryptedAgentTaskWithResult(req, typedInput, {}, config, context)) - .toEqual({ recovered: true }); - const booleanInput = input(); - expect(await recoverEncryptedAgentTask(req, booleanInput, {}, config, context)).toBe(true); - expect(booleanInput).toEqual(typedInput); - expect(typedInput).toEqual([{ - type: "message", role: "user", content: [ - { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType) }, - { type: "input_text", text: "Recovered diagnostic fixture." }, - ], - }]); - const replay = input(); - expect(restoreCachedEncryptedAgentTasks(req, replay, config, context)).toBe(1); - expect(replay).toEqual(typedInput); - expect(fetches).toBe(1); + const typedInput = input(); + expect(await recoverEncryptedAgentTaskWithResult(req, typedInput, {}, config, context)) + .toEqual({ recovered: true }); + const booleanInput = input(); + expect(await recoverEncryptedAgentTask(req, booleanInput, {}, config, context)).toBe(true); + expect(booleanInput).toEqual(typedInput); + expect(typedInput).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: ROUTING_ENVELOPE }, + { type: "input_text", text: "Recovered diagnostic fixture." }, + ], + }]); + const replay = input(); + expect(restoreCachedEncryptedAgentTasks(req, replay, config, context)).toBe(1); + expect(replay).toEqual(typedInput); + expect(fetches).toBe(1); - const otherType = agentMessage([ - { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", messageType === "MESSAGE" ? "NEW_TASK" : "MESSAGE") }, - { type: "encrypted_content", encrypted_content: FERNET_TASK }, - ]); - expect(restoreCachedEncryptedAgentTasks(req, otherType, config, context)).toBe(0); - discardEncryptedAgentTaskRecovery(req, input(), config, context); - expect(restoreCachedEncryptedAgentTasks(req, input(), config, context)).toBe(0); - expect(fetches).toBe(1); - }); - } + discardEncryptedAgentTaskRecovery(req, input(), config, context); + expect(restoreCachedEncryptedAgentTasks(req, input(), config, context)).toBe(0); + expect(fetches).toBe(1); + }); + + test("MESSAGE envelopes fail closed without recovery or cache restoration", async () => { + const req = new Request("http://localhost/v1/responses", { headers: codexHeaders() }); + const config = routedConfig(); + const input = agentMessage([ + { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE") }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + const original = structuredClone(input); + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + return new Response(recoverySse("must not be recovered")); + }) as typeof fetch; + + expect(await recoverEncryptedAgentTaskWithResult(req, input, {}, config)) + .toEqual({ recovered: false, reason: "unsupported_envelope" }); + expect(input).toEqual(original); + expect(restoreCachedEncryptedAgentTasks(req, input, config)).toBe(0); + expect(fetches).toBe(0); + }); const failedRecoveries: Array<[string, () => Response, AgentTaskRecoveryFailureReason]> = [ ["HTTP 401", () => new Response("private-error", { status: 401 }), "recovery_http_rejected"], @@ -933,6 +947,26 @@ describe("mid-thread encrypted agent task recovery (#4089)", () => { expect(providerBody).not.toContain(FERNET_TASK); }); + test("a mid-thread MESSAGE fails closed without reaching recovery or the routed provider", async () => { + let fetches = 0; + globalThis.fetch = (async () => { + fetches += 1; + throw new Error("MESSAGE ciphertext must not leave the proxy"); + }) as typeof fetch; + const input = agentMessage([ + { type: "input_text", text: ROUTING_ENVELOPE.replace("NEW_TASK", "MESSAGE") }, + { type: "encrypted_content", encrypted_content: FERNET_TASK }, + ]); + + const response = await post(routedConfig(), "xai/grok-4.5", input, midThreadHeaders()); + + expect(response.status).toBe(400); + expect(await response.json()).toMatchObject({ + error: { code: "unreadable_encrypted_agent_task", recovery_reason: "unsupported_envelope" }, + }); + expect(fetches).toBe(0); + }); + test("a mid-thread replay reuses the cached plaintext instead of recovering again", async () => { // The report's third observation: the cache restore sits inside the same gate, so a // mid-thread turn could never reuse a plaintext this proxy had already paid for.