| |
| |
| |
| |
| |
| |
| |
| |
| import { test } from "node:test"; |
| import assert from "node:assert/strict"; |
|
|
| import { |
| parseToolCalls, |
| splitReasoning, |
| coerceArgs, |
| foldSystemIntoUser, |
| mergeAlternating, |
| buildSystemPrompt, |
| formatToolResult, |
| } from "../protocol.js"; |
| import { TOOL_SCHEMAS } from "../tools.js"; |
|
|
| const S = TOOL_SCHEMAS; |
|
|
| |
| |
| |
|
|
| test("splitReasoning handles a paired think block", () => { |
| const { reasoning, text } = splitReasoning("<think>weighing it up</think>Hello."); |
| assert.equal(reasoning, "weighing it up"); |
| assert.equal(text, "Hello."); |
| }); |
|
|
| test("splitReasoning handles an ORPHAN closing tag (real Qwen3.5 behaviour)", () => { |
| |
| const raw = "The user wants a price. I should call get_price.\n</think>\n\nBitcoin is $64,779."; |
| const { reasoning, text } = splitReasoning(raw); |
| assert.match(reasoning, /I should call get_price/); |
| assert.equal(text, "Bitcoin is $64,779."); |
| assert.ok(!text.includes("</think>"), "closing tag must not leak into visible text"); |
| }); |
|
|
| test("splitReasoning handles an unterminated open tag (truncated output)", () => { |
| const { reasoning, text } = splitReasoning("Answer soon.<think>still thinking when cut off"); |
| assert.equal(text, "Answer soon."); |
| assert.match(reasoning, /still thinking/); |
| }); |
|
|
| |
| |
| |
|
|
| test("parses a well-formed tagged call", () => { |
| const raw = 'I need the price.\n<tool_call>{"name": "get_price", "arguments": {"coin_id": "bitcoin"}}</tool_call>'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1); |
| assert.deepEqual(calls[0], { name: "get_price", arguments: { coin_id: "bitcoin" } }); |
| }); |
|
|
| test("parses an UNTERMINATED call (stop sequence ate the closing tag)", () => { |
| |
| const raw = '<tool_call>{"name": "get_price", "arguments": {"coin_id": "solana"}}'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1); |
| assert.equal(calls[0].arguments.coin_id, "solana"); |
| }); |
|
|
| test("parses two parallel calls in one completion", () => { |
| const raw = |
| 'Need both.\n<tool_call>{"name":"get_price","arguments":{"coin_id":"ethereum"}}</tool_call>\n' + |
| '<tool_call>{"name":"get_price","arguments":{"coin_id":"solana"}}</tool_call>'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 2); |
| assert.deepEqual(calls.map((c) => c.arguments.coin_id), ["ethereum", "solana"]); |
| }); |
|
|
| test("parses a ```json fenced call", () => { |
| const raw = 'Let me look.\n```json\n{"name": "search_coin", "arguments": {"query": "doge"}}\n```'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1); |
| assert.equal(calls[0].name, "search_coin"); |
| }); |
|
|
| test("parses a bare object with no tags or fences", () => { |
| const raw = 'I will call {"name": "get_coin_info", "arguments": {"coin_id": "cardano"}} now.'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1); |
| assert.equal(calls[0].name, "get_coin_info"); |
| }); |
|
|
| test("repairs single quotes, trailing commas and Python literals", () => { |
| const raw = "<tool_call>{'name': 'get_price', 'arguments': {'coin_id': 'bitcoin',},}</tool_call>"; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1); |
| assert.deepEqual(calls[0].arguments, { coin_id: "bitcoin" }); |
| }); |
|
|
| test("repairs unquoted keys", () => { |
| const raw = '<tool_call>{name: "get_price", arguments: {coin_id: "bitcoin"}}</tool_call>'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1); |
| assert.equal(calls[0].arguments.coin_id, "bitcoin"); |
| }); |
|
|
| test("accepts alias key names (tool / parameters)", () => { |
| const raw = '<tool_call>{"tool": "get_price", "parameters": {"coin_id": "bitcoin"}}</tool_call>'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls[0].name, "get_price"); |
| assert.equal(calls[0].arguments.coin_id, "bitcoin"); |
| }); |
|
|
| test("accepts arguments delivered as a JSON string", () => { |
| const raw = '<tool_call>{"name":"get_price","arguments":"{\\"coin_id\\":\\"bitcoin\\"}"}</tool_call>'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls[0].arguments.coin_id, "bitcoin"); |
| }); |
|
|
| test("unwraps a nested function/tool_call envelope", () => { |
| const raw = '<tool_call>{"function": {"name": "get_price", "arguments": {"coin_id": "bitcoin"}}}</tool_call>'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1); |
| assert.equal(calls[0].name, "get_price"); |
| }); |
|
|
| test("deduplicates a call that is both fenced AND tagged", () => { |
| const raw = |
| '<tool_call>{"name":"get_price","arguments":{"coin_id":"bitcoin"}}</tool_call>\n' + |
| '```json\n{"name":"get_price","arguments":{"coin_id":"bitcoin"}}\n```'; |
| const { calls } = parseToolCalls(raw, S); |
| assert.equal(calls.length, 1, "must not fire the same call twice"); |
| }); |
|
|
| test("plain prose yields no calls and is returned as visible text", () => { |
| const { calls, text } = parseToolCalls("Bitcoin is trading at $64,779 right now.", S); |
| assert.equal(calls.length, 0); |
| assert.match(text, /64,779/); |
| }); |
|
|
| test("the observed Qwen3.5-2B failure mode yields no calls, not a crash", () => { |
| |
| const { calls, text } = parseToolCalls("I need the live price for AAPL.\n", S); |
| assert.equal(calls.length, 0); |
| assert.match(text, /I need the live price/); |
| }); |
|
|
| test("malformed beyond repair does not throw", () => { |
| const { calls } = parseToolCalls('<tool_call>{"name": "get_price", "argum</tool_call>', S); |
| assert.ok(Array.isArray(calls)); |
| }); |
|
|
| test("reasoning plus a call in one completion: both are recovered", () => { |
| const raw = |
| 'The user asks for a price, so I need get_price.\n</think>\n\nFetching that now.\n' + |
| '<tool_call>{"name":"get_price","arguments":{"coin_id":"bitcoin"}}'; |
| const { reasoning, calls, text } = parseToolCalls(raw, S); |
| assert.match(reasoning, /so I need get_price/); |
| assert.equal(calls.length, 1); |
| assert.ok(!text.includes("tool_call"), "call block must be stripped from visible prose"); |
| }); |
|
|
| |
| |
| |
|
|
| test("coerces stringified numbers and integers per schema", () => { |
| const out = coerceArgs("convert_currency", { amount: "1919.55", from_currency: "USD", to_currency: "EUR" }, S); |
| assert.equal(out.amount, 1919.55); |
| assert.equal(typeof out.amount, "number"); |
|
|
| const chart = coerceArgs("get_market_chart", { coin_id: "bitcoin", days: "30" }, S); |
| assert.equal(chart.days, 30); |
| assert.equal(typeof chart.days, "number"); |
| }); |
|
|
| test("coerces a numeric value into a string field", () => { |
| const out = coerceArgs("search_coin", { query: 123 }, S); |
| assert.equal(out.query, "123"); |
| }); |
|
|
| test("coercion leaves unknown tools and unknown keys untouched", () => { |
| assert.deepEqual(coerceArgs("no_such_tool", { a: "1" }, S), { a: "1" }); |
| assert.deepEqual(coerceArgs("get_price", { mystery: "x" }, S), { mystery: "x" }); |
| }); |
|
|
| |
| |
| |
|
|
| test("foldSystemIntoUser merges the system prompt into the first user turn", () => { |
| const out = foldSystemIntoUser([ |
| { role: "system", content: "SYS" }, |
| { role: "user", content: "hi" }, |
| ]); |
| assert.equal(out.length, 1); |
| assert.equal(out[0].role, "user"); |
| assert.match(out[0].content, /SYS/); |
| assert.match(out[0].content, /hi/); |
| assert.ok(!out.some((m) => m.role === "system"), "no system role may survive"); |
| }); |
|
|
| test("mergeAlternating collapses consecutive same-role turns", () => { |
| const out = mergeAlternating([ |
| { role: "user", content: "a" }, |
| { role: "user", content: "b" }, |
| { role: "assistant", content: "c" }, |
| ]); |
| assert.equal(out.length, 2); |
| assert.equal(out[0].content, "a\nb"); |
| |
| for (let i = 1; i < out.length; i++) { |
| assert.notEqual(out[i].role, out[i - 1].role); |
| } |
| }); |
|
|
| |
| |
| |
|
|
| test("system prompt embeds every tool name and is valid-schema-bearing", () => { |
| const p = buildSystemPrompt(S); |
| for (const s of S) assert.ok(p.includes(s.function.name), `missing ${s.function.name}`); |
| assert.ok(p.includes("<tool_call>")); |
| assert.ok(!/[Ѐ-ӿ]/.test(p), "prompt must not contain stray Cyrillic"); |
| }); |
|
|
| test("formatToolResult round-trips through the parser as non-call text", () => { |
| const line = formatToolResult("get_price", { coin_id: "bitcoin", price: 1 }); |
| assert.match(line, /^TOOL_RESULT get_price /); |
| |
| const { calls } = parseToolCalls(line, S); |
| assert.equal(calls.length, 0, "a TOOL_RESULT line must not parse as a call"); |
| }); |
|
|