mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-20 06:02:14 +03:00
fix(executors): decode Composer Cursor </think>-marked visible output (#4554)
Integrated into release/v3.8.33
This commit is contained in:
committed by
GitHub
parent
9746b206db
commit
3ffd6ffff1
@@ -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,
|
||||
|
||||
@@ -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 `</think>` sentinel. Everything AFTER the last `</think>` 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 = "</think>";
|
||||
|
||||
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<string, { execMsgId: number; execId: string; toolName: string }>;
|
||||
// Composer thinking-as-content (decolua/9router#1310): tracks how much of
|
||||
// the visible suffix (after the last `</think>`) 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 `</think>` 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 `</think>`) 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) => ({
|
||||
|
||||
158
tests/unit/cursor-composer-thinking.test.ts
Normal file
158
tests/unit/cursor-composer-thinking.test.ts
Normal file
@@ -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<Record<string, unknown>> {
|
||||
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 </think> (trim-start)", () => {
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("private reasoning</think>OK"),
|
||||
"OK"
|
||||
);
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("a</think>b</think> final"),
|
||||
"final"
|
||||
);
|
||||
assert.equal(visibleComposerContentFromThinking("no marker yet"), "");
|
||||
assert.equal(visibleComposerContentFromThinking(""), "");
|
||||
assert.equal(visibleComposerContentFromThinking("ends with</think>"), "");
|
||||
});
|
||||
|
||||
test("composerReasoningRemainder returns only the hidden portion before last </think>", () => {
|
||||
assert.equal(
|
||||
composerReasoningRemainder("private reasoning</think>OK"),
|
||||
"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 </think> 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 leak</think>O"),
|
||||
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-</think> 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 </think> 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 leak</think>OK"),
|
||||
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("hidden</think>SHOULD_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");
|
||||
});
|
||||
Reference in New Issue
Block a user