@webmcp-auto-ui/agent 2.5.26 → 2.5.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,472 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { WasmProvider } from '../src/providers/wasm.js';
3
+ import { buildGemmaPrompt, formatGemmaToolDeclaration, gemmaValue } from '../src/prompts/gemma4-prompt-builder.js';
4
+ import type { ProviderTool, ChatMessage, ContentBlock } from '../src/types.js';
5
+
6
+ // Spec source of truth: docs/GEMMA4-SPEC.md
7
+ // Each `it()` references the spec section it asserts (`§N.M`). When the code
8
+ // under test diverges from the spec, the test is EXPECTED to fail — this file
9
+ // documents the target behaviour, not the current (partially-buggy) behaviour.
10
+
11
+ // ─────────────────────────────────────────────────────────────────────────────
12
+ // §5 — Tool declaration wrapper + delimiters + UPPER types + required list
13
+ // ─────────────────────────────────────────────────────────────────────────────
14
+ describe('formatGemmaToolDeclaration — spec §5', () => {
15
+ const tool: ProviderTool = {
16
+ name: 'tricoteuses_data_search_recipes',
17
+ description: 'Search recipes by keyword',
18
+ input_schema: {
19
+ type: 'object',
20
+ properties: {
21
+ query: { type: 'string', description: 'Keyword to search for' },
22
+ },
23
+ required: ['query'],
24
+ },
25
+ };
26
+
27
+ it('wraps declaration in <|tool>declaration:NAME{...}<tool|> (§5)', () => {
28
+ const decl = formatGemmaToolDeclaration(tool);
29
+ expect(decl.startsWith('<|tool>declaration:tricoteuses_data_search_recipes{')).toBe(true);
30
+ expect(decl.endsWith('}<tool|>')).toBe(true);
31
+ });
32
+
33
+ it('uses <|"|> as string delimiters (§3, §5)', () => {
34
+ const decl = formatGemmaToolDeclaration(tool);
35
+ expect(decl).toContain('<|"|>Search recipes by keyword<|"|>');
36
+ expect(decl).toContain('<|"|>query<|"|>');
37
+ });
38
+
39
+ it('emits types in UPPER case (§5)', () => {
40
+ const decl = formatGemmaToolDeclaration(tool);
41
+ expect(decl).toContain('type:<|"|>STRING<|"|>');
42
+ expect(decl).toContain('type:<|"|>OBJECT<|"|>');
43
+ expect(decl).not.toMatch(/type:<\|"\|>string<\|"\|>/);
44
+ });
45
+
46
+ it('emits required list with <|"|>-quoted names (§5)', () => {
47
+ const decl = formatGemmaToolDeclaration(tool);
48
+ expect(decl).toContain('required:[<|"|>query<|"|>]');
49
+ });
50
+ });
51
+
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+ // §3 + §6 — Value serialization (strings delimited, others bare)
54
+ // ─────────────────────────────────────────────────────────────────────────────
55
+ describe('gemmaValue — spec §3 + §6', () => {
56
+ it('wraps strings in <|"|> delimiters (§3)', () => {
57
+ expect(gemmaValue('hello')).toBe('<|"|>hello<|"|>');
58
+ });
59
+
60
+ it('leaves numbers, booleans and null bare (§3)', () => {
61
+ expect(gemmaValue(42)).toBe('42');
62
+ expect(gemmaValue(true)).toBe('true');
63
+ expect(gemmaValue(false)).toBe('false');
64
+ expect(gemmaValue(null)).toBe('null');
65
+ });
66
+
67
+ it('serializes nested objects and arrays (§6)', () => {
68
+ expect(gemmaValue({ a: 1, b: 'x' })).toBe('{a:1,b:<|"|>x<|"|>}');
69
+ expect(gemmaValue([1, 'x'])).toBe('[1,<|"|>x<|"|>]');
70
+ });
71
+
72
+ // Spec §3: "No official escape rule" — trou du format. Documented mitigation
73
+ // is client-side pre-cleaning, NOT format-level escaping. We keep the
74
+ // verbatim/collision behaviour and test for it so any future change is
75
+ // intentional. This test therefore PASSES — it freezes the spec-conformant
76
+ // "no escape" behaviour.
77
+ it('does NOT escape <|"|> inside string content (§3, trou du format)', () => {
78
+ expect(gemmaValue('<|"|>')).toBe('<|"|><|"|><|"|>');
79
+ });
80
+ });
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // §6 — Tool call syntax
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+ describe('WasmProvider.formatToolCall — spec §6', () => {
86
+ it('emits <|tool_call>call:NAME{k:v,...}<tool_call|> (§6)', () => {
87
+ const out = WasmProvider.formatToolCall('foo', { a: 1, b: 'x' });
88
+ expect(out).toBe('<|tool_call>call:foo{a:1,b:<|"|>x<|"|>}<tool_call|>');
89
+ });
90
+
91
+ it('empty object produces an empty brace pair (§6)', () => {
92
+ expect(WasmProvider.formatToolCall('foo', {})).toBe('<|tool_call>call:foo{}<tool_call|>');
93
+ });
94
+
95
+ // Bug: Object.entries(null|undefined) throws TypeError. Spec §6 says args are
96
+ // "k:v pairs separated by ," — the empty case IS an empty object. A missing/
97
+ // null input is semantically equivalent to `{}`, not a crash. These tests
98
+ // are EXPECTED TO FAIL until wasm.ts guards against null/undefined.
99
+ it('null input should be treated as {} (§6) — currently throws, bug to fix', () => {
100
+ expect(WasmProvider.formatToolCall('foo', null as unknown as Record<string, unknown>))
101
+ .toBe('<|tool_call>call:foo{}<tool_call|>');
102
+ });
103
+
104
+ it('undefined input should be treated as {} (§6) — currently throws, bug to fix', () => {
105
+ expect(WasmProvider.formatToolCall('foo', undefined as unknown as Record<string, unknown>))
106
+ .toBe('<|tool_call>call:foo{}<tool_call|>');
107
+ });
108
+ });
109
+
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+ // §7 — Tool response syntax (content can be object, array, primitive, string)
112
+ // ─────────────────────────────────────────────────────────────────────────────
113
+ describe('WasmProvider.formatToolResponse — spec §7, §10.2, §10.3', () => {
114
+ // Spec §7 prototype: `<|tool_response>response:{CONTENT}<tool_response|>` —
115
+ // no tool name, content passed through literally.
116
+ it('JSON object content passes through literally (§7)', () => {
117
+ expect(WasmProvider.formatToolResponse('{"a":1}'))
118
+ .toBe('<|tool_response>response:{"a":1}<tool_response|>');
119
+ });
120
+
121
+ // Spec §10.2 byte-exact.
122
+ it('JSON array top-level passes through literally (§7, §10.2)', () => {
123
+ expect(WasmProvider.formatToolResponse('["Paris","Lyon","Marseille"]'))
124
+ .toBe('<|tool_response>response:["Paris","Lyon","Marseille"]<tool_response|>');
125
+ });
126
+
127
+ // Spec §10.3 byte-exact.
128
+ it('JSON primitive number passes through literally (§7, §10.3)', () => {
129
+ expect(WasmProvider.formatToolResponse('42'))
130
+ .toBe('<|tool_response>response:42<tool_response|>');
131
+ });
132
+
133
+ it('JSON primitive null passes through literally (§7)', () => {
134
+ expect(WasmProvider.formatToolResponse('null'))
135
+ .toBe('<|tool_response>response:null<tool_response|>');
136
+ });
137
+
138
+ it('JSON primitive boolean passes through literally (§7)', () => {
139
+ expect(WasmProvider.formatToolResponse('true'))
140
+ .toBe('<|tool_response>response:true<tool_response|>');
141
+ });
142
+
143
+ // Plain-string (JSON.parse fails): analogy with §3 — strings delimited by <|"|>.
144
+ it('plain-string content uses bare <|"|>…<|"|> (§3, §7)', () => {
145
+ expect(WasmProvider.formatToolResponse('raw text'))
146
+ .toBe('<|tool_response>response:<|"|>raw text<|"|><tool_response|>');
147
+ });
148
+ });
149
+
150
+ // ─────────────────────────────────────────────────────────────────────────────
151
+ // §3 — No escape mechanism for delimiters (trou du format)
152
+ // ─────────────────────────────────────────────────────────────────────────────
153
+ describe('Delimiter collisions — spec §3 (trou du format)', () => {
154
+ // Spec §3 explicitly states there is NO escape mechanism. Mitigation is the
155
+ // responsibility of the client BEFORE calling the format functions. We
156
+ // therefore freeze the "pass-through verbatim" behaviour and document it.
157
+ it('<tool_call|> inside a string value is emitted verbatim (§3)', () => {
158
+ const out = WasmProvider.formatToolCall('foo', { note: '<tool_call|>' });
159
+ expect(out).toBe('<|tool_call>call:foo{note:<|"|><tool_call|><|"|>}<tool_call|>');
160
+ });
161
+
162
+ it('<turn|> inside a JSON string result is emitted verbatim (§3)', () => {
163
+ const out = WasmProvider.formatToolResponse('"contains <turn|> marker"');
164
+ expect(out).toBe('<|tool_response>response:"contains <turn|> marker"<tool_response|>');
165
+ });
166
+ });
167
+
168
+ // ─────────────────────────────────────────────────────────────────────────────
169
+ // §1 — Prompt structure (turn delimiters, trailing open model turn)
170
+ // ─────────────────────────────────────────────────────────────────────────────
171
+ describe('buildGemmaPrompt — spec §1', () => {
172
+ it('produces <|turn>system...<turn|>, <|turn>user...<turn|>, open <|turn>model\\n (§1)', () => {
173
+ const messages: ChatMessage[] = [{ role: 'user', content: 'Hello' }];
174
+ const prompt = buildGemmaPrompt({ systemPrompt: 'SYS', messages });
175
+ expect(prompt).toContain('<|turn>system\nSYS\n<turn|>');
176
+ expect(prompt).toContain('<|turn>user\nHello<turn|>');
177
+ expect(prompt.endsWith('<|turn>model\n')).toBe(true);
178
+ });
179
+
180
+ it('maps role "assistant" to <|turn>model (§4)', () => {
181
+ const messages: ChatMessage[] = [
182
+ { role: 'user', content: 'Q' },
183
+ { role: 'assistant', content: 'A' },
184
+ ];
185
+ const prompt = buildGemmaPrompt({ messages });
186
+ expect(prompt).toContain('<|turn>user\nQ<turn|>');
187
+ expect(prompt).toContain('<|turn>model\nA<turn|>');
188
+ });
189
+
190
+ it('serializes tool_use blocks as <|tool_call>call:...<tool_call|> (§6)', () => {
191
+ const blocks: ContentBlock[] = [
192
+ { type: 'tool_use', id: 'tu1', name: 'search', input: { query: 'cats' } },
193
+ ];
194
+ const prompt = buildGemmaPrompt({ messages: [{ role: 'assistant', content: blocks }] });
195
+ expect(prompt).toContain('<|tool_call>call:search{query:<|"|>cats<|"|>}<tool_call|>');
196
+ });
197
+ });
198
+
199
+ // ─────────────────────────────────────────────────────────────────────────────
200
+ // §4 + §7 — Role handling & tool_response placement
201
+ // ─────────────────────────────────────────────────────────────────────────────
202
+ describe('buildGemmaPrompt — roles & tool_response placement (spec §4, §7, §10.1)', () => {
203
+ // Spec §4: "tool role does NOT exist in Gemma 4. Tool responses stay inside
204
+ // the open model turn."
205
+ // Spec §7 (byte-exact example): when a client replies to a tool_call with a
206
+ // tool_result, that result must be serialized INSIDE the same <|turn>model
207
+ // block that contained the tool_call — NOT in a new user or tool turn.
208
+ //
209
+ // Current code creates a separate <|turn>user block for the tool_result
210
+ // (because `role:'user'` in the client ChatMessage) — this is the
211
+ // STRUCTURAL BUG. Test is EXPECTED TO FAIL.
212
+ it('§10.1 byte-exact: tool_response nests inside the model turn with the tool_call', () => {
213
+ const messages: ChatMessage[] = [
214
+ { role: 'user', content: 'Quel temps à Paris ?' },
215
+ {
216
+ role: 'assistant',
217
+ content: [
218
+ { type: 'tool_use', id: 'tu1', name: 'get_weather', input: { city: 'Paris' } },
219
+ ],
220
+ },
221
+ {
222
+ role: 'user',
223
+ content: [
224
+ { type: 'tool_result', tool_use_id: 'tu1', content: '{"temp":12,"condition":"rain"}' },
225
+ ],
226
+ },
227
+ ];
228
+ const prompt = buildGemmaPrompt({ messages });
229
+
230
+ // Positive: the call and the response must sit inside the SAME model turn.
231
+ const expectedFragment =
232
+ '<|turn>model\n' +
233
+ '<|tool_call>call:get_weather{city:<|"|>Paris<|"|>}<tool_call|>' +
234
+ '<|tool_response>response:{"temp":12,"condition":"rain"}<tool_response|>' +
235
+ '\n<turn|>';
236
+ expect(prompt).toContain(expectedFragment);
237
+
238
+ // Negative: a separate user turn wrapping a tool_response is a spec violation.
239
+ expect(prompt).not.toMatch(/<\|turn>user\n<\|tool_response>/);
240
+ });
241
+
242
+ // Spec §10.1 byte-exact golden: full prompt (system + user + model with
243
+ // call+response + open model). Asserts the full structure byte-for-byte.
244
+ // EXPECTED TO FAIL until the structural bug above is fixed.
245
+ it('§10.1 golden byte-exact full prompt', () => {
246
+ const systemPrompt =
247
+ 'You are a helpful weather assistant.\n\n' +
248
+ '<|tool>declaration:get_weather{description:<|"|>Get weather for a city<|"|>,parameters:{properties:{city:{type:<|"|>STRING<|"|>,description:<|"|>City name<|"|>}},required:[<|"|>city<|"|>],type:<|"|>OBJECT<|"|>}}<tool|>';
249
+ const messages: ChatMessage[] = [
250
+ { role: 'user', content: 'Quel temps à Paris ?' },
251
+ {
252
+ role: 'assistant',
253
+ content: [
254
+ { type: 'tool_use', id: 'tu1', name: 'get_weather', input: { city: 'Paris' } },
255
+ ],
256
+ },
257
+ {
258
+ role: 'user',
259
+ content: [
260
+ { type: 'tool_result', tool_use_id: 'tu1', content: '{"temp":12,"condition":"rain"}' },
261
+ ],
262
+ },
263
+ ];
264
+ const prompt = buildGemmaPrompt({ systemPrompt, messages });
265
+
266
+ const expected =
267
+ `<|turn>system\n${systemPrompt}\n<turn|>\n` +
268
+ '<|turn>user\nQuel temps à Paris ?<turn|>\n' +
269
+ '<|turn>model\n' +
270
+ '<|tool_call>call:get_weather{city:<|"|>Paris<|"|>}<tool_call|>' +
271
+ '<|tool_response>response:{"temp":12,"condition":"rain"}<tool_response|>' +
272
+ '\n<turn|>\n' +
273
+ '<|turn>model\n';
274
+
275
+ expect(prompt).toBe(expected);
276
+ });
277
+
278
+ // Spec §10.7: unknown roles are not defined by the format. The safe rule is
279
+ // "map to system/user/model client-side". Current code coerces any non-
280
+ // "assistant" role to "user". For a raw `role: 'tool'` message (hypothetical
281
+ // — clients should not emit this given §4), coercing to "user" is the
282
+ // least-dangerous fallback. We document the current behaviour here.
283
+ it('exotic role "tool" on a top-level message falls back to user turn (§10.7, defensive)', () => {
284
+ const messages = [{ role: 'tool', content: 'T' }] as unknown as ChatMessage[];
285
+ const prompt = buildGemmaPrompt({ messages });
286
+ expect(prompt).toContain('<|turn>user\nT<turn|>');
287
+ });
288
+
289
+ // Spec §4: role "system" in a per-message context should map to system.
290
+ // Current code coerces anything ≠ "assistant" to "user", so a message with
291
+ // role "system" produces a user turn. This is a minor divergence from §4
292
+ // (the system block should be rendered via the `systemPrompt` input anyway,
293
+ // not as a message) — test is EXPECTED TO FAIL if we strictly enforce §4.
294
+ it('role "system" in a message should map to <|turn>system (§4)', () => {
295
+ const messages = [{ role: 'system', content: 'SYS-AS-MSG' }] as unknown as ChatMessage[];
296
+ const prompt = buildGemmaPrompt({ messages });
297
+ expect(prompt).toContain('<|turn>system\nSYS-AS-MSG<turn|>');
298
+ });
299
+ });
300
+
301
+ // ─────────────────────────────────────────────────────────────────────────────
302
+ // §9 — Hallucinated-token stripping
303
+ // ─────────────────────────────────────────────────────────────────────────────
304
+ // Mirror of the regex chain applied in WasmProvider._chat before parsing.
305
+ function stripHallucinatedTokens(s: string): string {
306
+ return s
307
+ .replace(/<\|tool_response>[\s\S]*?<tool_response\|>/g, '')
308
+ .replace(/<\|channel>thought[\s\S]*?<channel\|>/g, '')
309
+ .replace(/<\|think\|>/g, '');
310
+ }
311
+
312
+ // ─────────────────────────────────────────────────────────────────────────────
313
+ // parseGemmaArgs — nested & mixed syntax (§6)
314
+ // ─────────────────────────────────────────────────────────────────────────────
315
+ describe('parseGemmaArgs — nested & mixed syntax', () => {
316
+ const parse = (raw: string): Record<string, unknown> =>
317
+ (WasmProvider as unknown as { parseGemmaArgs: (r: string) => Record<string, unknown> })
318
+ .parseGemmaArgs ? (WasmProvider as any).parseGemmaArgs(raw) : (() => { throw new Error('no'); })();
319
+
320
+ it('pure Gemma native, nested object + array of arrays', () => {
321
+ const raw = '{name:<|"|>kv<|"|>,params:{title:<|"|>x<|"|>,rows:[[<|"|>a<|"|>,<|"|>b<|"|>]]}}';
322
+ expect(parse(raw)).toEqual({
323
+ name: 'kv',
324
+ params: { title: 'x', rows: [['a', 'b']] },
325
+ });
326
+ });
327
+
328
+ it('mixed native + JSON-quoted string with raw newlines, backticks, ${} and apostrophes', () => {
329
+ const code =
330
+ "```js\ndocument.getElementById('out').textContent = (function(){\n" +
331
+ " const prefix = 'EXEC-HUMMINGBIRD-H2947';\n" +
332
+ " return `${prefix}`;\n" +
333
+ "})();\n```";
334
+ // Gemma emits this with a real double-quoted string containing raw newlines.
335
+ const raw = '{name:<|"|>js-sandbox<|"|>,params:{code:' + JSON.stringify(code).replace(/\\n/g, '\n') + '}}';
336
+ const got = parse(raw);
337
+ expect(got.name).toBe('js-sandbox');
338
+ expect((got.params as Record<string, unknown>).code).toBe(code);
339
+ });
340
+
341
+ it('array of Gemma native strings', () => {
342
+ expect(parse('{tags:[<|"|>a<|"|>,<|"|>b<|"|>,<|"|>c<|"|>]}'))
343
+ .toEqual({ tags: ['a', 'b', 'c'] });
344
+ });
345
+
346
+ it('empty object {} returns {}', () => {
347
+ expect(parse('{}')).toEqual({});
348
+ });
349
+
350
+ it('malformed input returns {} (safe fallback)', () => {
351
+ expect(parse('{name:<|"|>unterminated')).toEqual({});
352
+ expect(parse('not an object at all')).toEqual({});
353
+ expect(parse('{name:<|"|>x<|"|>,broken:')).toEqual({});
354
+ });
355
+
356
+ it('numbers, booleans, null are preserved', () => {
357
+ expect(parse('{n:42,f:-1.5,t:true,fa:false,z:null}'))
358
+ .toEqual({ n: 42, f: -1.5, t: true, fa: false, z: null });
359
+ });
360
+
361
+ // Bug fix: Gemma recopies `\n` from tool-result JSON into its <|"|>…<|"|>
362
+ // string args. Without decoding, the backslash+n survive as two chars and
363
+ // later get re-escaped to `\\n` by JSON.stringify, reaching the sandbox as
364
+ // literal text instead of real newlines. These tests freeze the decoding
365
+ // contract: standard escapes (\n \t \r \" \\) become their real characters.
366
+ it('decodes \\n inside <|"|>…<|"|> to a real newline', () => {
367
+ const got = parse('{code:<|"|>a\\nb<|"|>}');
368
+ expect(got.code).toBe('a\nb');
369
+ });
370
+
371
+ it('preserves a literal backslash written as \\\\n', () => {
372
+ const got = parse('{code:<|"|>a\\\\nb<|"|>}');
373
+ expect(got.code).toBe('a\\nb');
374
+ });
375
+
376
+ it('decodes \\" inside <|"|>…<|"|> to a real double-quote', () => {
377
+ const got = parse('{msg:<|"|>say \\"hi\\"<|"|>}');
378
+ expect(got.msg).toBe('say "hi"');
379
+ });
380
+ });
381
+
382
+ describe('stripHallucinatedTokens — spec §9', () => {
383
+ it('removes hallucinated <|tool_response>...<tool_response|> (§9, §10.6)', () => {
384
+ expect(stripHallucinatedTokens('hello<|tool_response>response:x{}<tool_response|>world'))
385
+ .toBe('helloworld');
386
+ });
387
+
388
+ it('removes ghost <|channel>thought...<channel|> (§8, §9)', () => {
389
+ expect(stripHallucinatedTokens('a<|channel>thought\nreasoning\n<channel|>b')).toBe('ab');
390
+ });
391
+
392
+ it('removes stray <|think|> markers (§9)', () => {
393
+ expect(stripHallucinatedTokens('before<|think|>after')).toBe('beforeafter');
394
+ });
395
+
396
+ it('handles all three hallucinations in one string (§9)', () => {
397
+ const input =
398
+ '<|think|>x<|channel>thought\ny\n<channel|>z' +
399
+ '<|tool_response>response:a{}<tool_response|>';
400
+ expect(stripHallucinatedTokens(input)).toBe('xz');
401
+ });
402
+ });
403
+
404
+ // ─────────────────────────────────────────────────────────────────────────────
405
+ // extractToolCalls — noise tolerance between args and <tool_call|>
406
+ // ─────────────────────────────────────────────────────────────────────────────
407
+ // Bug fix: Gemma sometimes hallucinates extra `}` (or trailing whitespace /
408
+ // commas) between the balanced args block and the `<tool_call|>` closing tag.
409
+ // Without tolerance, the strict scanner rejected the call silently — no
410
+ // tool_use was produced and the widget never rendered. extractArgsBlock itself
411
+ // must stay strict on internal brace balancing; only AFTER the balanced block
412
+ // do we skip noise chars (`}`, ` `, `\n`, `\r`, `\t`, `,`).
413
+ describe('extractToolCalls — noise tolerance before <tool_call|>', () => {
414
+ const scan = (text: string) => WasmProvider.extractToolCalls(text);
415
+
416
+ it('accepts a call with one stray `}` before <tool_call|>', () => {
417
+ const txt =
418
+ '<|tool_call>call:widget_display' +
419
+ '{name:<|"|>kv<|"|>,params:{rows:[[<|"|>a<|"|>]]},title:<|"|>foo<|"|>}' +
420
+ '}<tool_call|>';
421
+ const calls = scan(txt);
422
+ expect(calls).toHaveLength(1);
423
+ expect(calls[0].name).toBe('widget_display');
424
+ expect(calls[0].argsBlock).toBe(
425
+ '{name:<|"|>kv<|"|>,params:{rows:[[<|"|>a<|"|>]]},title:<|"|>foo<|"|>}',
426
+ );
427
+ });
428
+
429
+ it('accepts multiple stray `}` before <tool_call|>', () => {
430
+ const txt =
431
+ '<|tool_call>call:widget_display' +
432
+ '{name:<|"|>kv<|"|>}' +
433
+ '}}}<tool_call|>';
434
+ const calls = scan(txt);
435
+ expect(calls).toHaveLength(1);
436
+ expect(calls[0].name).toBe('widget_display');
437
+ });
438
+
439
+ it('accepts stray `}` mixed with whitespace before <tool_call|>', () => {
440
+ const txt =
441
+ '<|tool_call>call:widget_display' +
442
+ '{name:<|"|>kv<|"|>}' +
443
+ '}}\n <tool_call|>';
444
+ const calls = scan(txt);
445
+ expect(calls).toHaveLength(1);
446
+ expect(calls[0].name).toBe('widget_display');
447
+ });
448
+
449
+ it('clean call (no noise) is parsed as before — no regression', () => {
450
+ const txt =
451
+ '<|tool_call>call:widget_display' +
452
+ '{name:<|"|>kv<|"|>,params:{rows:[[<|"|>a<|"|>]]}}' +
453
+ '<tool_call|>';
454
+ const calls = scan(txt);
455
+ expect(calls).toHaveLength(1);
456
+ expect(calls[0].name).toBe('widget_display');
457
+ expect(calls[0].argsBlock).toBe(
458
+ '{name:<|"|>kv<|"|>,params:{rows:[[<|"|>a<|"|>]]}}',
459
+ );
460
+ });
461
+
462
+ it('rejects calls with real non-noise content between args and <tool_call|>', () => {
463
+ // Between the balanced args block and <tool_call|> there is `{"extra":"foo"}`
464
+ // — that is not noise; the scanner must still reject (no false positive).
465
+ const txt =
466
+ '<|tool_call>call:widget_display' +
467
+ '{name:<|"|>kv<|"|>}' +
468
+ '{<|"|>extra<|"|>:<|"|>foo<|"|>}<tool_call|>';
469
+ const calls = scan(txt);
470
+ expect(calls).toHaveLength(0);
471
+ });
472
+ });
@@ -10,9 +10,9 @@ const TOOLS: McpToolDef[] = [
10
10
  describe('buildSystemPrompt', () => {
11
11
  it('returns concise behavioral prompt', () => {
12
12
  const prompt = buildSystemPrompt([]);
13
- expect(prompt).toContain('assistant IA');
14
- expect(prompt).toContain('images');
15
- expect(prompt.length).toBeLessThan(2000); // procedural prompt with recipe workflow
13
+ expect(prompt).toContain('FLEX');
14
+ expect(prompt).toContain('recipes');
15
+ expect(prompt.length).toBeLessThan(4000); // procedural prompt with recipe workflow
16
16
  });
17
17
  });
18
18
 
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { serializeMessagesForTemplate } from '../src/providers/transformers-serialize.js';
3
+ import type { ChatMessage } from '../src/types.js';
4
+
5
+ // Regression coverage for the commit e47874c migration: Qwen/Mistral prompts
6
+ // now flow through tokenizer.apply_chat_template, and the serializer maps
7
+ // ChatMessage[] tool_use / tool_result blocks into the wire spans each family
8
+ // expects (ChatML <tool_call>/<tool_response> for Qwen, [TOOL_CALLS]/
9
+ // [TOOL_RESULTS] for Mistral).
10
+
11
+ describe('serializeMessagesForTemplate — promptKind="qwen"', () => {
12
+ it('(1) plain user text turn passes through verbatim', () => {
13
+ const msgs: ChatMessage[] = [
14
+ { role: 'user', content: 'hello' },
15
+ ];
16
+ const out = serializeMessagesForTemplate(msgs, 'qwen');
17
+ expect(out).toEqual([{ role: 'user', content: 'hello' }]);
18
+ });
19
+
20
+ it('(2) assistant tool_use renders as <tool_call>…</tool_call>', () => {
21
+ const msgs: ChatMessage[] = [
22
+ {
23
+ role: 'assistant',
24
+ content: [
25
+ { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'recipes' } },
26
+ ],
27
+ },
28
+ ];
29
+ const out = serializeMessagesForTemplate(msgs, 'qwen');
30
+ expect(out).toHaveLength(1);
31
+ expect(out[0].role).toBe('assistant');
32
+ expect(out[0].content).toBe(
33
+ '<tool_call>\n{"name":"search","arguments":{"q":"recipes"}}\n</tool_call>',
34
+ );
35
+ });
36
+
37
+ it('(3) user turn with only tool_result is promoted to role "tool" and wrapped in <tool_response>', () => {
38
+ const msgs: ChatMessage[] = [
39
+ {
40
+ role: 'user',
41
+ content: [
42
+ { type: 'tool_result', tool_use_id: 'call_1', content: '{"hits":3}' },
43
+ ],
44
+ },
45
+ ];
46
+ const out = serializeMessagesForTemplate(msgs, 'qwen');
47
+ expect(out).toHaveLength(1);
48
+ expect(out[0].role).toBe('tool');
49
+ expect(out[0].content).toBe('<tool_response>\n{"hits":3}\n</tool_response>');
50
+ });
51
+
52
+ it('(4) mixed text + tool_result in a user turn keeps role "user" and joins segments', () => {
53
+ const msgs: ChatMessage[] = [
54
+ {
55
+ role: 'user',
56
+ content: [
57
+ { type: 'text', text: 'Here you go:' },
58
+ { type: 'tool_result', tool_use_id: 'call_1', content: '{"ok":true}' },
59
+ ],
60
+ },
61
+ ];
62
+ const out = serializeMessagesForTemplate(msgs, 'qwen');
63
+ expect(out).toHaveLength(1);
64
+ expect(out[0].role).toBe('user');
65
+ expect(out[0].content).toBe(
66
+ 'Here you go:\n<tool_response>\n{"ok":true}\n</tool_response>',
67
+ );
68
+ });
69
+ });
70
+
71
+ describe('serializeMessagesForTemplate — promptKind="mistral"', () => {
72
+ it('(5) assistant tool_use renders as [TOOL_CALLS][…]', () => {
73
+ const msgs: ChatMessage[] = [
74
+ {
75
+ role: 'assistant',
76
+ content: [
77
+ { type: 'tool_use', id: 'call_1', name: 'search', input: { q: 'recipes' } },
78
+ ],
79
+ },
80
+ ];
81
+ const out = serializeMessagesForTemplate(msgs, 'mistral');
82
+ expect(out).toHaveLength(1);
83
+ expect(out[0].role).toBe('assistant');
84
+ expect(out[0].content).toBe(
85
+ '[TOOL_CALLS][{"name":"search","arguments":{"q":"recipes"}}]',
86
+ );
87
+ });
88
+
89
+ it('(6) user turn with only tool_result keeps role "user" and wraps in [TOOL_RESULTS] … [/TOOL_RESULTS]', () => {
90
+ const msgs: ChatMessage[] = [
91
+ {
92
+ role: 'user',
93
+ content: [
94
+ { type: 'tool_result', tool_use_id: 'call_1', content: '{"hits":3}' },
95
+ ],
96
+ },
97
+ ];
98
+ const out = serializeMessagesForTemplate(msgs, 'mistral');
99
+ expect(out).toHaveLength(1);
100
+ expect(out[0].role).toBe('user');
101
+ expect(out[0].content).toBe('[TOOL_RESULTS] {"hits":3} [/TOOL_RESULTS]');
102
+ });
103
+ });