fix(stream): drop leaked chat bootstrap chunk for responses clients (#3035)

Integrated into release/v3.8.8. Drops the leaked empty chat.completion.chunk bootstrap frame before response.* SSE events so strict /v1/responses clients (OpenCode) don't fail on the first frame. Thanks @CitrusIce!
This commit is contained in:
CitrusIce
2026-06-01 18:56:25 +08:00
committed by GitHub
parent 8ef8a9b5a7
commit c34285f676
3 changed files with 112 additions and 0 deletions

View File

@@ -236,6 +236,14 @@
- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958)
### ✨ New Features
- **notion:** add Notion as an MCP context source — 6 tools (`notion_search`, `notion_list_databases`, `notion_get_database`, `notion_query_database`, `notion_read`, `notion_append_blocks`) scoped under `read:notion` / `write:notion`, with dashboard "Context Sources" tab, settings API, and token persistence in `key_value` table (#2959)
### 🔧 Bug Fixes
- **mcp:** move `enforceScopes` guard before `MCP_TOOL_MAP` lookup, add inline `scopes` parameter to `withScopeEnforcement()`, and declare scopes on all 24 dynamic tool definitions (memory, skills, plugins, gamification, compression) to fix scope enforcement for dynamic MCP tool groups (#2958)
---
## [3.8.7] — 2026-05-29

View File

@@ -1255,6 +1255,45 @@ export function createSSEStream(options: StreamOptions = {}) {
try {
let parsed = JSON.parse(trimmed.slice(5).trim());
// Some upstream Responses-compatible providers leak an initial Chat Completions
// bootstrap chunk (assistant role + empty content) before emitting proper
// `response.*` events. That chunk is invalid on /v1/responses and breaks strict
// clients like OpenCode, so drop it only for Responses-native consumers.
const hasActiveDeltaValue = (value: unknown): boolean => {
if (typeof value === "string") return value.length > 0;
if (Array.isArray(value)) return value.some((entry) => hasActiveDeltaValue(entry));
if (value && typeof value === "object") {
return Object.values(value).some((entry) => hasActiveDeltaValue(entry));
}
return value !== null && value !== undefined;
};
const isEmptyAssistantBootstrapChunkForResponsesClient =
clientExpectsResponsesStream &&
parsed?.object === "chat.completion.chunk" &&
Array.isArray(parsed?.choices) &&
parsed.choices.length > 0 &&
parsed.choices.every((choice) => {
const candidate = choice && typeof choice === "object" ? choice : {};
const delta =
candidate.delta && typeof candidate.delta === "object"
? candidate.delta
: null;
if (!delta || delta.role !== "assistant") return false;
if (hasActiveDeltaValue(delta.content)) return false;
if (candidate.finish_reason !== null && candidate.finish_reason !== undefined) {
return false;
}
const { role: _role, content: _content, ...restDelta } = delta;
return !hasActiveDeltaValue(restDelta);
});
if (isEmptyAssistantBootstrapChunkForResponsesClient) {
continue;
}
// Detect Responses SSE payloads (have a `type` field like "response.created",
// "response.output_item.added", etc.) and skip Chat Completions-specific
// sanitization to avoid corrupting the stream for Responses-native clients.

View File

@@ -652,6 +652,71 @@ test("createSSEStream passthrough preserves Responses API events and completion
assert.equal(onCompletePayload.providerPayload.summary.object, "response");
});
test("createSSEStream passthrough drops leaked empty chat bootstrap chunks for Responses clients", async () => {
const text = await readTransformed(
[
`data: ${JSON.stringify({
id: "chatcmpl-dummy",
object: "chat.completion.chunk",
created: 1,
model: "gpt-5.4",
choices: [
{ index: 0, delta: { role: "assistant", content: null, refusal: null }, finish_reason: null },
],
})}\n\n`,
`event: response.created\ndata: ${JSON.stringify({
type: "response.created",
response: {
id: "resp_1",
object: "response",
model: "gpt-5.4",
status: "in_progress",
output: [],
},
})}\n\n`,
`event: response.in_progress\ndata: ${JSON.stringify({
type: "response.in_progress",
response: {
id: "resp_1",
object: "response",
model: "gpt-5.4",
status: "in_progress",
output: [],
},
})}\n\n`,
`data: ${JSON.stringify({
type: "response.output_text.delta",
delta: "OK",
})}\n\n`,
`data: ${JSON.stringify({
type: "response.completed",
response: {
id: "resp_1",
object: "response",
model: "gpt-5.4",
status: "completed",
output: [],
usage: { input_tokens: 2, output_tokens: 1, total_tokens: 3 },
},
})}\n\n`,
`data: [DONE]\n\n`,
],
{
mode: "passthrough",
sourceFormat: FORMATS.OPENAI_RESPONSES,
clientResponseFormat: FORMATS.OPENAI_RESPONSES,
provider: "openai",
model: "gpt-5.4",
body: { input: "hello" },
}
);
assert.doesNotMatch(text, /chatcmpl-dummy/);
assert.match(text, /response\.created/);
assert.match(text, /response\.output_text\.delta/);
assert.match(text, /"delta":"OK"/);
});
test("buildStreamSummaryFromEvents falls back to response.output_text.delta when completed output is empty", () => {
const summary = buildStreamSummaryFromEvents(
[