Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion open-sse/executors/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/cursor-composer-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,41 @@ test("visibleComposerContentFromThinking returns suffix after last </think> (tri
assert.equal(visibleComposerContentFromThinking("ends with</think>"), "");
});

test("visibleComposerContentFromThinking strips `<|final|>` sentinel markers (full-width + ASCII)", () => {
// Full-width pipe sentinels (decolua/9router#1316).
assert.equal(
visibleComposerContentFromThinking("reasoning</think><|final|>OK_PR1316<|/final|>"),
"OK_PR1316"
);
// ASCII pipe sentinels.
assert.equal(
visibleComposerContentFromThinking("reasoning</think><|final|>HELLO<|/final|>"),
"HELLO"
);
// Open marker without a closing tag still gets stripped.
assert.equal(
visibleComposerContentFromThinking("r</think><|final|>just open"),
"just open"
);
// No sentinel — plain suffix is unchanged.
assert.equal(
visibleComposerContentFromThinking("reasoning</think>plain 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</think><"), "");
assert.equal(visibleComposerContentFromThinking("r</think><|fin"), "");
assert.equal(visibleComposerContentFromThinking("r</think><|fin"), "");
// Once the full marker + payload arrive the real content surfaces.
assert.equal(
visibleComposerContentFromThinking("r</think><|final|>NOW_VISIBLE"),
"NOW_VISIBLE"
);
});

test("composerReasoningRemainder returns only the hidden portion before last </think>", () => {
assert.equal(
composerReasoningRemainder("private reasoning</think>OK"),
Expand Down Expand Up @@ -135,6 +170,27 @@ test("Composer non-streaming aggregation: thinking with </think> 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</think><|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));
Expand Down
Loading