diff --git a/CHANGELOG.md b/CHANGELOG.md index 3aa34f9e45..0819f30939 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/open-sse/utils/stream.ts b/open-sse/utils/stream.ts index 589bf786e7..3992e5f655 100644 --- a/open-sse/utils/stream.ts +++ b/open-sse/utils/stream.ts @@ -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. diff --git a/tests/unit/stream-utils.test.ts b/tests/unit/stream-utils.test.ts index 23b34d8ead..7a2bd35034 100644 --- a/tests/unit/stream-utils.test.ts +++ b/tests/unit/stream-utils.test.ts @@ -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( [