diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index a1c855a1ca..58354f2384 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -325,11 +325,32 @@ export function isComposerModel(model: string | undefined | null): boolean { return /^composer(?:-|$)/i.test(id ?? ""); } +// Composer's protobuf sometimes wraps the visible suffix in sentinel tags: +// `<|final|>` (full-width pipes) or `<|final|>` (ASCII), optionally closed +// with a matching `<|/final|>` / `<|/final|>`. These are protocol-internal +// and must never leak to OpenAI-compatible clients (decolua/9router#1316). +const COMPOSER_OPEN_MARKER = /^\s*<[||]\s*final\s*[||]>\s*/i; +const COMPOSER_CLOSE_MARKER = /\s*<[||]\s*\/\s*final\s*[||]>\s*$/i; +const COMPOSER_PARTIAL_OPEN = /^\s*<(?![||/])/; +const COMPOSER_PARTIAL_OPEN_PIPE = /^\s*<[||][^>]*$/; + 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(); + let visible = thinking.slice(endIdx + COMPOSER_THINK_END.length).trimStart(); + if (COMPOSER_OPEN_MARKER.test(visible)) { + visible = visible.replace(COMPOSER_OPEN_MARKER, ""); + } else if ( + COMPOSER_PARTIAL_OPEN.test(visible) || + COMPOSER_PARTIAL_OPEN_PIPE.test(visible) + ) { + // A streamed chunk delivered only a partial opening marker (e.g. `<` or + // `<|fin`). Hold back everything until more data arrives so the marker + // fragment never leaks as content. + return ""; + } + return visible.replace(COMPOSER_CLOSE_MARKER, "").trim(); } export function composerReasoningRemainder(thinking: string): string { diff --git a/tests/unit/cursor-composer-thinking.test.ts b/tests/unit/cursor-composer-thinking.test.ts index a269269f78..8e1c58503d 100644 --- a/tests/unit/cursor-composer-thinking.test.ts +++ b/tests/unit/cursor-composer-thinking.test.ts @@ -71,6 +71,41 @@ test("visibleComposerContentFromThinking returns suffix after last (tri assert.equal(visibleComposerContentFromThinking("ends with"), ""); }); +test("visibleComposerContentFromThinking strips `<|final|>` sentinel markers (full-width + ASCII)", () => { + // Full-width pipe sentinels (decolua/9router#1316). + assert.equal( + visibleComposerContentFromThinking("reasoning<|final|>OK_PR1316<|/final|>"), + "OK_PR1316" + ); + // ASCII pipe sentinels. + assert.equal( + visibleComposerContentFromThinking("reasoning<|final|>HELLO<|/final|>"), + "HELLO" + ); + // Open marker without a closing tag still gets stripped. + assert.equal( + visibleComposerContentFromThinking("r<|final|>just open"), + "just open" + ); + // No sentinel — plain suffix is unchanged. + assert.equal( + visibleComposerContentFromThinking("reasoningplain answer"), + "plain answer" + ); +}); + +test("visibleComposerContentFromThinking holds back a partial opening marker until complete", () => { + // A streamed chunk delivered only the start of the sentinel — emit nothing yet. + assert.equal(visibleComposerContentFromThinking("r<"), ""); + assert.equal(visibleComposerContentFromThinking("r<|fin"), ""); + assert.equal(visibleComposerContentFromThinking("r<|fin"), ""); + // Once the full marker + payload arrive the real content surfaces. + assert.equal( + visibleComposerContentFromThinking("r<|final|>NOW_VISIBLE"), + "NOW_VISIBLE" + ); +}); + test("composerReasoningRemainder returns only the hidden portion before last ", () => { assert.equal( composerReasoningRemainder("private reasoningOK"), @@ -135,6 +170,27 @@ test("Composer non-streaming aggregation: thinking with populates total assert.equal(ctx.totalText, "OK"); }); +test("Composer streaming: partial `<|final|>` sentinel split across chunks never leaks", () => { + const chunks: string[] = []; + const ctx: StreamCtx = newStreamCtx("cu/composer-2.5", (c) => chunks.push(c)); + // The sentinel arrives byte-fragmented across three thinking-delta frames. + processFrame(buildThinkingDeltaPayload("reasoning<|fina"), ctx, new Set()); + processFrame(buildThinkingDeltaPayload("l|>OK_S"), ctx, new Set()); + processFrame(buildThinkingDeltaPayload("TREAM"), ctx, new Set()); + + const events = parseSSE(chunks.join("")); + 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_STREAM"); + assert.equal(ctx.totalText, "OK_STREAM"); + assert.ok(!chunks.join("").includes("final"), "sentinel literal must not leak"); + assert.ok(!chunks.join("").includes("|"), "full-width pipe must not leak"); +}); + 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));