From 3ffd6ffff1d3887ee3ac22c6cdf692e8f8c61527 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sun, 21 Jun 2026 19:07:34 -0300 Subject: [PATCH] fix(executors): decode Composer Cursor -marked visible output (#4554) Integrated into release/v3.8.33 --- config/quality/file-size-baseline.json | 2 +- open-sse/executors/cursor.ts | 66 +++++++- tests/unit/cursor-composer-thinking.test.ts | 158 ++++++++++++++++++++ 3 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 tests/unit/cursor-composer-thinking.test.ts diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index 9da7070a14..e9f9f4a82f 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -110,7 +110,7 @@ "open-sse/executors/chatgpt-web.ts": 2870, "open-sse/executors/claude-web.ts": 1057, "open-sse/executors/codex.ts": 1449, - "open-sse/executors/cursor.ts": 1391, + "open-sse/executors/cursor.ts": 1453, "open-sse/executors/deepseek-web.ts": 1117, "open-sse/executors/duckduckgo-web.ts": 925, "open-sse/executors/grok-web.ts": 1871, diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 0622ec7a76..a1c855a1ca 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -305,6 +305,40 @@ function tryParseJsonError(payload: Buffer): { message: string; status: number } } } +// ─── Composer thinking-as-content decoding ───────────────────────────────── +// +// The Cursor `composer-*` family encodes its visible reply inside the +// `thinking` field, marked off from the (private) chain-of-thought by a +// final `` sentinel. Everything AFTER the last `` is the +// user-facing reply; the prefix must stay hidden. +// +// Ported from decolua/9router#1310 by Noé Rivera. Same algorithm, adapted +// to OmniRoute's StreamCtx-based pipeline so streaming + non-streaming +// share the accumulation path. + +const COMPOSER_THINK_END = ""; + +export function isComposerModel(model: string | undefined | null): boolean { + const id = String(model ?? "") + .split("/") + .pop(); + return /^composer(?:-|$)/i.test(id ?? ""); +} + +export function visibleComposerContentFromThinking(thinking: string): string { + if (!thinking) return ""; + const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); + if (endIdx < 0) return ""; + return thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart(); +} + +export function composerReasoningRemainder(thinking: string): string { + if (!thinking) return ""; + const endIdx = thinking.lastIndexOf(COMPOSER_THINK_END); + if (endIdx < 0) return thinking; + return thinking.slice(0, endIdx); +} + // ─── Phase 4: streaming dispatch context ─────────────────────────────────── // // One StreamCtx flows through a single execute() call. It owns the live @@ -344,6 +378,10 @@ export type StreamCtx = { // role:"tool" message can be answered on the open h2 stream via // encodeExecMcpResult. pendingToolCalls: Map; + // Composer thinking-as-content (decolua/9router#1310): tracks how much of + // the visible suffix (after the last ``) has already been streamed + // out as `content` deltas, so we only emit the incremental tail per frame. + composerVisibleEmittedLength: number; }; export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx { @@ -363,6 +401,7 @@ export function newStreamCtx(model: string, emit: (chunk: string) => void): Stre emittedToolCallIndex: 0, toolCalls: [], pendingToolCalls: new Map(), + composerVisibleEmittedLength: 0, }; } @@ -577,7 +616,22 @@ export function processFrame( } ctx.thinkingText += d.text; ctx.receivedText = true; - emitChunk(ctx, { reasoning_content: d.text }); + // Composer (decolua/9router#1310) encodes the visible reply inside the + // thinking field, after a final `` marker. Emit the post-marker + // suffix as plain `content` (so OpenAI-compatible clients see the reply) + // and keep the pre-marker chain-of-thought out of `reasoning_content` — + // it was never intended for the user. + if (isComposerModel(ctx.model)) { + const visible = visibleComposerContentFromThinking(ctx.thinkingText); + if (visible.length > ctx.composerVisibleEmittedLength) { + const deltaContent = visible.slice(ctx.composerVisibleEmittedLength); + ctx.composerVisibleEmittedLength = visible.length; + ctx.totalText += deltaContent; + emitChunk(ctx, { content: deltaContent }); + } + } else { + emitChunk(ctx, { reasoning_content: d.text }); + } } else if (d.kind === "token_delta") { ctx.tokenDelta += d.tokens; } else if (d.kind === "turn_ended") { @@ -1354,7 +1408,15 @@ export class CursorExecutor extends BaseExecutor { content: ctx.totalText.length > 0 ? ctx.totalText : null, }; if (ctx.thinkingText.length > 0) { - message.reasoning_content = ctx.thinkingText; + // Composer: strip the visible reply (after ``) from the reasoning + // payload so it is not duplicated — it already lives in message.content + // via the processFrame thinking handler. + const reasoningPayload = isComposerModel(ctx.model) + ? composerReasoningRemainder(ctx.thinkingText) + : ctx.thinkingText; + if (reasoningPayload.length > 0) { + message.reasoning_content = reasoningPayload; + } } if (ctx.toolCalls.length > 0) { message.tool_calls = ctx.toolCalls.map((tc) => ({ diff --git a/tests/unit/cursor-composer-thinking.test.ts b/tests/unit/cursor-composer-thinking.test.ts new file mode 100644 index 0000000000..a269269f78 --- /dev/null +++ b/tests/unit/cursor-composer-thinking.test.ts @@ -0,0 +1,158 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + newStreamCtx, + processFrame, + isComposerModel, + visibleComposerContentFromThinking, + composerReasoningRemainder, + type StreamCtx, +} from "../../open-sse/executors/cursor"; + +// ─── Wire-format helpers (mirror cursor-streaming.test.ts) ──────────────────── + +function v(n: number): Buffer { + const out: number[] = []; + while (n > 0x7f) { + out.push((n & 0x7f) | 0x80); + n >>>= 7; + } + out.push(n); + return Buffer.from(out); +} +function tag(field: number, wireType: number): Buffer { + return v((field << 3) | wireType); +} +function lenPrefixed(field: number, payload: Buffer): Buffer { + return Buffer.concat([tag(field, 2), v(payload.length), payload]); +} + +// AgentServerMessage { interaction_update (1): { thinking_delta (4): { text (1): str } } } +function buildThinkingDeltaPayload(text: string): Buffer { + const tdu = lenPrefixed(1, Buffer.from(text, "utf8")); + const iu = lenPrefixed(4, tdu); + return lenPrefixed(1, iu); +} + +function parseSSE(text: string): Array> { + return text + .split("\n\n") + .filter((c) => c.startsWith("data: ")) + .map((c) => c.slice("data: ".length)) + .filter((d) => d !== "[DONE]") + .map((d) => JSON.parse(d)); +} + +// ─── Pure helpers ──────────────────────────────────────────────────────────── + +test("isComposerModel matches composer + composer-* (case-insensitive, vendor prefix tolerated)", () => { + assert.equal(isComposerModel("composer"), true); + assert.equal(isComposerModel("composer-2.5"), true); + assert.equal(isComposerModel("composer-2.5-fast"), true); + assert.equal(isComposerModel("cu/composer-2.5"), true); + assert.equal(isComposerModel("CURSOR/Composer-2.5"), true); + assert.equal(isComposerModel("gpt-5.3-codex"), false); + assert.equal(isComposerModel("claude-4-sonnet"), false); + assert.equal(isComposerModel("composer2"), false); + assert.equal(isComposerModel(""), false); +}); + +test("visibleComposerContentFromThinking returns suffix after last (trim-start)", () => { + assert.equal( + visibleComposerContentFromThinking("private reasoningOK"), + "OK" + ); + assert.equal( + visibleComposerContentFromThinking("ab final"), + "final" + ); + assert.equal(visibleComposerContentFromThinking("no marker yet"), ""); + assert.equal(visibleComposerContentFromThinking(""), ""); + assert.equal(visibleComposerContentFromThinking("ends with"), ""); +}); + +test("composerReasoningRemainder returns only the hidden portion before last ", () => { + assert.equal( + composerReasoningRemainder("private reasoningOK"), + "private reasoning" + ); + assert.equal( + composerReasoningRemainder("just hidden, no marker"), + "just hidden, no marker" + ); + assert.equal(composerReasoningRemainder(""), ""); +}); + +// ─── Composer thinking handling via processFrame ───────────────────────────── + +test("Composer streaming: emits visible suffix after as content deltas; hidden never leaks as content", () => { + const chunks: string[] = []; + const ctx: StreamCtx = newStreamCtx("composer-2.5-fast", (c) => chunks.push(c)); + processFrame(buildThinkingDeltaPayload("private reasoning"), ctx, new Set()); + processFrame( + buildThinkingDeltaPayload(" that must not leakO"), + ctx, + new Set() + ); + processFrame(buildThinkingDeltaPayload("K"), ctx, new Set()); + + const sseText = chunks.join(""); + const events = parseSSE(sseText); + const content = events + .map((e) => { + const choices = (e as { choices?: Array<{ delta?: { content?: string } }> }).choices; + return choices?.[0]?.delta?.content ?? ""; + }) + .join(""); + assert.equal(content, "OK"); + // Aggregated ctx.totalText must mirror the visible content so the + // non-streaming aggregator surfaces it as message.content unchanged. + assert.equal(ctx.totalText, "OK"); + // Composer must NOT emit reasoning_content for the visible suffix portion + // — the hidden reasoning may still appear as reasoning_content deltas, but + // the literal post- text must never appear as reasoning_content. + const reasoningStream = events + .map((e) => { + const choices = (e as { choices?: Array<{ delta?: { reasoning_content?: string } }> }) + .choices; + return choices?.[0]?.delta?.reasoning_content ?? ""; + }) + .join(""); + assert.ok( + !reasoningStream.includes("OK"), + "visible suffix must not be duplicated into reasoning_content" + ); +}); + +test("Composer non-streaming aggregation: thinking with populates totalText with visible suffix", () => { + const chunks: string[] = []; + const ctx: StreamCtx = newStreamCtx("cu/composer-2.5", (c) => chunks.push(c)); + processFrame( + buildThinkingDeltaPayload("private reasoning that must not leakOK"), + ctx, + new Set() + ); + assert.equal(ctx.totalText, "OK"); +}); + +test("Non-Composer model: thinking field stays in reasoning_content (unchanged contract)", () => { + const chunks: string[] = []; + const ctx: StreamCtx = newStreamCtx("gpt-5.3-codex", (c) => chunks.push(c)); + processFrame( + buildThinkingDeltaPayload("hiddenSHOULD_NOT_APPEAR"), + ctx, + new Set() + ); + + const sseText = chunks.join(""); + assert.ok(sseText.includes("reasoning_content"), "reasoning_content delta missing"); + const events = parseSSE(sseText); + const content = events + .map((e) => { + const choices = (e as { choices?: Array<{ delta?: { content?: string } }> }).choices; + return choices?.[0]?.delta?.content ?? ""; + }) + .join(""); + assert.equal(content, "", "non-Composer must not surface thinking as content"); + assert.equal(ctx.totalText, "", "non-Composer must not populate totalText from thinking"); +});