Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,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";
Expand Down Expand Up @@ -1765,6 +1769,9 @@ async function handleResponsesInner(
hasResponsesItemIdRepair(repairConfig)
? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
: undefined,
route.providerName === "github-copilot"
? createGithubCopilotObfuscationRewrite()
: undefined,
Comment on lines +1772 to +1774

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Include the Copilot rewrite in needsClientRewrite.

On Line 1765, needsClientRewrite excludes the condition added on Lines 1772-1774. On Windows, clientBody then returns nativeBody and bypasses rewrittenBody. If selectEagerPath selects the eager relay, it also omits rewritePayload because win32EagerRewrite uses the same false gate. The client receives the original ciphertext deltas.

Define one Copilot rewrite flag. Include it in needsClientRewrite. Use the same flag when building payloadRewrites. Add a focused regression test for the Windows and eager relay paths.

Proposed fix
       const repairConfig = route.provider.responsesItemIdRepair;
-      const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
+      const needsGithubCopilotObfuscationRewrite = route.providerName === "github-copilot";
+      const needsClientRewrite = imageGenCallAliases.size > 0
+        || hasResponsesItemIdRepair(repairConfig)
+        || needsGithubCopilotObfuscationRewrite;
       // Compose opt-in payload rewrites into one parse/stringify pass (image-gen restore first).
       const payloadRewrites = [
         createImageGenCallRestoreRewrite(imageGenCallAliases),
         hasResponsesItemIdRepair(repairConfig)
           ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
           : undefined,
-        route.providerName === "github-copilot"
+        needsGithubCopilotObfuscationRewrite
           ? createGithubCopilotObfuscationRewrite()
           : undefined,
       ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);

Based on the PR objective, the rewrite must apply to both native-passthrough relay paths. As per path instructions, “A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
route.providerName === "github-copilot"
? createGithubCopilotObfuscationRewrite()
: undefined,
needsGithubCopilotObfuscationRewrite
? createGithubCopilotObfuscationRewrite()
: undefined,
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/server/responses/core.ts` around lines 1772 - 1774, Extract the
github-copilot provider check and createGithubCopilotObfuscationRewrite call
into a separate rewrite flag variable, then include that flag in the
needsClientRewrite conditional expression on Line 1765 so the rewrite is
considered when determining whether clientBody should use rewrittenBody instead
of nativeBody. Use the same rewrite flag variable when building payloadRewrites
to ensure win32EagerRewrite and selectEagerPath apply the rewrite consistently.
Add a focused regression test covering the Windows native-passthrough relay path
and eager relay selection path to verify the Copilot rewrite is applied
end-to-end.

Source: Path instructions

Comment on lines +1772 to +1774

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Mark Copilot streams as needing a client rewrite

For a GitHub Copilot Responses stream with neither image-generation aliases nor item-ID repair—the normal tool-call case—the new rewrite is added to payloadRewrites while needsClientRewrite remains false. On Windows this prevents win32EagerRewrite and later selects the unchanged nativeBody; configured eager relay also receives no rewritePayload, as does Darwin with streamMode: "eager-relay". The ciphertext therefore still reaches Codex and the tool turn stalls on those paths. Include the Copilot condition in needsClientRewrite and cover the platform gates with a regression test.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

].filter((rewrite): rewrite is NonNullable<typeof rewrite> => 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
Expand Down
122 changes: 122 additions & 0 deletions src/server/sse-payload-rewrite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,26 @@ import type { TranslatorBudget } from "../lib/translator-budget";

export type SsePayloadRewrite = (payload: string) => string;

function isPayloadRecord(v: unknown): v is Record<string, unknown> {
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<string, unknown>): 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/);
Expand Down Expand Up @@ -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<number>();
// 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<number, string>();
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Assign a unique sequence number to the synthetic delta

The inserted delta copies the done event's sequence_number, so the emitted stream contains two consecutive recognized events with the same number—for example, the new test produces both the plaintext delta and done event at sequence 5. Other generated Responses streams increment the sequence for every event in src/bridge.ts:278, while src/server/ws-bridge.ts:247 forwards rewritten payloads verbatim; clients that order or deduplicate by this field can therefore discard either the plaintext arguments or the completion event. Track an insertion offset and renumber the done and subsequent events so the sequence remains unique and monotonic.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

delta: typeof parsed.arguments === "string" ? parsed.arguments : "",
};
// The relay writes `data: <payload>` 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)}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve SSE event names when inserting the delta

When the upstream done frame includes event: response.function_call_arguments.done, as the repository's own src/bridge.ts:18-20 framing does, embedding a blank line inside the replacement payload leaves that event field attached to the synthetic delta and emits the original done data as an unnamed message event. Clients that dispatch by SSE event name therefore receive neither event under its correct name and can still fail to commit the tool call. Emit two complete blocks with their respective event: fields, and test a named upstream frame.

AGENTS.md reference: src/AGENTS.md:L19-L19

Useful? React with 👍 / 👎.

}
}
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.
Expand Down
104 changes: 104 additions & 0 deletions tests/sse-payload-rewrite.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -39,6 +40,109 @@ async function readAll(stream: ReadableStream<Uint8Array>): Promise<string> {
}

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<string, unknown> };
const items = out.response?.output ?? (out.item ? [out.item] : []);
for (const item of items as Record<string, unknown>[]) {
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',
Expand Down
Loading