diff --git a/open-sse/executors/cursor.js b/open-sse/executors/cursor.js index 0c92b6ea..de64870c 100644 --- a/open-sse/executors/cursor.js +++ b/open-sse/executors/cursor.js @@ -41,6 +41,19 @@ const debugLog = (...args) => { if (CURSOR_STREAM_DEBUG) console.log(...args); }; +function isComposerModel(model) { + const modelId = String(model || "").split("/").pop(); + return /^composer(?:-|$)/i.test(modelId); +} + +function visibleComposerContentFromThinking(thinking) { + if (!thinking) return ""; + const endTag = ""; + const endIdx = thinking.lastIndexOf(endTag); + if (endIdx < 0) return ""; + return thinking.slice(endIdx + endTag.length).trimStart(); +} + function decompressPayload(payload, flags) { // Check if payload is JSON error (starts with {"error") if (payload.length > 10 && payload[0] === 0x7b && payload[1] === 0x22) { @@ -264,6 +277,7 @@ export class CursorExecutor extends BaseExecutor { let offset = 0; let totalContent = ""; + let totalThinking = ""; const toolCalls = []; const toolCallsMap = new Map(); // Track streaming tool calls by ID const finalizedIds = new Set(); @@ -373,8 +387,14 @@ export class CursorExecutor extends BaseExecutor { } if (result.text) totalContent += result.text; + if (result.thinking) totalThinking += result.thinking; } + const visibleComposerContent = isComposerModel(model) + ? visibleComposerContentFromThinking(totalThinking) + : ""; + const finalContent = totalContent || visibleComposerContent; + debugLog( `[CURSOR BUFFER] Parsed ${frameCount} frames, toolCallsMap size: ${toolCallsMap.size}, finalized toolCalls: ${toolCalls.length}` ); @@ -400,14 +420,14 @@ export class CursorExecutor extends BaseExecutor { const message = { role: "assistant", - content: totalContent || null + content: finalContent || null }; if (toolCalls.length > 0) { message.tool_calls = toolCalls; } - const usage = estimateUsage(body, totalContent.length, FORMATS.OPENAI); + const usage = estimateUsage(body, finalContent.length, FORMATS.OPENAI); const completion = { id: responseId, @@ -435,6 +455,8 @@ export class CursorExecutor extends BaseExecutor { const chunks = []; let offset = 0; let totalContent = ""; + let totalThinking = ""; + let emittedComposerThinkingContentLength = 0; const toolCalls = []; const toolCallsMap = new Map(); // Track streaming tool calls by ID const finalizedIds = new Set(); @@ -635,6 +657,34 @@ export class CursorExecutor extends BaseExecutor { })}\n\n` ); } + + if (isComposerModel(model) && result.thinking) { + totalThinking += result.thinking; + const visibleContent = visibleComposerContentFromThinking(totalThinking); + if (visibleContent.length > emittedComposerThinkingContentLength) { + const deltaContent = visibleContent.slice(emittedComposerThinkingContentLength); + emittedComposerThinkingContentLength = visibleContent.length; + totalContent += deltaContent; + chunks.push( + `data: ${JSON.stringify({ + id: responseId, + object: "chat.completion.chunk", + created, + model, + choices: [ + { + index: 0, + delta: + chunks.length === 0 && toolCalls.length === 0 + ? { role: "assistant", content: deltaContent } + : { content: deltaContent }, + finish_reason: null + } + ] + })}\n\n` + ); + } + } } debugLog( diff --git a/tests/unit/cursor-composer-thinking.test.js b/tests/unit/cursor-composer-thinking.test.js new file mode 100644 index 00000000..7033231d --- /dev/null +++ b/tests/unit/cursor-composer-thinking.test.js @@ -0,0 +1,86 @@ +import { describe, it, expect } from "vitest"; + +import { CursorExecutor } from "../../open-sse/executors/cursor.js"; +import { encodeField, wrapConnectRPCFrame } from "../../open-sse/utils/cursorProtobuf.js"; + +const LEN = 2; + +function cursorResponseFrame({ text = "", thinking = "" }) { + const responseFields = []; + + if (text) { + responseFields.push(encodeField(1, LEN, text)); + } + + if (thinking) { + const thinkingMessage = encodeField(1, LEN, thinking); + responseFields.push(encodeField(25, LEN, thinkingMessage)); + } + + const response = Buffer.concat(responseFields.map((field) => Buffer.from(field))); + const envelope = encodeField(2, LEN, response); + return Buffer.from(wrapConnectRPCFrame(envelope)); +} + +function parseSSE(text) { + return text + .split("\n\n") + .filter((chunk) => chunk.startsWith("data: ")) + .map((chunk) => chunk.slice("data: ".length)) + .filter((data) => data !== "[DONE]") + .map((data) => JSON.parse(data)); +} + +describe("CursorExecutor Composer thinking-field responses", () => { + it("uses visible content after for non-streaming Composer responses", async () => { + const executor = new CursorExecutor(); + const buffer = cursorResponseFrame({ + thinking: "private reasoning that must not leakOK", + }); + + const response = executor.transformProtobufToJSON(buffer, "cu/composer-2.5", { + messages: [{ role: "user", content: "reply OK" }], + }); + const payload = await response.json(); + + expect(payload.choices[0].message.content).toBe("OK"); + expect(JSON.stringify(payload)).not.toContain("private reasoning"); + expect(payload.usage.completion_tokens).toBeGreaterThan(0); + }); + + it("streams only visible content after for Composer responses", async () => { + const executor = new CursorExecutor(); + const buffer = Buffer.concat([ + cursorResponseFrame({ thinking: "private reasoning" }), + cursorResponseFrame({ thinking: " that must not leakO" }), + cursorResponseFrame({ thinking: "K" }), + ]); + + const response = executor.transformProtobufToSSE(buffer, "composer-2.5-fast", { + messages: [{ role: "user", content: "reply OK" }], + }); + const events = parseSSE(await response.text()); + const content = events + .map((event) => event.choices?.[0]?.delta?.content || "") + .join(""); + + expect(content).toBe("OK"); + expect(JSON.stringify(events)).not.toContain("private reasoning"); + expect(events.at(-1).usage.completion_tokens).toBeGreaterThan(0); + }); + + it("does not treat thinking as visible output for non-Composer models", async () => { + const executor = new CursorExecutor(); + const buffer = cursorResponseFrame({ + thinking: "private reasoningSHOULD_NOT_APPEAR", + }); + + const response = executor.transformProtobufToJSON(buffer, "gpt-5.3-codex", { + messages: [{ role: "user", content: "hi" }], + }); + const payload = await response.json(); + + expect(payload.choices[0].message.content).toBeNull(); + expect(JSON.stringify(payload)).not.toContain("SHOULD_NOT_APPEAR"); + }); +});