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 @@ -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";
Expand Down Expand Up @@ -1972,6 +1976,9 @@ async function handleResponsesInner(
hasResponsesItemIdRepair(repairConfig)
? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
: undefined,
route.providerName === "github-copilot"
? createGithubCopilotObfuscationRewrite()
: undefined,
Comment on lines +1979 to +1981

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 Include Copilot in the eager-relay rewrite gate

In src/server/responses/core.ts, adding the Copilot transform only to payloadRewrites leaves needsClientRewrite false when it is the sole rewrite. Consequently, macOS with streamMode: "eager-relay" and Windows when eager relay is selected enter the branch at line 1992, but rewritePayload is omitted because win32EagerRewrite is also false, so obfuscated Copilot events reach the client unchanged and tool turns still stall. Derive the rewrite requirement from payloadRewrites.length or explicitly include Copilot so every eager path either applies the transform inline or remains on the rewriting tee path.

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

Useful? React with 👍 / 👎.

Comment on lines +1979 to +1981

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.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Include the Copilot rewrite in needsClientRewrite.

For Copilot-only traffic, payloadRewrites contains the new rewrite while needsClientRewrite remains false. On Windows, this skips win32EagerRewrite and sends rewrite traffic through the tee plus JS-pull path that the existing code marks unsafe. On Darwin with streamMode: "eager-relay", this selects the no-rewrite eager path and omits rewritePayload, so clients receive the incompatible Copilot events unchanged.

Add a dedicated Copilot boolean to needsClientRewrite, and reuse it when creating the rewrite. Add regression coverage for Copilot-only traffic on both affected relay selections.

Proposed fix
       const repairConfig = route.provider.responsesItemIdRepair;
-      const needsClientRewrite = imageGenCallAliases.size > 0 || hasResponsesItemIdRepair(repairConfig);
+      const needsGithubCopilotRewrite = route.providerName === "github-copilot";
+      const needsClientRewrite = imageGenCallAliases.size > 0
+        || hasResponsesItemIdRepair(repairConfig)
+        || needsGithubCopilotRewrite;
       const payloadRewrites = [
         createImageGenCallRestoreRewrite(imageGenCallAliases),
         hasResponsesItemIdRepair(repairConfig)
           ? createResponsesItemIdPayloadRewrite(repairConfig!, translatorBudget)
           : undefined,
-        route.providerName === "github-copilot"
+        needsGithubCopilotRewrite
           ? createGithubCopilotObfuscationRewrite()
           : undefined,
       ].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);

As per path instructions, “A behavior change in src/ should come with a focused regression test near existing tests for that subsystem.”

🤖 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 1979 - 1981, Update the response
routing logic around needsClientRewrite to include a dedicated boolean for
github-copilot traffic, and reuse that boolean when conditionally creating
createGithubCopilotObfuscationRewrite(). Add focused regression tests covering
Copilot-only traffic on Windows relay selection and Darwin streamMode
"eager-relay", verifying the client rewrite path and rewritePayload behavior
respectively.

Source: Path instructions

].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>();

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 Bound retained Copilot rewrite state

If a buggy or hostile Copilot stream emits obfuscated deltas with many distinct output_index values and never sends the matching done events, obfuscatedCalls grows for the entire request with no entry cap or translator-budget charge. The relay releases its per-frame budget after every event, so a long-lived upstream can grow this set without hitting the existing 32 MiB translation limit. Charge this retained state to translatorBudget, impose a bounded index count, or fail the stream once the cap is exceeded.

Useful? React with 👍 / 👎.

// 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,

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 Normalize argument-event item IDs to the pinned item ID

When Copilot re-encrypts the function-call item's ID between response.output_item.added and response.function_call_arguments.done, this synthetic delta copies the latter cipher ID even though the item lifecycle and completed snapshot have been pinned to the first ID. Consumers that correlate argument events by item_id cannot attach the plaintext arguments to the function call; for example, src/chat/outbound.ts records the added ID at line 379 and ignores a done event whose ID is absent from that map at lines 397-400. Rewrite the original deltas, synthetic delta, and done event to itemIds.get(output_index) so the entire function-call lifecycle uses one ID.

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

Useful? React with 👍 / 👎.

output_index: parsed.output_index,
sequence_number: parsed.sequence_number,
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 Emit the synthetic delta as a separately framed SSE event

When Copilot sends standard named SSE blocks such as event: response.function_call_arguments.done, embedding \n\ndata: inside the replacement payload leaves that original event: field attached to the synthetic delta, while the following done payload becomes an unnamed message event. Clients dispatching by SSE event name can therefore parse the delta as a done event and ignore the actual done event. Emit two complete blocks with matching event: fields, or make the relay rewrite framing-aware, rather than inserting an event separator inside a data payload.

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

Useful? React with 👍 / 👎.

