From 2ed7d532f96be4145d907abfb10e5ddc31382374 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Fri, 26 Jun 2026 10:57:19 -0300 Subject: [PATCH] feat(cursor): parse Composer DeepSeek-style inline tool calls (#4912) Integrated into release/v3.8.38 (leva 5) --- CHANGELOG.md | 1 + open-sse/executors/cursor.ts | 111 ++++++++- open-sse/utils/composerToolCalls.ts | 307 +++++++++++++++++++++++++ tests/unit/composer-tool-calls.test.ts | 216 +++++++++++++++++ 4 files changed, 631 insertions(+), 4 deletions(-) create mode 100644 open-sse/utils/composerToolCalls.ts create mode 100644 tests/unit/composer-tool-calls.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3be0e6c510..d7ece888b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ _In development — bullets added per PR; finalized at release._ - **feat(blackbox):** refresh provider model catalog with latest models. (thanks @ptkelanatechsolutions) - **kiro**: inline `` stream splitter — when `enabled` is present, `assistantResponseEvent` content is now split into separate `delta.content` / `delta.reasoning_content` SSE chunks (new `open-sse/executors/kiroThinking.ts` module wired into `KiroExecutor.transformEventStreamToSSE`). +- **feat(cursor):** parse Cursor Composer DeepSeek-style inline tool calls — Composer `cu/composer-2.5*` models embed tool invocations in their visible text using `<|tool▁calls▁begin|>…<|tool▁calls▁end|>` markers instead of structured protobuf frames; a new streaming parser (`composerToolCalls.ts`) intercepts these in both streaming and non-streaming paths, suppresses the markers from the client-visible content, and emits proper OpenAI `tool_calls` deltas so downstream clients handle them natively. (thanks @noestelar) ### 🔧 Bug Fixes diff --git a/open-sse/executors/cursor.ts b/open-sse/executors/cursor.ts index 58354f2384..62cf10f348 100644 --- a/open-sse/executors/cursor.ts +++ b/open-sse/executors/cursor.ts @@ -51,6 +51,12 @@ import { import { getCursorVersion } from "../utils/cursorVersionDetector.ts"; import { sanitizeErrorMessage } from "../utils/error.ts"; import { generateToolCallId } from "../translator/helpers/toolCallHelper.ts"; +import { + parseComposerToolCalls, + createStreamingState, + feedStreamingChunk, + type StreamingState as ComposerStreamingState, +} from "../utils/composerToolCalls.ts"; import { cursorSessionManager, type CursorSession } from "../services/cursorSessionManager.ts"; import crypto from "crypto"; import * as fs from "node:fs"; @@ -403,6 +409,14 @@ export type StreamCtx = { // 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; + // Composer DeepSeek-format inline tool-call parser state (decolua/9router#1335). + // Null for non-Composer models (no overhead). When set, the streaming parser + // holds back text inside `<|tool▁calls▁begin|>...<|tool▁calls▁end|>` markers + // and emits structured tool_calls SSE chunks once the block closes. + composerToolParserState: ComposerStreamingState | null; + // True once we've emitted structured tool_calls from the inline Composer parser + // (to avoid double-emitting if the block appears in multiple accumulated frames). + composerInlineToolCallsEmitted: boolean; }; export function newStreamCtx(model: string, emit: (chunk: string) => void): StreamCtx { @@ -423,6 +437,8 @@ export function newStreamCtx(model: string, emit: (chunk: string) => void): Stre toolCalls: [], pendingToolCalls: new Map(), composerVisibleEmittedLength: 0, + composerToolParserState: isComposerModel(model) ? createStreamingState() : null, + composerInlineToolCallsEmitted: false, }; } @@ -645,10 +661,45 @@ export function processFrame( 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 }); + // Feed the full accumulated visible text into the DeepSeek inline + // tool-call streaming parser (decolua/9router#1335). It tracks how + // much has already been safely emitted and returns only the new + // safe delta — i.e. text that precedes any `<|tool▁calls▁begin|>` + // marker (or a partial prefix of one). When the closing marker + // arrives, it sets ready=true and provides the parsed tool_calls. + if (ctx.composerToolParserState) { + const parseOut = feedStreamingChunk(ctx.composerToolParserState, visible); + // composerVisibleEmittedLength tracks what the parser has "emitted" + // — stays in sync via state.emitted. + ctx.composerVisibleEmittedLength = ctx.composerToolParserState.emitted; + if (parseOut.safeDelta) { + ctx.totalText += parseOut.safeDelta; + emitChunk(ctx, { content: parseOut.safeDelta }); + } + if (parseOut.ready && parseOut.toolCalls.length > 0 && !ctx.composerInlineToolCallsEmitted) { + ctx.composerInlineToolCallsEmitted = true; + for (const tc of parseOut.toolCalls) { + const toolCallIndex = ctx.emittedToolCallIndex++; + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + emitChunk(ctx, { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: tc.function.arguments }, + }, + ], + }); + } + } + } else { + // Non-composer or state not initialised — fall back to direct emit. + const deltaContent = visible.slice(ctx.composerVisibleEmittedLength); + ctx.composerVisibleEmittedLength = visible.length; + ctx.totalText += deltaContent; + emitChunk(ctx, { content: deltaContent }); + } } } else { emitChunk(ctx, { reasoning_content: d.text }); @@ -1378,6 +1429,39 @@ export class CursorExecutor extends BaseExecutor { // one delta before finish. emitChunk(ctx, { role: "assistant", content: "" }); } + + // End-of-stream Composer inline tool-call fallback (decolua/9router#1335): + // if the entire response arrived as a single big chunk (or the streaming + // parser state never reached "ready"), try a full non-streaming parse on + // the accumulated visible content so we still emit structured tool_calls + // and don't leak the markers as plain text. + if ( + isComposerModel(ctx.model) && + !ctx.composerInlineToolCallsEmitted && + ctx.totalText + ) { + const parsed = parseComposerToolCalls(ctx.totalText); + if (parsed.toolCalls.length > 0) { + ctx.composerInlineToolCallsEmitted = true; + // Replace totalText with the residual (markers stripped). + ctx.totalText = parsed.content; + for (const tc of parsed.toolCalls) { + const toolCallIndex = ctx.emittedToolCallIndex++; + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + emitChunk(ctx, { + tool_calls: [ + { + index: toolCallIndex, + id: tc.id, + type: "function", + function: { name: tc.function.name, arguments: tc.function.arguments }, + }, + ], + }); + } + } + } + // OpenAI finish_reason: "tool_calls" if the model invoked any declared // tool, else "stop". A turn with mixed text + tool_calls finishes with // "tool_calls" (the tool calls are the actionable signal for the client). @@ -1413,6 +1497,25 @@ export class CursorExecutor extends BaseExecutor { // Non-streaming: chat.completion shape. Include tool_calls in the // assistant message when the model invoked any (Phase 5). + + // Composer DeepSeek inline tool-call fallback (decolua/9router#1335): for + // non-streaming requests, the streaming parser never runs — parse the + // accumulated visible content once here instead. + if ( + isComposerModel(ctx.model) && + !ctx.composerInlineToolCallsEmitted && + ctx.totalText + ) { + const parsed = parseComposerToolCalls(ctx.totalText); + if (parsed.toolCalls.length > 0) { + ctx.composerInlineToolCallsEmitted = true; + ctx.totalText = parsed.content; + for (const tc of parsed.toolCalls) { + ctx.toolCalls.push({ id: tc.id, name: tc.function.name, argumentsJson: tc.function.arguments }); + } + } + } + const usage = buildCursorUsage(ctx, body); const finishReason = ctx.toolCalls.length > 0 ? "tool_calls" : "stop"; const message: { diff --git a/open-sse/utils/composerToolCalls.ts b/open-sse/utils/composerToolCalls.ts new file mode 100644 index 0000000000..d688973ce3 --- /dev/null +++ b/open-sse/utils/composerToolCalls.ts @@ -0,0 +1,307 @@ +/** + * Parser for Cursor Composer's DeepSeek-style inline tool call format. + * + * Composer (Cursor's `cu/composer-2.5*` models) emits tool calls inside its + * normal text output using sentinel markers, e.g.: + * + * Optional preamble text... + * <|tool▁calls▁begin|> + * <|tool▁call▁begin|> + * tool_name + * <|tool▁sep|>arg_name + * arg_value + * <|tool▁sep|>arg_name_2 + * arg_value_2 + * <|tool▁call▁end|> + * <|tool▁call▁begin|> + * other_tool + * <|tool▁sep|>arg + * value + * <|tool▁call▁end|> + * <|tool▁calls▁end|> + * Optional trailing text... + * + * Markers use full-width pipes (`|`, U+FF5C) and small-triangle separator + * (`▁`, U+2581). We also accept ASCII fallbacks (`<|tool_calls_begin|>` etc.) + * defensively in case Cursor ever changes encoding. + * + * The parser converts this into the OpenAI Chat Completions `tool_calls` + * shape and returns the residual content (preamble + trailing text) so the + * caller can decide whether to surface it as the assistant's visible message. + */ + +const FW = "[||]"; // full-width or ASCII pipe +const SEP = "[▁_]"; // full-width separator or ASCII underscore + +// Match the outer tool-calls block, lazily. +const OUTER_RE = new RegExp( + `<${FW}tool${SEP}calls${SEP}begin${FW}>([\\s\\S]*?)<${FW}tool${SEP}calls${SEP}end${FW}>`, + "i" +); + +// Match a single tool-call block. +const INNER_RE = new RegExp( + `<${FW}tool${SEP}call${SEP}begin${FW}>([\\s\\S]*?)<${FW}tool${SEP}call${SEP}end${FW}>`, + "gi" +); + +// Match an arg separator. +const ARG_SEP_RE = new RegExp(`<${FW}tool${SEP}sep${FW}>`, "gi"); + +// Heuristic: any partial opening marker (start of `<|tool` ... without the +// final `>`). Used by the streaming parser to know it must hold back text. +const PARTIAL_OPEN_MARKER_RE = new RegExp( + `<${FW}?(?:t(?:o(?:o(?:l(?:${SEP}(?:c(?:a(?:l(?:l(?:s)?(?:${SEP}(?:b(?:e(?:g(?:i(?:n${FW}?>?)?)?)?)?)?)?)?)?)?)?)?)?)?)?)?$`, + "i" +); + +export interface ComposerToolCall { + id: string; + type: "function"; + function: { + name: string; + arguments: string; + }; +} + +export interface ParseComposerResult { + content: string; + toolCalls: ComposerToolCall[]; +} + +export interface StreamingState { + emitted: number; + done: boolean; +} + +export interface FeedChunkResult { + safeDelta: string; + ready: boolean; + toolCalls: ComposerToolCall[]; + holdback: boolean; +} + +// Detection helper +export function hasComposerToolCalls(text: string): boolean { + if (!text || typeof text !== "string") return false; + return OUTER_RE.test(text); +} + +function generateToolCallId(index: number): string { + // Format: call_; mirrors what OpenAI clients expect. + // Use crypto for deterministic-quality randomness (Hard Rule: no Math.random for IDs). + const rand = crypto.randomUUID().replace(/-/g, "").slice(0, 10); + return `call_${rand}${index}`; +} + +/** + * Parse a single inner tool-call block body (the text between + * `<|tool▁call▁begin|>` and `<|tool▁call▁end|>`). + * + * Body shape: + * tool_name + * <|tool▁sep|>arg_name + * arg_value + * <|tool▁sep|>arg_name_2 + * arg_value_2 + * + * Returns {name, arguments} where arguments is a JSON string suitable for + * the OpenAI tool_calls schema. Arg values are taken verbatim from the + * text between one separator's argname-line and the next separator (or + * end of body). We attempt to detect when a value is already valid JSON + * (object/array/number/bool/null) and store it natively; otherwise we keep + * it as a string. + */ +function parseInnerCall(body: string): { name: string; arguments: string } | null { + // Body starts with the tool name on (typically) its own line, optionally + // surrounded by whitespace, then the first `<|tool▁sep|>`. + const trimmed = body.replace(/^\s+|\s+$/g, ""); + // Split by argument separator first to isolate name + arg blocks. + const segments = trimmed.split(ARG_SEP_RE); + // First segment is the tool name (and any preamble whitespace). + const name = (segments.shift() ?? "").trim(); + if (!name) { + return null; + } + const args: Record = {}; + for (const seg of segments) { + if (!seg) continue; + // Each segment is `arg_name\nvalue\n...`. The arg name is the first + // line; everything after the first newline is the value (verbatim, + // including additional newlines). + const idxNl = seg.indexOf("\n"); + let argName: string; + let argValue: string; + if (idxNl < 0) { + argName = seg.trim(); + argValue = ""; + } else { + argName = seg.slice(0, idxNl).trim(); + argValue = seg.slice(idxNl + 1); + } + if (!argName) continue; + // Strip the trailing newline before the next separator (the separator + // marker itself was already consumed by the split). + argValue = argValue.replace(/\n+$/, ""); + // Attempt JSON parse so structured args (objects/arrays/numbers/bools) + // come through as native JSON values rather than quoted strings. + args[argName] = coerceArgValue(argValue); + } + return { name, arguments: JSON.stringify(args) }; +} + +function coerceArgValue(raw: string): unknown { + if (raw === "") return ""; + const stripped = raw.trim(); + if ( + (stripped.startsWith("{") && stripped.endsWith("}")) || + (stripped.startsWith("[") && stripped.endsWith("]")) + ) { + try { + return JSON.parse(stripped); + } catch { + // not valid JSON — fall through to string + } + } + if (stripped === "true") return true; + if (stripped === "false") return false; + if (stripped === "null") return null; + if (/^-?\d+$/.test(stripped)) { + const n = Number(stripped); + if (Number.isSafeInteger(n)) return n; + } + if (/^-?\d*\.\d+$/.test(stripped)) { + const n = Number(stripped); + if (Number.isFinite(n)) return n; + } + return raw; +} + +/** + * Parse a complete (non-streaming) Composer content string. + * + * Returns { content, toolCalls } where: + * - content: the residual visible text (preamble + trailing text combined + * and trimmed; empty string if nothing left). + * - toolCalls: array of OpenAI-shaped tool_calls; empty if none found. + * + * If the input has no tool-call block, returns { content: input, toolCalls: [] }. + */ +export function parseComposerToolCalls(text: string): ParseComposerResult { + if (!text || typeof text !== "string") { + return { content: text || "", toolCalls: [] }; + } + + const match = text.match(OUTER_RE); + if (!match || match.index === undefined) { + return { content: text, toolCalls: [] }; + } + + const preamble = text.slice(0, match.index); + const trailing = text.slice(match.index + match[0].length); + const block = match[1]; + + const toolCalls: ComposerToolCall[] = []; + let idx = 0; + for (const innerMatch of block.matchAll(INNER_RE)) { + const parsed = parseInnerCall(innerMatch[1]); + if (!parsed) continue; + toolCalls.push({ + id: generateToolCallId(idx), + type: "function", + function: parsed, + }); + idx += 1; + } + + const residual = (preamble + trailing).trim(); + return { content: residual, toolCalls }; +} + +/** + * Streaming helper: feed it the *accumulated* content seen so far and it + * returns what is safe to emit as visible text, plus whether tool calls + * are now ready to be flushed. + * + * { safeDelta, ready, toolCalls, holdback } + * + * safeDelta: text delta that can be emitted to the client right now as a + * content delta (relative to how much was already emitted via state.emitted). + * ready: true once a complete `<|tool▁calls▁end|>` has been seen and + * toolCalls are parsed. + * toolCalls: parsed tool calls (only populated when ready=true). + * holdback: whether more bytes are being held back (an outer-block has + * opened but not yet closed, OR a partial opening marker is at the + * tail of the buffer). + * + * Usage pattern: + * const state = createStreamingState(); + * for each frame: const out = feedStreamingChunk(state, accumulated); + * emit out.safeDelta as content delta; + * if (out.ready) emit out.toolCalls and stop emitting content. + */ +export function createStreamingState(): StreamingState { + return { + emitted: 0, // number of safe content chars already emitted + done: false, + }; +} + +export function feedStreamingChunk(state: StreamingState, accumulated: string): FeedChunkResult { + if (state.done) { + return { safeDelta: "", ready: false, toolCalls: [], holdback: false }; + } + if (!accumulated) { + return { safeDelta: "", ready: false, toolCalls: [], holdback: false }; + } + + // 1. Complete block already in buffer? Parse it. + const m = accumulated.match(OUTER_RE); + if (m && m.index !== undefined) { + const preamble = accumulated.slice(0, m.index); + const block = m[1]; + const toolCalls: ComposerToolCall[] = []; + let idx = 0; + for (const innerMatch of block.matchAll(INNER_RE)) { + const parsed = parseInnerCall(innerMatch[1]); + if (!parsed) continue; + toolCalls.push({ + id: generateToolCallId(idx), + type: "function", + function: parsed, + }); + idx += 1; + } + // Emit any preamble we haven't emitted yet. + const safe = preamble; + const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; + state.emitted = safe.length; + state.done = true; + return { safeDelta, ready: true, toolCalls, holdback: false }; + } + + // 2. Look for an opening-only marker. If found, everything before it is + // safe; everything after must be held until we see the closing marker. + const openOnlyRe = new RegExp(`<${FW}tool${SEP}calls${SEP}begin${FW}>`, "i"); + const openMatch = accumulated.match(openOnlyRe); + if (openMatch && openMatch.index !== undefined) { + const safe = accumulated.slice(0, openMatch.index); + const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; + state.emitted = safe.length; + return { safeDelta, ready: false, toolCalls: [], holdback: true }; + } + + // 3. Partial opening marker at the tail? Hold back the suspicious tail. + const tailMatch = accumulated.match(PARTIAL_OPEN_MARKER_RE); + if (tailMatch && tailMatch.index !== undefined) { + const safe = accumulated.slice(0, tailMatch.index); + const safeDelta = safe.length > state.emitted ? safe.slice(state.emitted) : ""; + state.emitted = safe.length; + return { safeDelta, ready: false, toolCalls: [], holdback: true }; + } + + // 4. No markers anywhere. Emit everything new. + const safeDelta = accumulated.length > state.emitted ? accumulated.slice(state.emitted) : ""; + state.emitted = accumulated.length; + return { safeDelta, ready: false, toolCalls: [], holdback: false }; +} diff --git a/tests/unit/composer-tool-calls.test.ts b/tests/unit/composer-tool-calls.test.ts new file mode 100644 index 0000000000..c1133f0513 --- /dev/null +++ b/tests/unit/composer-tool-calls.test.ts @@ -0,0 +1,216 @@ +/** + * Tests for composerToolCalls.ts — DeepSeek inline tool-call parser. + * Ported from decolua/9router#1335 (noestelar), adapted to OmniRoute + * node:test conventions. + */ + +import test from "node:test"; +import assert from "node:assert/strict"; +import { + hasComposerToolCalls, + parseComposerToolCalls, + createStreamingState, + feedStreamingChunk, +} from "../../open-sse/utils/composerToolCalls.ts"; + +// ─── hasComposerToolCalls ───────────────────────────────────────────────────── + +test("hasComposerToolCalls: returns false for plain text", () => { + assert.equal(hasComposerToolCalls("Hello world"), false); +}); + +test("hasComposerToolCalls: returns false for empty string", () => { + assert.equal(hasComposerToolCalls(""), false); +}); + +test("hasComposerToolCalls: detects full-width pipe markers", () => { + const text = + "<|tool▁calls▁begin|><|tool▁call▁begin|>\ntool_name\n<|tool▁sep|>arg\nval\n<|tool▁call▁end|><|tool▁calls▁end|>"; + assert.equal(hasComposerToolCalls(text), true); +}); + +test("hasComposerToolCalls: detects ASCII fallback markers", () => { + const text = + "<|tool_calls_begin|><|tool_call_begin|>\ntool_name\n<|tool_sep|>arg\nval\n<|tool_call_end|><|tool_calls_end|>"; + assert.equal(hasComposerToolCalls(text), true); +}); + +// ─── parseComposerToolCalls ─────────────────────────────────────────────────── + +test("parseComposerToolCalls: returns unchanged text when no markers present", () => { + const result = parseComposerToolCalls("Hello world"); + assert.equal(result.content, "Hello world"); + assert.deepEqual(result.toolCalls, []); +}); + +test("parseComposerToolCalls: parses a single tool call with two args", () => { + const text = + "Searching now.\n<|tool▁calls▁begin|><|tool▁call▁begin|>\nsearch_files\n" + + "<|tool▁sep|>pattern\n*cron*.py\n" + + "<|tool▁sep|>path\n/home/user/.hermes\n" + + "<|tool▁call▁end|><|tool▁calls▁end|>"; + + const result = parseComposerToolCalls(text); + + assert.equal(result.content, "Searching now."); + assert.equal(result.toolCalls.length, 1); + const tc = result.toolCalls[0]; + assert.equal(tc.type, "function"); + assert.equal(tc.function.name, "search_files"); + const args = JSON.parse(tc.function.arguments); + assert.equal(args.pattern, "*cron*.py"); + assert.equal(args.path, "/home/user/.hermes"); + // ID must follow call_<...> pattern + assert.match(tc.id, /^call_/); +}); + +test("parseComposerToolCalls: strips markers and returns residual preamble", () => { + const text = + "Preamble.\n<|tool▁calls▁begin|><|tool▁call▁begin|>\nwrite_file\n" + + "<|tool▁sep|>path\n/tmp/x.txt\n<|tool▁sep|>content\nhello\n" + + "<|tool▁call▁end|><|tool▁calls▁end|>\nTrailing."; + + const result = parseComposerToolCalls(text); + // Both preamble and trailing should be in content + assert.ok(result.content.includes("Preamble.") || result.content.includes("Trailing.")); + assert.equal(result.toolCalls.length, 1); + assert.equal(result.toolCalls[0].function.name, "write_file"); + // No marker should remain in content + assert.ok(!result.content.includes("tool▁calls▁begin")); + assert.ok(!result.content.includes("|")); +}); + +test("parseComposerToolCalls: parses multiple tool calls", () => { + const text = + "<|tool▁calls▁begin|>" + + "<|tool▁call▁begin|>\ntool_a\n<|tool▁sep|>arg\nval_a\n<|tool▁call▁end|>" + + "<|tool▁call▁begin|>\ntool_b\n<|tool▁sep|>arg\nval_b\n<|tool▁call▁end|>" + + "<|tool▁calls▁end|>"; + + const result = parseComposerToolCalls(text); + assert.equal(result.toolCalls.length, 2); + assert.equal(result.toolCalls[0].function.name, "tool_a"); + assert.equal(result.toolCalls[1].function.name, "tool_b"); +}); + +test("parseComposerToolCalls: coerces JSON object arg value", () => { + const text = + "<|tool▁calls▁begin|><|tool▁call▁begin|>\njson_tool\n" + + '<|tool▁sep|>data\n{"key":"value"}\n' + + "<|tool▁call▁end|><|tool▁calls▁end|>"; + + const result = parseComposerToolCalls(text); + const args = JSON.parse(result.toolCalls[0].function.arguments); + assert.deepEqual(args.data, { key: "value" }); +}); + +test("parseComposerToolCalls: coerces integer arg value", () => { + const text = + "<|tool▁calls▁begin|><|tool▁call▁begin|>\nset_timeout\n" + + "<|tool▁sep|>ms\n3000\n" + + "<|tool▁call▁end|><|tool▁calls▁end|>"; + + const result = parseComposerToolCalls(text); + const args = JSON.parse(result.toolCalls[0].function.arguments); + assert.equal(args.ms, 3000); +}); + +test("parseComposerToolCalls: coerces boolean arg value", () => { + const text = + "<|tool▁calls▁begin|><|tool▁call▁begin|>\nset_flag\n" + + "<|tool▁sep|>enabled\ntrue\n" + + "<|tool▁call▁end|><|tool▁calls▁end|>"; + + const result = parseComposerToolCalls(text); + const args = JSON.parse(result.toolCalls[0].function.arguments); + assert.equal(args.enabled, true); +}); + +test("parseComposerToolCalls: returns empty toolCalls for null/undefined input", () => { + // @ts-expect-error testing runtime safety + const result = parseComposerToolCalls(null); + assert.equal(result.content, ""); + assert.deepEqual(result.toolCalls, []); +}); + +test("parseComposerToolCalls: accepts ASCII fallback markers", () => { + const text = + "<|tool_calls_begin|><|tool_call_begin|>\nmy_tool\n" + + "<|tool_sep|>arg\nvalue\n" + + "<|tool_call_end|><|tool_calls_end|>"; + + const result = parseComposerToolCalls(text); + assert.equal(result.toolCalls.length, 1); + assert.equal(result.toolCalls[0].function.name, "my_tool"); +}); + +// ─── Streaming parser ───────────────────────────────────────────────────────── + +test("feedStreamingChunk: emits safe text before the marker block", () => { + const state = createStreamingState(); + const out = feedStreamingChunk(state, "Safe text before."); + assert.equal(out.safeDelta, "Safe text before."); + assert.equal(out.ready, false); + assert.equal(out.holdback, false); +}); + +test("feedStreamingChunk: holds back partial opening marker at tail", () => { + const state = createStreamingState(); + const out = feedStreamingChunk(state, "Working on it.<|tool▁call"); + assert.equal(out.safeDelta, "Working on it."); + assert.equal(out.holdback, true); + assert.equal(out.ready, false); +}); + +test("feedStreamingChunk: suppresses text once opening marker is seen", () => { + const state = createStreamingState(); + // First: safe text only + feedStreamingChunk(state, "Preamble."); + // Second: opening marker arrives mid-accumulation + const out = feedStreamingChunk(state, "Preamble.<|tool▁calls▁begin|>"); + assert.equal(out.safeDelta, ""); + assert.equal(out.holdback, true); +}); + +test("feedStreamingChunk: flushes tool calls once the closing marker arrives", () => { + const state = createStreamingState(); + const acc = + "ok\n<|tool▁calls▁begin|><|tool▁call▁begin|>\nwrite_file\n" + + "<|tool▁sep|>path\n/tmp/x\n<|tool▁sep|>content\nhi\n" + + "<|tool▁call▁end|><|tool▁calls▁end|>"; + + // Simulate it arriving in two halves + feedStreamingChunk(state, acc.slice(0, 30)); + const out = feedStreamingChunk(state, acc); + + assert.equal(out.ready, true); + assert.equal(out.toolCalls.length, 1); + assert.equal(out.toolCalls[0].function.name, "write_file"); + const args = JSON.parse(out.toolCalls[0].function.arguments); + assert.deepEqual(args, { path: "/tmp/x", content: "hi" }); +}); + +test("feedStreamingChunk: does not leak partial opening marker split across frames", () => { + const state = createStreamingState(); + const a = feedStreamingChunk(state, "Working on it.<|tool▁call"); + assert.equal(a.safeDelta, "Working on it."); + assert.equal(a.holdback, true); + const b = feedStreamingChunk(state, "Working on it.<|tool▁calls▁begin|>"); + assert.equal(b.safeDelta, ""); + assert.equal(b.holdback, true); +}); + +test("feedStreamingChunk: emits no tool calls when block closes empty", () => { + const state = createStreamingState(); + const out = feedStreamingChunk(state, "<|tool▁calls▁begin|><|tool▁calls▁end|>"); + assert.equal(out.ready, true); + assert.deepEqual(out.toolCalls, []); +}); + +test("feedStreamingChunk: noop after done state", () => { + const state = createStreamingState(); + state.done = true; + const out = feedStreamingChunk(state, "some text"); + assert.equal(out.safeDelta, ""); + assert.equal(out.ready, false); +});