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
6 changes: 5 additions & 1 deletion open-sse/utils/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -595,8 +595,12 @@ export function createSSEStream(options: StreamOptions = {}) {
// When translating Responses -> Claude, `item` is already a Claude SSE event;
// sanitizing it as an OpenAI chunk strips message_start/content_block_delta/message_stop
// and causes Claude Code to drop the assistant message.
// #761: Responses API events have {event, data} structure — skip sanitization
// entirely as it strips them to {"object":"chat.completion.chunk"}, losing all content.
let itemSanitized: Record<string, unknown> = item;
if (sourceFormat === FORMATS.OPENAI || sourceFormat === FORMATS.OPENAI_RESPONSES) {
const isResponsesEvent =
typeof item?.event === "string" && item.event.startsWith("response.");
if (sourceFormat === FORMATS.OPENAI && !isResponsesEvent) {
itemSanitized = sanitizeStreamingChunk(itemSanitized) as Record<string, unknown>;

// Extract reasoning tags from content if translation generated them
Expand Down
66 changes: 66 additions & 0 deletions tests/unit/claude-code-rendering-fixes.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,72 @@ test("Responses->Chat: empty-name tool call is dropped when done still has no va
assert.equal(state.toolCallIndex, 0);
});

test("Claude->Responses: {event,data} items bypass sanitization in translate mode", async () => {
// Regression test: when translating Claude-format (GLM) to Responses API for Codex CLI,
// the sanitizer was stripping {event,data} items to {"object":"chat.completion.chunk"},
// losing all content and the critical response.completed event.
const encoder = new TextEncoder();
const decoder = new TextDecoder();

// Create stream translating claude → openai-responses (same path as GLM via Codex CLI)
const stream = createSSETransformStreamWithLogger(
FORMATS.CLAUDE,
FORMATS.OPENAI_RESPONSES,
"glm",
null,
null,
"glm-5.1",
"conn-test",
{ messages: [{ role: "user", content: "hi" }] },
null,
null
);

const writer = stream.writable.getWriter();
// Simulate Claude-format SSE from GLM
await writer.write(
encoder.encode(
'event: message_start\ndata: {"type":"message_start","message":{"id":"msg_test","type":"message","role":"assistant","model":"glm-5.1","content":[],"stop_reason":null,"usage":{"input_tokens":10,"output_tokens":0}}}\n\n'
)
);
await writer.write(
encoder.encode(
'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n'
)
);
await writer.write(
encoder.encode(
'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}\n\n'
)
);
await writer.write(
encoder.encode(
'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":5}}\n\n'
)
);
await writer.write(encoder.encode('event: message_stop\ndata: {"type":"message_stop"}\n\n'));
await writer.close();

const reader = stream.readable.getReader();
let output = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
output += decoder.decode(value, { stream: true });
}
output += decoder.decode();

// Must emit Responses API events (not sanitized chat.completion.chunk objects)
assert.match(output, /event: response\.created/);
assert.match(output, /event: response\.output_text\.delta/);
assert.match(output, /event: response\.completed/);
assert.match(output, /"delta":"hello"/);
assert.match(output, /"status":"completed"/);

// Must NOT contain sanitized empty chunks
assert.doesNotMatch(output, /data: \{"object":"chat\.completion\.chunk"\}\n\n/);
});

test("Responses->Claude: translated Claude SSE is not sanitized into empty OpenAI chunks", async () => {
const encoder = new TextEncoder();
const decoder = new TextDecoder();
Expand Down