diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index fd90dc8e8f..c1c261ae88 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -776,7 +776,25 @@ export function createSSEStream(options: StreamOptions = {}) { // #9315: compute the summary live from every pushed chunk (not just the // ones that survive the storage cap below) so a long stream never shows a // stale/incomplete "provider response" in the dashboard. - format: sourceFormat, + // + // Real bug: this was unconditionally `sourceFormat` (the CLIENT's wire + // format — see this function's own @param doc above). In TRANSLATE mode + // the chunks pushed here are the RAW PROVIDER response, whose format is + // `targetFormat` (@param "Provider format (for translate mode)"), not + // sourceFormat. Whenever a client's format differs from the provider's + // (e.g. a Responses-API client routed to a plain-OpenAI-chat-completions + // upstream — the OpenClaw/opencode-zen case that surfaced this live), the + // reducer picked for `sourceFormat` could never recognize the provider's + // actual event shape, so it never left its empty initial state — the + // dashboard's "Provider Response" panel permanently showed + // `output: []`/empty while "Client Response" (built from + // separately-accumulated state, unaffected by this) correctly showed full + // content, reading as if the two panels simply disagreed. PASSTHROUGH + // mode has no separate provider/client format split — nothing gets + // translated, so the provider's raw chunks genuinely ARE in sourceFormat + // (and real passthrough callers, e.g. createPassthroughStreamWithLogger, + // don't even pass targetFormat) — keep using sourceFormat there. + format: mode === STREAM_MODE.TRANSLATE ? targetFormat : sourceFormat, fallbackModel: model, }); const clientPayloadCollector = createStructuredSSECollector({ diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 7913af1c04..86809fe1be 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -1098,6 +1098,63 @@ test("createSSEStream passthrough preserves Responses API events and completion assert.equal(onCompletePayload.providerPayload.summary.object, "response"); }); +// Real bug found live (dashboard log id 1786032832181-1c6275, #9315 follow-up): +// providerPayloadCollector was keyed on `sourceFormat` (the CLIENT's format) +// instead of `targetFormat` (the PROVIDER's format — see createSSEStream's own +// @param doc). A Responses-API client routed to a plain-OpenAI-chat-completions +// upstream (exactly this OpenClaw/opencode-zen combo) fed the provider's real +// chat.completion.chunk deltas into the Responses-API reducer, which never +// recognizes them — so the dashboard's "Provider Response" panel stayed stuck +// empty (`output: []`) forever while "Client Response" correctly showed full +// content, reading as if the two panels disagreed about the same request. +test("createSSEStream translate mode: providerPayload summary reflects the PROVIDER's format, not the client's", async () => { + let onCompletePayload = null; + await readTransformed( + [ + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 1, + model: "big-pickle", + choices: [ + { index: 0, delta: { role: "assistant", content: "Hello " }, finish_reason: null }, + ], + })}\n\n`, + `data: ${JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion.chunk", + created: 1, + model: "big-pickle", + choices: [{ index: 0, delta: { content: "world" }, finish_reason: "stop" }], + usage: { prompt_tokens: 5, completion_tokens: 2, total_tokens: 7 }, + })}\n\n`, + `data: [DONE]\n\n`, + ], + { + mode: "translate", + // Client speaks Responses API; the upstream provider (opencode-zen-style) + // speaks plain OpenAI chat-completions — exactly the OpenClaw combo that + // surfaced this live. + sourceFormat: FORMATS.OPENAI_RESPONSES, + targetFormat: FORMATS.OPENAI, + provider: "opencode-zen", + model: "big-pickle", + body: { input: "hi" }, + onComplete(payload) { + onCompletePayload = payload; + }, + } + ); + + const summary = onCompletePayload.providerPayload.summary; + assert.ok(summary, "providerPayload.summary must not be null/undefined"); + // The bug's exact symptom: a Responses-API reducer fed chat-completion chunks + // never recognizes them, so it stays at "no output" — assert the OPPOSITE. + assert.equal(summary.object, "chat.completion"); + assert.equal(summary.choices?.[0]?.message?.content, "Hello world"); + assert.equal(summary.choices?.[0]?.finish_reason, "stop"); +}); + test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for Responses clients", async () => { const text = await readTransformed( [