From f69e2be6d70e8960f9b69f9884f8135954165f2f Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 4 Aug 2026 12:01:10 +0200 Subject: [PATCH] fix(providers): normalize GitHub Copilot streamed Responses wire GitHub Copilot's /responses stream is incompatible with Responses clients for the vscode-chat integration: function-call argument deltas are obfuscated (ciphertext + obfuscation field), reasoning items ship as GitHub-encrypted encrypted_content with no plaintext summary, and the response id plus every item id are re-encrypted per event so the terminal completed payload disagrees with the streamed events. Agentic turns on Responses-only Copilot models (gpt-5.6-luna/sol/terra, gpt-5.4, ...) stall in Codex clients: reasoning never finishes and tool calls are unreadable. Add a provider-scoped SSE rewrite wired into the passthrough payloadRewrites composition for github-copilot: drop ciphertext deltas and re-emit the plaintext arguments as one delta before done, replace encrypted reasoning items with a canonical empty plaintext summary (in output_item events and the terminal completed payload), pin response/item ids to first-seen values, and strip the obfuscation metadata from every event. Clean events pass through byte-identical. --- src/server/responses/core.ts | 9 ++- src/server/sse-payload-rewrite.ts | 122 ++++++++++++++++++++++++++++++ tests/sse-payload-rewrite.test.ts | 104 +++++++++++++++++++++++++ 3 files changed, 234 insertions(+), 1 deletion(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 6225e8f9bd..b4825168d7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -169,7 +169,11 @@ import { imageGenToolCallAliases, restoreImageGenCallsInJson, } from "../responses-image-gen-repair"; -import { composeSsePayloadRewrites, relaySseWithPayloadRewrite } from "../sse-payload-rewrite"; +import { + composeSsePayloadRewrites, + createGithubCopilotObfuscationRewrite, + relaySseWithPayloadRewrite, +} from "../sse-payload-rewrite"; import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; import { buildToolBridgeMaps, collabSurface, injectDeveloperMessage, multiAgentGuidanceText } from "./collaboration"; @@ -1972,6 +1976,9 @@ async function handleResponsesInner( hasResponsesItemIdRepair(repairConfig) ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget) : undefined, + route.providerName === "github-copilot" + ? createGithubCopilotObfuscationRewrite() + : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); // #864: win32 rewrite traffic must never enter the tee()+JS-pull chain // (Bun#32111 JS-sink segfault — text frames pass, the terminal block is diff --git a/src/server/sse-payload-rewrite.ts b/src/server/sse-payload-rewrite.ts index 712853cee1..664801f89f 100644 --- a/src/server/sse-payload-rewrite.ts +++ b/src/server/sse-payload-rewrite.ts @@ -9,6 +9,26 @@ import type { TranslatorBudget } from "../lib/translator-budget"; export type SsePayloadRewrite = (payload: string) => string; +function isPayloadRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +/** + * GitHub Copilot encrypts reasoning summaries (and re-encrypts them per event) with its own + * scheme, which Responses clients cannot decrypt; the app then stays stuck on "thinking". + * Replace the ciphertext with a canonical empty plaintext summary so the item completes + * immediately and the turn moves on to the actual output. + */ +function sanitizeGithubCopilotReasoningItem(item: Record): boolean { + if (item.type !== "reasoning" || typeof item.encrypted_content !== "string") return false; + delete item.encrypted_content; + const summary = Array.isArray(item.summary) ? item.summary : []; + summary.push({ type: "summary_text", text: "" }); + item.summary = summary; + if (!Array.isArray(item.content)) item.content = []; + return true; +} + /** Split one complete SSE event block while retaining its original blank-line delimiter. */ export function nextSseBlock(buffer: string): { block: string; delimiter: string; rest: string } | null { const match = buffer.match(/\r?\n\r?\n/); @@ -61,6 +81,108 @@ export function composeSsePayloadRewrites(...rewrites: SsePayloadRewrite[]): Sse }; } +/** + * GitHub Copilot's `/responses` wire obfuscates streamed function-call argument + * deltas (ciphertext `delta` plus an `obfuscation` field) for the `vscode-chat` + * integration. Responses clients (codex-rs) cannot de-obfuscate them, so an + * agentic turn stalls on the first tool call. The terminal + * `response.function_call_arguments.done` event carries the plaintext + * `arguments`, so this rewrite drops the ciphertext deltas and re-emits the + * full plaintext arguments as a single delta immediately before `done`, keeping + * the stream protocol-conformant (deltas still concatenate to the final + * arguments). The `obfuscation` metadata is stripped from every event. + */ +export function createGithubCopilotObfuscationRewrite(): SsePayloadRewrite { + // GitHub re-encrypts `item_id` per event (delta and done carry different ids for the + // same logical call), so calls are tracked by the stable `output_index` instead. + const obfuscatedCalls = new Set(); + // GitHub also re-encrypts the response id and every item id per event (`created`/`done` + // and the terminal `completed` payload disagree). Clients reconcile streamed items with + // the completed response by id, so pin every entity to its first-seen id. + let responseId: string | null = null; + const itemIds = new Map(); + return (payload: string): string => { + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + return payload; + } + if (!isPayloadRecord(parsed)) return payload; + let changed = false; + if (typeof parsed.obfuscation === "string") { + delete parsed.obfuscation; + changed = true; + } + const type = parsed.type; + if (type === "response.function_call_arguments.delta" && changed) { + if (typeof parsed.output_index === "number") obfuscatedCalls.add(parsed.output_index); + // Ciphertext chunk: emit an empty delta so the client sees a valid event + // without any unusable bytes; the plaintext arrives with `.done`. + parsed.delta = ""; + return JSON.stringify(parsed); + } + if (type === "response.function_call_arguments.done" && typeof parsed.output_index === "number") { + if (obfuscatedCalls.delete(parsed.output_index)) { + const deltaEvent = { + type: "response.function_call_arguments.delta", + item_id: parsed.item_id, + output_index: parsed.output_index, + sequence_number: parsed.sequence_number, + delta: typeof parsed.arguments === "string" ? parsed.arguments : "", + }; + // The relay writes `data: ` then the block delimiter, so this + // payload becomes two consecutive valid SSE events (delta, then done). + return `${JSON.stringify(deltaEvent)}\n\ndata: ${JSON.stringify(parsed)}`; + } + } + if (type === "response.created" && isPayloadRecord(parsed.response)) { + const id = parsed.response.id; + if (typeof id === "string") { + if (responseId === null) responseId = id; + else if (id !== responseId) { + parsed.response.id = responseId; + changed = true; + } + } + } + if (type === "response.output_item.added" || type === "response.output_item.done") { + const outputIndex = parsed.output_index; + const item = parsed.item; + if (typeof outputIndex === "number" && isPayloadRecord(item) && typeof item.id === "string") { + const existing = itemIds.get(outputIndex); + if (existing === undefined) itemIds.set(outputIndex, item.id); + else if (item.id !== existing) { + item.id = existing; + changed = true; + } + } + if (isPayloadRecord(item) && sanitizeGithubCopilotReasoningItem(item)) changed = true; + } + if (type === "response.completed" && isPayloadRecord(parsed.response)) { + if (typeof parsed.response.id === "string" && responseId !== null && parsed.response.id !== responseId) { + parsed.response.id = responseId; + changed = true; + } + if (Array.isArray(parsed.response.output)) { + parsed.response.output.forEach((item, outputIndex) => { + if (!isPayloadRecord(item)) return; + if (typeof item.id === "string") { + const existing = itemIds.get(outputIndex); + if (existing === undefined) itemIds.set(outputIndex, item.id); + else if (item.id !== existing) { + item.id = existing; + changed = true; + } + } + if (sanitizeGithubCopilotReasoningItem(item)) changed = true; + }); + } + } + return changed ? JSON.stringify(parsed) : payload; + }; +} + /** * Relay an SSE body through a single JS pull wrapper, rewriting each event's data payload in place. * Non-data fields and framing are preserved; invalid JSON payloads are left to the rewrite callback. diff --git a/tests/sse-payload-rewrite.test.ts b/tests/sse-payload-rewrite.test.ts index c6c72f615b..fec094fa2d 100644 --- a/tests/sse-payload-rewrite.test.ts +++ b/tests/sse-payload-rewrite.test.ts @@ -6,6 +6,7 @@ import { createImageGenCallRestoreRewrite } from "../src/server/responses-image- import { createResponsesItemIdPayloadRewrite } from "../src/server/responses-item-id-repair"; import { composeSsePayloadRewrites, + createGithubCopilotObfuscationRewrite, relaySseWithPayloadRewrite, } from "../src/server/sse-payload-rewrite"; import { createTestTranslatorBudget } from "./helpers/translator-budget"; @@ -39,6 +40,109 @@ async function readAll(stream: ReadableStream): Promise { } describe("SSE payload rewrite composition", () => { + test("GitHub Copilot obfuscation rewrite turns ciphertext deltas into plaintext", async () => { + const rewrite = createGithubCopilotObfuscationRewrite(); + // GitHub re-encrypts item ids per event; deltas and done never share an id. + const deltaItemId = "cipher-delta-id"; + const doneItemId = "cipher-done-id"; + const upstream = [ + 'data: {"type":"response.function_call_arguments.delta","item_id":"' + deltaItemId + '","output_index":0,"sequence_number":3,"delta":"ciphertext-chunk-1","obfuscation":"abc123"}\n\n', + 'data: {"type":"response.function_call_arguments.delta","item_id":"' + deltaItemId + '","output_index":0,"sequence_number":4,"delta":"ciphertext-chunk-2","obfuscation":"abc123"}\n\n', + 'data: {"type":"response.function_call_arguments.done","item_id":"' + doneItemId + '","output_index":0,"sequence_number":5,"arguments":"{\\"a\\":2,\\"b\\":2}"}\n\n', + ].join(""); + + const rewritten = await readAll( + relaySseWithPayloadRewrite( + streamFromText(upstream), + rewrite, + createTestTranslatorBudget(), + ), + ); + + const events = rewritten + .split("\n\n") + .map((block) => block.replace(/^data: /, "")) + .filter((block) => block.length > 0) + .map((block) => JSON.parse(block)); + + expect(events.map((event) => event.type)).toEqual([ + "response.function_call_arguments.delta", + "response.function_call_arguments.delta", + "response.function_call_arguments.delta", + "response.function_call_arguments.done", + ]); + expect(events[0].delta).toBe(""); + expect(events[1].delta).toBe(""); + expect(events[2].delta).toBe('{"a":2,"b":2}'); + expect(events[2].item_id).toBe(doneItemId); + expect(events[3].arguments).toBe('{"a":2,"b":2}'); + expect(JSON.stringify(rewritten)).not.toContain("obfuscation"); + expect(JSON.stringify(rewritten)).not.toContain("ciphertext-chunk"); + }); + + test("GitHub Copilot obfuscation rewrite strips obfuscation from text deltas", async () => { + const rewrite = createGithubCopilotObfuscationRewrite(); + const upstream = + 'data: {"type":"response.output_text.delta","item_id":"msg_0","output_index":0,"sequence_number":1,"delta":"bonjour","obfuscation":"abc123"}\n\n'; + + const rewritten = await readAll( + relaySseWithPayloadRewrite( + streamFromText(upstream), + rewrite, + createTestTranslatorBudget(), + ), + ); + + expect(rewritten).toContain('"delta":"bonjour"'); + expect(rewritten).not.toContain("obfuscation"); + }); + + test("GitHub Copilot obfuscation rewrite passes clean events through unchanged", () => { + const rewrite = createGithubCopilotObfuscationRewrite(); + const payload = '{"type":"response.output_text.delta","delta":"hello"}'; + expect(rewrite(payload)).toBe(payload); + }); + + test("GitHub Copilot obfuscation rewrite neutralizes encrypted reasoning items", () => { + const rewrite = createGithubCopilotObfuscationRewrite(); + const added = '{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"cipher-id","content":[],"encrypted_content":"ciphertext"}}'; + const done = '{"type":"response.output_item.done","output_index":0,"item":{"type":"reasoning","id":"cipher-id","content":[],"encrypted_content":"ciphertext"}}'; + const completed = '{"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[{"type":"reasoning","id":"cipher-id","content":[],"encrypted_content":"ciphertext"},{"type":"function_call","id":"fc_1","name":"shell","arguments":"{}"}]}}'; + + for (const payload of [added, done, completed]) { + const out = JSON.parse(rewrite(payload)!) as { response?: { output?: unknown[] }; item?: Record }; + const items = out.response?.output ?? (out.item ? [out.item] : []); + for (const item of items as Record[]) { + if (item.type === "reasoning") { + expect(item.encrypted_content).toBeUndefined(); + expect(item.summary).toEqual([{ type: "summary_text", text: "" }]); + } + } + } + }); + + test("GitHub Copilot obfuscation rewrite pins response and item ids to first-seen values", () => { + const rewrite = createGithubCopilotObfuscationRewrite(); + const created = '{"type":"response.created","response":{"id":"resp-A","status":"in_progress"}}'; + const addedReasoning = '{"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs-stream","content":[],"encrypted_content":"ciphertext"}}'; + const addedCall = '{"type":"response.output_item.added","output_index":1,"item":{"type":"function_call","id":"fc-stream","call_id":"call_1","name":"shell","arguments":""}}'; + const doneCall = '{"type":"response.output_item.done","output_index":1,"item":{"type":"function_call","id":"fc-done","call_id":"call_1","name":"shell","arguments":"{\\"command\\":\\"ls\\"}"}}'; + const completed = '{"type":"response.completed","response":{"id":"resp-B","status":"completed","output":[{"type":"reasoning","id":"rs-final","content":[],"encrypted_content":"ciphertext"},{"type":"function_call","id":"fc-final","call_id":"call_1","name":"shell","arguments":"{\\"command\\":\\"ls\\"}"}]}}'; + + const createdOut = JSON.parse(rewrite(created)!) as { response: { id: string } }; + rewrite(addedReasoning); + rewrite(addedCall); + const doneOut = JSON.parse(rewrite(doneCall)!) as { item: { id: string } }; + const completedOut = JSON.parse(rewrite(completed)!) as { response: { id: string; output: { id: string; encrypted_content?: string }[] } }; + + expect(createdOut.response.id).toBe("resp-A"); + expect(doneOut.item.id).toBe("fc-stream"); + expect(completedOut.response.id).toBe("resp-A"); + expect(completedOut.response.output[0]!.id).toBe("rs-stream"); + expect(completedOut.response.output[1]!.id).toBe("fc-stream"); + expect(completedOut.response.output[0]!.encrypted_content).toBeUndefined(); + }); + test("applies image-gen restore and item-id repair in one relay pass", async () => { const upstream = [ 'event: response.output_item.added\ndata: {"type":"response.output_item.added","output_index":0,"item":{"type":"message","id":"msg_0","role":"assistant"}}\n\n',