From 41e8a6f10d70d4a692a3ef27de820f7a0fc25796 Mon Sep 17 00:00:00 2001 From: Yilmhi Date: Sat, 19 Sep 2026 12:22:56 +0800 Subject: [PATCH 1/3] fix: forward inter-agent messages as user text so DeepSeek stops rejecting them Codex delivers a task to a child agent as an `agent_message` item whose payload block is typed `encrypted_content`. Two things broke every spawn: * DeepSeek's content enum accepts only input_text/output_text/input_image/input_file, so the raw block failed the request with a 422 before the child ever started. The block is now rewritten as text, and a payload that cannot be read as text (a sealed artifact) is dropped rather than handed to the model as ciphertext. * The item was replayed with role `assistant`, which DeepSeek reads as the model's own prior thinking turn: as soon as the request carries tools it answers "The `reasoning_text` in the thinking mode must be passed back to the API." and the request fails. For a child agent that message is the last item of its first request, so every spawn died; the same 400 also killed the parent's next turn after an inter-agent reply. `user` carries the same text without claiming prior reasoning. Also stop forwarding Codex-internal `internal_chat_message_metadata_passthrough` to DeepSeek. --- src/proxy.mjs | 34 ++++++++++++++++++++++++- test/proxy.test.mjs | 60 ++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/proxy.mjs b/src/proxy.mjs index 7962ea0..78faf5b 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -117,6 +117,26 @@ function openCompaction(value, secret) { } } +// Codex hands another agent its task as a message whose payload arrives in a block +// typed `encrypted_content`. The block is plain text in practice, and DeepSeek's +// content enum — input_text, output_text, input_image, input_file — rejects the +// variant outright, failing the whole request with a 422 so the child agent never +// starts. Forward the payload as text; a block that cannot be read as text (a real +// sealed artifact) is dropped instead of being handed to the model as ciphertext. +function contentBlockAsText(block, compactionSecret) { + const value = block.encrypted_content; + if (typeof value !== "string" || value.length === 0) return null; + if (value.startsWith(COMPACTION_PREFIX)) { + const summary = openCompaction(value, compactionSecret); + return summary ? { type: "input_text", text: summary } : null; + } + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value)) return null; + return { type: "input_text", text: value }; +} + +const isEncryptedBlock = (block) => + Boolean(block) && typeof block === "object" && block.type === "encrypted_content"; + function convertInputItem(item, compactionSecret) { if (!item || typeof item !== "object" || Array.isArray(item)) return item; if (item.type === "compaction") { @@ -134,9 +154,21 @@ function convertInputItem(item, compactionSecret) { } const converted = { ...item }; delete converted.id; + delete converted.internal_chat_message_metadata_passthrough; if (converted.type === "agent_message") { + // An inter-agent message is a task handed *to* this agent, not a turn the model + // produced. Replaying it as `assistant` makes DeepSeek treat it as its own prior + // thinking turn: with tools in the request it answers "The `reasoning_text` in + // the thinking mode must be passed back to the API." and the whole request fails, + // which is fatal for a freshly spawned child agent whose task message is the last + // item. `user` carries the same text without claiming prior reasoning. converted.type = "message"; - converted.role = "assistant"; + converted.role = "user"; + } + if (Array.isArray(converted.content) && converted.content.some(isEncryptedBlock)) { + converted.content = converted.content + .map((block) => (isEncryptedBlock(block) ? contentBlockAsText(block, compactionSecret) : block)) + .filter((block) => block != null); } return converted; } diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index 550e6a7..c299c18 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -96,11 +96,69 @@ test("routes Flash and legacy task aliases to the current Flash model", async (t assert.equal(request.body.store, false); assert.equal("previous_response_id" in request.body, false); assert.equal("metadata" in request.body, false); - assert.deepEqual(request.body.input[0], { type: "message", role: "assistant", content: "prior answer" }); + assert.deepEqual(request.body.input[0], { type: "message", role: "user", content: "prior answer" }); assert.deepEqual(request.body.input[1], { type: "function_call_output", call_id: "call_7", output: "done" }); } }); +test("forwards an inter-agent task message as text instead of an encrypted_content block", async (t) => { + const observed = []; + const upstream = http.createServer(async (request, response) => { + observed.push(JSON.parse(await bodyOf(request))); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end("event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + deepSeekKey: "test-key", + deepSeekBaseUrl: upstreamUrl, + chatGptBaseUrl: upstreamUrl, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + t.after(async () => { await close(proxy); await close(upstream); }); + + // Codex ships a task handed to another agent as a message whose payload block is + // typed `encrypted_content` even though the text is plain. DeepSeek's content enum + // only knows input_text/output_text/input_image/input_file, so forwarding the block + // unchanged fails the whole request with a 422 and the child agent never starts. + const taskText = "Message Type: NEW_TASK\nTask name: /root/child\nSender: /root\nPayload:\n"; + const payload = "reply with banana"; + const response = await fetch(route(proxyUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "deepseek/deepseek-flash", + stream: true, + input: [{ + id: "amsg_1", + type: "agent_message", + author: "/root", + recipient: "/root/child", + content: [ + { type: "input_text", text: taskText }, + { type: "encrypted_content", encrypted_content: payload }, + ], + internal_chat_message_metadata_passthrough: { turn_id: "turn_1" }, + }], + }), + }); + + assert.equal(response.status, 200); + await response.text(); + const forwarded = observed.at(-1).input[0]; + assert.equal(forwarded.type, "message"); + // `user`, not `assistant`: DeepSeek rejects a replayed assistant turn without + // reasoning_text once the request carries tools, which killed every child agent. + assert.equal(forwarded.role, "user"); + assert.deepEqual(forwarded.content, [ + { type: "input_text", text: taskText }, + { type: "input_text", text: payload }, + ]); + assert.equal("internal_chat_message_metadata_passthrough" in forwarded, false); +}); + test("adapts Codex remote compaction v2 to a DeepSeek summary and restores it on replay", async (t) => { const observed = []; const summary = "The user approved the router fix; tests and a restart are still pending."; From ae84ef5eccba80e75dbe83c806008e517b32c675 Mon Sep 17 00:00:00 2001 From: Yilmhi Date: Sat, 19 Sep 2026 12:31:33 +0800 Subject: [PATCH 2/3] fix: guarantee the DeepSeek content enum instead of teaching it block types one by one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DeepSeek deserializes the content of an input message into a closed enum — input_text, output_text, input_image, input_file — and answers 422 `unknown variant ...` for anything else, taking the whole turn offline. The inter-agent message bug was one instance of that; the next block type we have not been taught yet would be the next outage. Probing every shape Codex actually recorded in its own session rollouts (41 files) shows the item layer is tolerant: custom_tool_call, tool_search_call, web_search_call, tool_search outputs carrying `namespace` tool groups, and input_image all pass through untouched. Content blocks are the only fragile layer, and a fabricated unknown block (`refusal`) still 422s, which confirms where the guarantee belongs. Every content block is now normalised on the way out: known types pass through byte for byte, a block carrying text (encrypted_content, refusal, whatever comes next) is forwarded as input_text, and a block with no readable text is dropped rather than handed to the model as ciphertext. --- src/proxy.mjs | 52 +++++++++++++++++++++++++++++---------------- test/proxy.test.mjs | 46 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/proxy.mjs b/src/proxy.mjs index 78faf5b..e848479 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -117,25 +117,41 @@ function openCompaction(value, secret) { } } -// Codex hands another agent its task as a message whose payload arrives in a block -// typed `encrypted_content`. The block is plain text in practice, and DeepSeek's -// content enum — input_text, output_text, input_image, input_file — rejects the -// variant outright, failing the whole request with a 422 so the child agent never -// starts. Forward the payload as text; a block that cannot be read as text (a real -// sealed artifact) is dropped instead of being handed to the model as ciphertext. -function contentBlockAsText(block, compactionSecret) { - const value = block.encrypted_content; - if (typeof value !== "string" || value.length === 0) return null; - if (value.startsWith(COMPACTION_PREFIX)) { - const summary = openCompaction(value, compactionSecret); - return summary ? { type: "input_text", text: summary } : null; +// DeepSeek deserializes the content of an input message into a closed enum — input_text, +// output_text, input_image, input_file — and rejects the entire request with a 422 on +// anything else. Codex keeps introducing block types (an inter-agent task arrives as +// `encrypted_content`), and a single unknown block takes a whole agent turn offline, so +// translate every block into something DeepSeek accepts rather than waiting to be taught +// each new type one incident at a time. +const DEEPSEEK_CONTENT_TYPES = new Set(["input_text", "output_text", "input_image", "input_file"]); + +const acceptsBlock = (block) => DEEPSEEK_CONTENT_TYPES.has(block?.type); + +function textCarriedBy(block) { + for (const value of [block.text, block.refusal, block.content]) { + if (typeof value === "string" && value.length > 0) return value; } - if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value)) return null; - return { type: "input_text", text: value }; + return null; } -const isEncryptedBlock = (block) => - Boolean(block) && typeof block === "object" && block.type === "encrypted_content"; +function contentBlockForDeepSeek(block, compactionSecret) { + if (!block || typeof block !== "object") return block; + if (acceptsBlock(block)) return block; + if (block.type === "encrypted_content") { + const value = block.encrypted_content; + if (typeof value !== "string" || value.length === 0) return null; + if (value.startsWith(COMPACTION_PREFIX)) { + const summary = openCompaction(value, compactionSecret); + return summary ? { type: "input_text", text: summary } : null; + } + // Unreadable bytes are never handed to the model as if they were prose. + if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(value)) return null; + return { type: "input_text", text: value }; + } + // An unrecognised block: keep the text it carries, drop what cannot be read as text. + const text = textCarriedBy(block); + return text === null ? null : { type: "input_text", text }; +} function convertInputItem(item, compactionSecret) { if (!item || typeof item !== "object" || Array.isArray(item)) return item; @@ -165,9 +181,9 @@ function convertInputItem(item, compactionSecret) { converted.type = "message"; converted.role = "user"; } - if (Array.isArray(converted.content) && converted.content.some(isEncryptedBlock)) { + if (Array.isArray(converted.content) && converted.content.some((block) => !acceptsBlock(block))) { converted.content = converted.content - .map((block) => (isEncryptedBlock(block) ? contentBlockAsText(block, compactionSecret) : block)) + .map((block) => contentBlockForDeepSeek(block, compactionSecret)) .filter((block) => block != null); } return converted; diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index c299c18..af12092 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -159,6 +159,52 @@ test("forwards an inter-agent task message as text instead of an encrypted_conte assert.equal("internal_chat_message_metadata_passthrough" in forwarded, false); }); +test("never forwards a content block DeepSeek cannot deserialize", async (t) => { + const observed = []; + const upstream = http.createServer(async (request, response) => { + observed.push(JSON.parse(await bodyOf(request))); + response.writeHead(200, { "content-type": "text/event-stream" }); + response.end("event: response.completed\ndata: {\"type\":\"response.completed\"}\n\n"); + }); + const upstreamUrl = await listen(upstream); + const proxy = createProxyServer({ + deepSeekKey: "test-key", + deepSeekBaseUrl: upstreamUrl, + chatGptBaseUrl: upstreamUrl, + logger: { info() {}, error() {} }, + routerToken: ROUTER_TOKEN, + }); + const proxyUrl = await listen(proxy); + t.after(async () => { await close(proxy); await close(upstream); }); + + // DeepSeek deserializes an input message's content into a closed enum: input_text, + // output_text, input_image, input_file. Anything else fails the entire request with a + // 422 — one unknown block type is enough to take a whole agent turn offline — so the + // router has to guarantee the enum on the way out instead of waiting to be taught each + // new block type one at a time. Known blocks must survive byte for byte. + const response = await fetch(route(proxyUrl), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "deepseek/deepseek-flash", + stream: true, + input: [ + { type: "message", role: "assistant", content: [{ type: "refusal", refusal: "I cannot help with that." }] }, + { type: "message", role: "assistant", content: [{ type: "encrypted_content", encrypted_content: null }] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go on" }] }, + ], + }), + }); + + assert.equal(response.status, 200); + await response.text(); + const input = observed.at(-1).input; + assert.deepEqual(input[0].content, [{ type: "input_text", text: "I cannot help with that." }]); + // A block that carries no recoverable text is dropped rather than guessed at. + assert.deepEqual(input[1].content, []); + assert.deepEqual(input[2].content, [{ type: "input_text", text: "go on" }]); +}); + test("adapts Codex remote compaction v2 to a DeepSeek summary and restores it on replay", async (t) => { const observed = []; const summary = "The user approved the router fix; tests and a restart are still pending."; From c20ffed16dd25f392a6d5810963077d8291d8f93 Mon Sep 17 00:00:00 2001 From: Yilmhi Date: Sat, 19 Sep 2026 13:05:03 +0800 Subject: [PATCH 3/3] fix: stop replaying encrypted payloads ChatGPT never issued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An encrypted payload only verifies for the provider that issued it. When the root model is served by this router and a GPT model runs a sub-agent, every spawn is a cross-provider model switch, so the child's request replays encrypted fields the ChatGPT endpoint cannot verify and the turn dies. DeepSeek now returns a non-null `encrypted_content`, so the previous "is the field empty?" test never recognised those items as foreign and forwarded them anyway. ChatGPT's own ciphertext is base64 beginning `gAAAAA`, so that is the only encrypted payload let through now; DSCodex-sealed compactions are still unwrapped, and anything else is dropped rather than forwarded for the upstream to reject. Applies to both the HTTP and websocket transports. Verified against the live endpoint: a request carrying a DeepSeek-issued reasoning token in history went 400 -> 200. This does not by itself revive GPT-model sub-agents in that setup — see https://github.com/openai/codex/issues/33267 — where the remaining failure is client side. --- src/proxy.mjs | 34 +++++++++++++++++++++++---------- test/proxy.test.mjs | 46 +++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/proxy.mjs b/src/proxy.mjs index e848479..19404bb 100644 --- a/src/proxy.mjs +++ b/src/proxy.mjs @@ -189,24 +189,38 @@ function convertInputItem(item, compactionSecret) { return converted; } +// ChatGPT verifies every encrypted payload it is handed and fails the whole turn with +// "the encrypted content could not be verified / decrypted" when one was issued by another +// provider. That is what breaks a GPT sub-agent spawned from a DeepSeek session: the child's +// request replays history carrying DeepSeek-issued encrypted fields — and DeepSeek returns a +// non-null `encrypted_content` that is not ChatGPT ciphertext, so the old "is the field +// empty?" test never caught it. ChatGPT's own ciphertext is base64 beginning "gAAAAA", so +// that is the only encrypted payload allowed through; DSCodex-sealed compactions are unwrapped +// below; everything else is dropped rather than replayed for the upstream to reject. +const CHATGPT_SEALED_PREFIX = "gAAAAA"; +const sealedByChatGpt = (value) => + typeof value === "string" && value.startsWith(CHATGPT_SEALED_PREFIX); + // Provider-specific replay artifacts cannot be sent to ChatGPT. Keep native GPT // reasoning intact and avoid re-encoding ordinary GPT requests at all. function buildChatGptBody(body, compactionSecret) { if (!Array.isArray(body?.input)) return null; let changed = false; const input = body.input.flatMap((item) => { - if (item?.type === "reasoning" - && !item.encrypted_content - && Array.isArray(item.content) - && item.content.some((part) => part?.type === "reasoning_text")) { - changed = true; - return []; + if (item?.type === "reasoning" && !sealedByChatGpt(item.encrypted_content)) { + const foreign = item.encrypted_content != null + || (Array.isArray(item.content) && item.content.some((part) => part?.type === "reasoning_text")); + if (foreign) { + changed = true; + return []; + } } - if (item?.type === "compaction" - && typeof item.encrypted_content === "string" - && item.encrypted_content.startsWith(COMPACTION_PREFIX)) { + if (item?.type === "compaction" && !sealedByChatGpt(item.encrypted_content)) { changed = true; - const summary = openCompaction(item.encrypted_content, compactionSecret); + const summary = typeof item.encrypted_content === "string" + && item.encrypted_content.startsWith(COMPACTION_PREFIX) + ? openCompaction(item.encrypted_content, compactionSecret) + : null; return summary ? [{ type: "message", role: "assistant", content: [{ type: "output_text", text: `[Compacted prior context]\n${summary}` }], diff --git a/test/proxy.test.mjs b/test/proxy.test.mjs index af12092..ed4384d 100644 --- a/test/proxy.test.mjs +++ b/test/proxy.test.mjs @@ -807,10 +807,10 @@ for (const compressed of [false, true]) { const url = await listen(proxy); t.after(async () => { await close(proxy); await close(upstream); }); const retained = [ - { type: "reasoning", encrypted_content: "gpt-sealed", content: [{ type: "reasoning_text", text: "native" }] }, + { type: "reasoning", summary: [], content: [], encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000000" }, { type: "reasoning", summary: [{ type: "summary_text", text: "keep" }] }, { type: "message", role: "user", content: "reasoning_text is literal user text" }, - { type: "compaction", encrypted_content: "gpt-compaction" }, + { type: "compaction", encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000001" }, { type: "function_call", call_id: "call_1", name: "shell", arguments: "{}" }, { type: "function_call_output", call_id: "call_1", output: "done" }, ]; @@ -831,6 +831,44 @@ for (const compressed of [false, true]) { }); } +test("GPT replay drops encrypted payloads that another provider issued", async (t) => { + let observed; + const upstream = http.createServer(async (request, response) => { + observed = JSON.parse(await bodyOf(request)); + response.end('{}'); + }); + const proxy = createProxyServer({ chatGptBaseUrl: await listen(upstream), routerToken: ROUTER_TOKEN, logger: { info() {}, error() {} } }); + const url = await listen(proxy); + t.after(async () => { await close(proxy); await close(upstream); }); + + // ChatGPT verifies every encrypted payload it is handed. A reasoning item that came back + // from a different provider — a DeepSeek token, a DSCodex-sealed blob — cannot be verified, + // and Codex then fails the turn with "the encrypted content could not be verified / + // decrypted". That is what breaks a GPT sub-agent spawned from a DeepSeek session: the + // child's request replays the DeepSeek-flavoured history. Only ChatGPT-issued ciphertext + // (base64 "gAAAAA…") may be replayed; everything else is dropped. + const body = JSON.stringify({ + model: "gpt-5.6-sol", + input: [ + { + type: "reasoning", + summary: [], + content: [{ type: "reasoning_text", text: "deepseek thinking" }], + encrypted_content: "9591cfc5-c41a-4b44-9f51-a82a2f61d6ff-0", + }, + { type: "reasoning", summary: [], content: [], encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000002" }, + { type: "compaction", encrypted_content: "ZGVlcHNlZWstc2VhbGVkLWJsb2I=" }, + { type: "message", role: "user", content: [{ type: "input_text", text: "go on" }] }, + ], + }); + const response = await fetch(route(url), { method: "POST", headers: { "content-type": "application/json" }, body }); + + assert.equal(response.status, 200); + await response.text(); + assert.deepEqual(observed.input.map((item) => item.type), ["reasoning", "message"]); + assert.equal(observed.input[0].encrypted_content, "gAAAAABmSealedByChatGptForReplay0000000000000002"); +}); + test("ordinary compressed GPT traffic preserves exact bytes", async (t) => { let observed; const upstream = http.createServer(async (request, response) => { @@ -1030,14 +1068,14 @@ test("GPT websocket rewrite strips foreign DeepSeek reasoning_text", async (t) = type: "response.create", model: "gpt-6-astra", input: [ - { type: "reasoning", encrypted_content: "gpt-sealed", content: [{ type: "reasoning_text", text: "native" }] }, + { type: "reasoning", encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000003", content: [{ type: "reasoning_text", text: "native" }] }, { type: "reasoning", encrypted_content: null, content: [{ type: "reasoning_text", text: "foreign" }] }, { type: "message", role: "user", content: "hi" }, ], })); await waitUntil(() => upstream.state.messages.length >= 1); assert.deepEqual(JSON.parse(upstream.state.messages[0]).input, [ - { type: "reasoning", encrypted_content: "gpt-sealed", content: [{ type: "reasoning_text", text: "native" }] }, + { type: "reasoning", encrypted_content: "gAAAAABmSealedByChatGptForReplay0000000000000003", content: [{ type: "reasoning_text", text: "native" }] }, { type: "message", role: "user", content: "hi" }, ]); });