diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 56ae32857e5..502e2565493 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -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 = 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; // Extract reasoning tags from content if translation generated them diff --git a/tests/unit/claude-code-rendering-fixes.test.mjs b/tests/unit/claude-code-rendering-fixes.test.mjs index 09cce6d217f..bb8012631ad 100644 --- a/tests/unit/claude-code-rendering-fixes.test.mjs +++ b/tests/unit/claude-code-rendering-fixes.test.mjs @@ -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();