Files
OmniRoute/tests/unit/composer-tool-calls.test.ts
Diego Rodrigues de Sa e Souza 0130a4bbb2 fix(sse): handle space-separated arg name/value in Composer tool calls (port from 9router#1811) (#7116)
parseInnerCall only split arg segments on a newline between the arg name
and its value. Cursor's live Composer/Auto output has been observed using a
single space instead, so those segments were treated as one long
(space-containing) arg name with an empty value, silently no-opping
Write/tool calls for Composer/Auto models.

Fall back to splitting on the first whitespace boundary when no newline is
present in the segment.

Reported-by: way-art (https://github.com/decolua/9router/issues/1811)
2026-07-16 14:13:43 -03:00

241 lines
9.9 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
// ─── Regression: space-separated arg name/value (9router#1811) ───────────────
// Cursor's real Composer/Auto output has been observed using a single space
// (instead of a newline) between the arg name and its value inside a
// <tool▁sep> segment, e.g. "<tool▁sep>path /Users/.../test". The parser
// must still extract {path: "/Users/.../test"} rather than treating the whole
// segment as the (empty-valued) arg name.
test("parseComposerToolCalls: parses args separated by a space instead of a newline (Cursor Composer live capture)", () => {
const text =
"<tool▁calls▁begin><tool▁call▁begin> Write " +
"<tool▁sep>path /Users/kabawagang/Desktop/Code/iOS_Review/test " +
"<tool▁sep>contents 22\n\n<tool▁call▁end><tool▁calls▁end>";
const result = parseComposerToolCalls(text);
assert.equal(result.toolCalls.length, 1);
const tc = result.toolCalls[0];
assert.equal(tc.function.name, "Write");
const args = JSON.parse(tc.function.arguments);
assert.deepEqual(args, {
path: "/Users/kabawagang/Desktop/Code/iOS_Review/test",
contents: 22,
});
});