Skip to content
Merged
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
3 changes: 2 additions & 1 deletion config/quality/file-size-baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
25 changes: 25 additions & 0 deletions open-sse/translator/response/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
70 changes: 70 additions & 0 deletions tests/unit/responses-chat-assistant-role-first-chunk.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

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<string, unknown> = {};

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