diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index bc567d205e6a..21fea907210d 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -8,6 +8,7 @@ "_rebaseline_2026_06_15_3907_zai_validator": "PR #3907 own growth: validation.ts 4348->4394 (+46 = a zai (z.ai/glm) validator using directHttpsRequest to bypass the undici pool — same anti-zombie-socket pattern as nvidia #3226 — since z.ai drops idle keep-alive sockets without TCP RST after 502s. Anthropic wire format, x-api-key, 401/403->invalid else->valid). Cohesive validator branch; not extractable. (The global keepAlive 4000->1000 change from the original PR was reverted on review — scoped to the zai validator only.)", "_rebaseline_2026_06_15_3906_proxy_direct_fallback": "PR #3906 own growth: oauth/[provider]/[action]/route.ts 916->918 (+2 = swap runWithProxyContext -> runWithProxyContextOrDirect at the control-plane callsites so a dead pinned proxy degrades to a direct connection instead of a 500; the longer call name + a prettier reflow add the lines). Cohesive control-plane change; not extractable.", "_rebaseline_2026_06_15_3871_empty_pool": "PR #3871 own growth: combo.ts 5203->5204 (+1 = guard expandAutoComboCandidatePool against an empty candidatePool array — Array.isArray(pool) && pool.length > 0 so [] falls through to active-connection expansion instead of early-returning). One-line correctness fix; not extractable.", + "_rebaseline_2026_06_15_3911_sse_role": "PR #3911 own growth: openai-responses.ts 878->903 (+25 = withAssistantRoleOnFirstDelta wrapper that synthesizes role=assistant on the first Responses->Chat delta so strict streaming clients (langchain/n8n) do not drop tool_call deltas). Cohesive translator fix; not extractable.", "cap": 800, "frozen": { "open-sse/config/providerRegistry.ts": 4708, @@ -41,7 +42,7 @@ "open-sse/services/tokenRefresh.ts": 1997, "open-sse/services/usage.ts": 3408, "open-sse/translator/request/openai-to-gemini.ts": 844, - "open-sse/translator/response/openai-responses.ts": 878, + "open-sse/translator/response/openai-responses.ts": 903, "open-sse/utils/cursorAgentProtobuf.ts": 1521, "open-sse/utils/stream.ts": 2710, "src/app/(dashboard)/dashboard/HomePageClient.tsx": 1385, diff --git a/open-sse/translator/response/openai-responses.ts b/open-sse/translator/response/openai-responses.ts index d0c6731d18a8..6c54661f4910 100644 --- a/open-sse/translator/response/openai-responses.ts +++ b/open-sse/translator/response/openai-responses.ts @@ -490,11 +490,36 @@ function normalizeUpstreamFailure(data, fallbackType = "server_error") { }; } +/** + * OpenAI Chat Completions streams announce the assistant role on the FIRST delta + * (e.g. `{ "role": "assistant", "content": "" }` or `{ "role": "assistant", + * "tool_calls": [...] }`). The Responses API has no role-announcement event, so when + * translating Responses → Chat we must synthesize it on the first emitted chunk. + * + * Strict streaming clients — notably @langchain/openai's `_convertDeltaToMessageChunk` + * (used by n8n's AI Agent) — key off the first chunk's role to build an AIMessageChunk. + * Without it, streamed tool_call deltas are dropped and the agent returns an empty + * response, even though the underlying tool call is well-formed. + */ +function withAssistantRoleOnFirstDelta(state, result) { + if (!result || state.roleEmitted) return result; + const delta = result.choices?.[0]?.delta; + if (delta && typeof delta === "object" && !Array.isArray(delta)) { + delta.role = "assistant"; + state.roleEmitted = true; + } + return result; +} + /** * Translate OpenAI Responses API chunk to OpenAI Chat Completions format * This is for when Codex returns data and we need to send it to an OpenAI-compatible client */ export function openaiResponsesToOpenAIResponse(chunk, state) { + return withAssistantRoleOnFirstDelta(state, openaiResponsesToOpenAIResponseStream(chunk, state)); +} + +function openaiResponsesToOpenAIResponseStream(chunk, state) { if (!chunk) { // Flush: send final chunk with finish_reason if (!state.finishReasonSent && state.started) { diff --git a/tests/unit/responses-chat-assistant-role-first-chunk.test.ts b/tests/unit/responses-chat-assistant-role-first-chunk.test.ts new file mode 100644 index 000000000000..de243d6c2d7e --- /dev/null +++ b/tests/unit/responses-chat-assistant-role-first-chunk.test.ts @@ -0,0 +1,70 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { openaiResponsesToOpenAIResponse } = + await import("../../open-sse/translator/response/openai-responses.ts"); + +/** + * Regression: Responses → Chat Completions streaming must announce the assistant + * role on the FIRST emitted delta. + * + * OpenAI Chat Completions streams put `role: "assistant"` on the first chunk's + * delta. The Responses API has no role-announcement event, so the translator must + * synthesize it. Strict streaming clients — notably @langchain/openai's + * `_convertDeltaToMessageChunk` (used by n8n's AI Agent) — key off the first + * chunk's role to build an AIMessageChunk. Without it, streamed tool_call deltas + * are dropped and the agent receives an empty response. + */ + +test("Responses->Chat: first tool_call chunk announces role=assistant", () => { + const state: Record = {}; + + const first = openaiResponsesToOpenAIResponse( + { + type: "response.output_item.added", + item: { + type: "function_call", + call_id: "call_abc", + name: "get_weather", + arguments: "", + }, + }, + state + ); + + assert.ok(first, "should emit a chunk for output_item.added"); + assert.equal( + first.choices[0].delta.role, + "assistant", + "first emitted delta must carry role=assistant" + ); + assert.equal(first.choices[0].delta.tool_calls[0].function.name, "get_weather"); + + // Subsequent argument deltas must NOT repeat the role announcement. + const next = openaiResponsesToOpenAIResponse( + { type: "response.function_call_arguments.delta", delta: '{"x":1}' }, + state + ); + assert.ok(next, "should emit a chunk for arguments.delta"); + assert.equal(next.choices[0].delta.role, undefined, "only the first delta announces the role"); +}); + +test("Responses->Chat: first text chunk announces role=assistant", () => { + const state: Record = {}; + + const first = openaiResponsesToOpenAIResponse( + { type: "response.output_text.delta", delta: "Olá" }, + state + ); + + assert.ok(first, "should emit a chunk for output_text.delta"); + assert.equal(first.choices[0].delta.role, "assistant"); + assert.equal(first.choices[0].delta.content, "Olá"); + + const next = openaiResponsesToOpenAIResponse( + { type: "response.output_text.delta", delta: " mundo" }, + state + ); + assert.equal(next.choices[0].delta.role, undefined); + assert.equal(next.choices[0].delta.content, " mundo"); +});