Files
2026-07-13 13:39:12 +08:00

217 lines
8.7 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);
});