217 lines
8.7 KiB
TypeScript
217 lines
8.7 KiB
TypeScript
/**
|
||
* 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);
|
||
});
|