File size: 9,656 Bytes
9eeee48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
/**
 * Offline tests for the prompted protocol. No network, no inference credits.
 *
 * Every input string below is either a real completion captured from a small
 * model, or a malformation of the kind they demonstrably produce.
 *
 * Run: node --test tests/
 */
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;

/* ---------------------------------------------------------------- *
 * reasoning splitting
 * ---------------------------------------------------------------- */

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)", () => {
  // Captured shape: reasoning, then </think>, with no opening tag at all.
  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/);
});

/* ---------------------------------------------------------------- *
 * parsing: the happy path and the many unhappy ones
 * ---------------------------------------------------------------- */

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)", () => {
  // This is the normal case in production: stop=["</tool_call>"].
  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", () => {
  // Real captured output: it mimicked the few-shot's prose line and stopped.
  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");
});

/* ---------------------------------------------------------------- *
 * argument coercion
 * ---------------------------------------------------------------- */

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" });
});

/* ---------------------------------------------------------------- *
 * template shims
 * ---------------------------------------------------------------- */

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");
  // gemma-2 requires strict alternation; verify that is what we produced.
  for (let i = 1; i < out.length; i++) {
    assert.notEqual(out[i].role, out[i - 1].role);
  }
});

/* ---------------------------------------------------------------- *
 * prompt assembly
 * ---------------------------------------------------------------- */

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 /);
  // A result echoed back must never be mistaken for a new tool call.
  const { calls } = parseToolCalls(line, S);
  assert.equal(calls.length, 0, "a TOOL_RESULT line must not parse as a call");
});