Comment on lines +118 to +136

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

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Verify whether optional generic item-ID repair currently covers GitHub Copilot.
rg -n -C 4 'responsesItemIdRepair|github-copilot' src
rg -n -C 4 'cipher-delta-id|cipher-done-id|item_id' tests/sse-payload-rewrite.test.ts

Repository: lidge-jun/opencodex

Length of output: 16633


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- outline ---'
ast-grep outline src/server/sse-payload-rewrite.ts
echo '--- implementation ---'
sed -n '1,190p' src/server/sse-payload-rewrite.ts
echo '--- relevant tests ---'
sed -n '1,120p' tests/sse-payload-rewrite.test.ts
echo '--- rewrite composition and config ---'
sed -n '1960,1985p' src/server/responses/core.ts
rg -n -C 8 'function hasResponsesItemIdRepair|createResponsesItemIdPayloadRewrite|createGithubCopilotObfuscationRewrite|itemIds' src/server

Repository: lidge-jun/opencodex

Length of output: 27880


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo '--- relay framing ---'
sed -n '180,280p' src/server/sse-payload-rewrite.ts
echo '--- remaining rewrite tests ---'
sed -n '120,260p' tests/sse-payload-rewrite.test.ts
echo '--- item-id repair state model ---'
sed -n '1,220p' src/server/responses-item-id-repair.ts

Repository: lidge-jun/opencodex

Length of output: 17836


🌐 Web query:

OpenAI Responses API streaming response.function_call_arguments.delta response.function_call_arguments.done item_id output_index

💡 Result:

In the OpenAI Responses and Realtime APIs, streaming function call arguments is handled via specific server-sent events that allow developers to build arguments incrementally [1][2]. The two primary events for this process are: 1. response.function_call_arguments.delta: This event is emitted incrementally as the model generates the JSON arguments for a function [1][3]. It contains the delta string that should be appended to your local buffer for that specific function call [1][3]. Key fields include item_id (identifying the function call), output_index, and the delta string itself [1][3][4]. 2. response.function_call_arguments.done: This event is emitted once the model has finished generating all arguments for a function call [1][3]. It provides the finalized, complete argument string [1]. It also contains the item_id and output_index, allowing you to map the finished arguments back to the specific function call initiated [1][3]. Key technical details for implementation: - Buffering: You should maintain a buffer (usually a string or dictionary) for each active function call, keyed by its item_id or call_id [5][3]. - Incremental Updates: When a response.function_call_arguments.delta event arrives, append the delta field to the corresponding buffer [1][5][3]. Do not parse the JSON until the process is complete [3]. - Completion: Upon receiving response.function_call_arguments.done, you can safely parse the fully assembled JSON string to invoke your function [1][3]. - Item Mapping: Both events provide the item_id and output_index, which are critical for tracking multiple simultaneous function calls or managing state in complex responses [1][3][4]. Note that in some earlier versions of the SDK, developers reported issues where the function name field was missing in the done event [6]; ensuring you are using the latest stable version of the OpenAI SDK is recommended to avoid such issues [6].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json

events = [
    {
        "type": "response.output_item.added",
        "output_index": 0,
        "item": {"type": "function_call", "id": "first-item-id"},
    },
    {
        "type": "response.function_call_arguments.delta",
        "item_id": "cipher-delta-id",
        "output_index": 0,
        "sequence_number": 3,
        "delta": "ciphertext-chunk-1",
        "obfuscation": "abc123",
    },
    {
        "type": "response.function_call_arguments.done",
        "item_id": "cipher-done-id",
        "output_index": 0,
        "sequence_number": 5,
        "arguments": '{"a":2,"b":2}',
    },
]

# Faithful behavioral probe of the inspected implementation's relevant branches.
obfuscated = set()
item_ids = {}
out = []
for event in events:
    parsed = json.loads(json.dumps(event))
    changed = False
    if isinstance(parsed.get("obfuscation"), str):
        del parsed["obfuscation"]
        changed = True
    typ = parsed.get("type")
    if typ == "response.function_call_arguments.delta" and changed:
        obfuscated.add(parsed["output_index"])
        parsed["delta"] = ""
        out.append(parsed)
        continue
    if typ == "response.function_call_arguments.done" and isinstance(parsed.get("output_index"), (int, float)):
        if parsed["output_index"] in obfuscated:
            obfuscated.remove(parsed["output_index"])
            out.append({
                "type": "response.function_call_arguments.delta",
                "item_id": parsed.get("item_id"),
                "output_index": parsed["output_index"],
                "sequence_number": parsed.get("sequence_number"),
                "delta": parsed.get("arguments", ""),
            })
    if typ in ("response.output_item.added", "response.output_item.done"):
        item = parsed.get("item")
        index = parsed.get("output_index")
        if isinstance(index, (int, float)) and isinstance(item, dict) and isinstance(item.get("id"), str):
            item_ids.setdefault(index, item["id"])
    out.append(parsed)

print("remembered nested item id:", item_ids[0])
print("argument event item ids:", [e.get("item_id") for e in out if "item_id" in e])
print("synthetic delta item id:", out[2]["item_id"])
assert item_ids[0] == "first-item-id"
assert [e.get("item_id") for e in out if "item_id" in e] == [
    "cipher-delta-id", "cipher-done-id", "cipher-done-id"
]
assert out[2]["item_id"] != item_ids[0]
print("result: top-level function-call item_id values are not pinned")
PY

Repository: lidge-jun/opencodex

Length of output: 381


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
import json

events = [
    {
        "type": "response.output_item.added",
        "output_index": 0,
        "item": {"type": "function_call", "id": "first-item-id"},
    },
    {
        "type": "response.function_call_arguments.delta",
        "item_id": "cipher-delta-id",
        "output_index": 0,
        "sequence_number": 3,
        "delta": "ciphertext-chunk-1",
        "obfuscation": "abc123",
    },
    {
        "type": "response.function_call_arguments.done",
        "item_id": "cipher-done-id",
        "output_index": 0,
        "sequence_number": 5,
        "arguments": '{"a":2,"b":2}',
    },
]

obfuscated = set()
item_ids = {}
out = []

for event in events:
    parsed = json.loads(json.dumps(event))
    changed = False

    if isinstance(parsed.get("obfuscation"), str):
        del parsed["obfuscation"]
        changed = True

    typ = parsed.get("type")
    if typ == "response.function_call_arguments.delta" and changed:
        obfuscated.add(parsed["output_index"])
        parsed["delta"] = ""
        out.append(parsed)
        continue

    if typ == "response.function_call_arguments.done":
        if parsed["output_index"] in obfuscated:
            obfuscated.remove(parsed["output_index"])
            out.append({
                "type": "response.function_call_arguments.delta",
                "item_id": parsed.get("item_id"),
                "output_index": parsed["output_index"],
                "sequence_number": parsed.get("sequence_number"),
                "delta": parsed.get("arguments", ""),
            })

    if typ in ("response.output_item.added", "response.output_item.done"):
        item = parsed.get("item")
        index = parsed.get("output_index")
        if isinstance(index, (int, float)) and isinstance(item, dict) and isinstance(item.get("id"), str):
            item_ids.setdefault(index, item["id"])

    out.append(parsed)

print("remembered nested item id:", item_ids[0])
print("argument event item ids:", [e.get("item_id") for e in out if "item_id" in e])
print("synthetic delta item id:", out[2]["item_id"])

assert item_ids[0] == "first-item-id"
assert [e.get("item_id") for e in out if "item_id" in e] == [
    "cipher-delta-id", "cipher-done-id", "cipher-done-id"
]
assert out[2]["item_id"] != item_ids[0]
print("result: top-level function-call item_id values are not pinned")
PY

Repository: lidge-jun/opencodex

Length of output: 381


Normalize top-level item_id values by output_index.

itemIds currently normalizes only nested item.id. Argument delta and done events retain different top-level parsed.item_id values, and the synthetic plaintext delta copies the done-event ID. This breaks function-call correlation.

  • src/server/sse-payload-rewrite.ts:118-136: Normalize parsed.item_id before the early delta return and use the canonical ID in deltaEvent. Track hadObfuscation separately so ID normalization does not cause clean deltas to be replaced with empty strings.
  • tests/sse-payload-rewrite.test.ts:43-80: Add a preceding response.output_item.added event and assert that all argument delta and done events use its first-seen item ID.
📍 Affects 2 files
  • src/server/sse-payload-rewrite.ts#L118-L136 (this comment)
  • tests/sse-payload-rewrite.test.ts#L43-L80
🤖 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/sse-payload-rewrite.ts` around lines 118 - 136, Normalize
top-level parsed.item_id by output_index in the argument-event handling before
the early delta return, while tracking whether the event was actually obfuscated
separately so clean deltas retain their payload. In
src/server/sse-payload-rewrite.ts:118-136, use the canonical normalized ID when
constructing deltaEvent. In tests/sse-payload-rewrite.test.ts:43-80, add a
preceding response.output_item.added event and verify all related argument delta
and done events use its first-seen item ID.

}
}
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;

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 Key continuation state by the client-visible response ID

When Copilot gives response.created and response.completed different encrypted IDs, this line changes the completed ID delivered to the client, but the inspection branch still passes the raw completed response to rememberPassthroughResponse in src/server/responses/core.ts, and rememberResponseState keys its cache by that raw terminal ID. The next request therefore supplies the rewritten first-seen ID as previous_response_id, misses the local replay cache, and forwards the tool-result turn without its prior conversation history. Store an alias under the client-visible ID or normalize the snapshot before recording it while retaining the raw output items for upstream replay.

Useful? React with 👍 / 👎.

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