// v1.13.16: covers the Qwen/Hermes parser, the new Anthropic // parser, the partial-opener detector for both flavors, the unified // extraction helper, and the unknown-tool error formatter that downstream // dispatch uses to give the model a recovery hint when it drifts to a // Claude Code tool name like read_file instead of BooCode's view_file. import { describe, expect, it } from 'vitest'; import { parseXmlToolCall, parseInvokeToolCall, partialXmlOpenerStart, extractToolCallBlocks, XML_TOOL_OPEN, XML_TOOL_CLOSE, INVOKE_TOOL_OPEN, INVOKE_TOOL_CLOSE, } from '../inference/xml-parser.js'; import { levenshtein, suggestToolName, formatUnknownToolError, } from '../inference/tool-suggestions.js'; describe('parseXmlToolCall (Qwen/Hermes )', () => { it('parses a well-formed single-parameter call', () => { const block = '/tmp/foo'; expect(parseXmlToolCall(block)).toEqual({ name: 'view_file', args: { path: '/tmp/foo' }, }); }); it('parses multi-parameter call', () => { const block = 'foosrc/'; expect(parseXmlToolCall(block)).toEqual({ name: 'grep', args: { pattern: 'foo', path: 'src/' }, }); }); it('JSON-parses numeric parameter values', () => { const block = '42'; expect(parseXmlToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } }); }); it('tolerates whitespace around = in function (v1.13.16 tightening)', () => { const block = '/tmp/foo'; expect(parseXmlToolCall(block)).toEqual({ name: 'view_file', args: { path: '/tmp/foo' }, }); }); it('tolerates whitespace around = in parameter (v1.13.16 tightening)', () => { const block = '/tmp/foo'; expect(parseXmlToolCall(block)).toEqual({ name: 'view_file', args: { path: '/tmp/foo' }, }); }); it('returns null when function name is missing', () => { const block = '/tmp/foo'; expect(parseXmlToolCall(block)).toBeNull(); }); }); describe('parseInvokeToolCall (Anthropic ) — v1.13.16', () => { // Spec case 1 it('parses a well-formed single-parameter call (spec case 1)', () => { const block = '/tmp/foo'; expect(parseInvokeToolCall(block)).toEqual({ name: 'view_file', args: { path: '/tmp/foo' }, }); }); // Spec case 2 it('parses a multi-parameter call (spec case 2)', () => { const block = 'foosrc/'; expect(parseInvokeToolCall(block)).toEqual({ name: 'grep', args: { pattern: 'foo', path: 'src/' }, }); }); // Spec case 3 it('tolerates newlines and spaces in attributes (spec case 3)', () => { const block = ` /tmp/foo `; expect(parseInvokeToolCall(block)).toEqual({ name: 'view_file', args: { path: '/tmp/foo' }, }); }); // Spec case 4 (parser portion — the not-found enrichment is tested below) it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => { const block = '/tmp/foo'; expect(parseInvokeToolCall(block)).toEqual({ name: 'read_file', args: { path: '/tmp/foo' }, }); }); it('supports single-quoted attribute values', () => { const block = "/tmp/foo"; expect(parseInvokeToolCall(block)).toEqual({ name: 'view_file', args: { path: '/tmp/foo' }, }); }); it('JSON-parses numeric parameter values', () => { const block = '42'; expect(parseInvokeToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } }); }); it('tolerates spaces around = inside name attribute', () => { const block = '/tmp/foo'; expect(parseInvokeToolCall(block)).toEqual({ name: 'view_file', args: { path: '/tmp/foo' }, }); }); it('returns null when name attribute is missing', () => { const block = '/tmp/foo'; expect(parseInvokeToolCall(block)).toBeNull(); }); it('returns null when name attribute is empty', () => { const block = '/tmp/foo'; expect(parseInvokeToolCall(block)).toBeNull(); }); it('exports the expected delimiters', () => { expect(INVOKE_TOOL_OPEN).toBe(''); expect(XML_TOOL_OPEN).toBe(''); expect(XML_TOOL_CLOSE).toBe(''); }); }); describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => { it('returns -1 when the buffer is empty', () => { expect(partialXmlOpenerStart('')).toBe(-1); }); it('returns -1 when the buffer has no openers', () => { expect(partialXmlOpenerStart('plain prose, no markup')).toBe(-1); }); it('returns the index of a complete opener (existing)', () => { expect(partialXmlOpenerStart('prose more')).toBe(6); }); it('returns the index of a complete { expect(partialXmlOpenerStart('prose { expect(partialXmlOpenerStart('text { expect(partialXmlOpenerStart('text { expect(partialXmlOpenerStart('text <')).toBe(5); }); it('returns -1 when < is followed by non-opener text', () => { expect(partialXmlOpenerStart('text ')).toBe(-1); }); it('returns the earliest opener when both flavors are present', () => { expect(partialXmlOpenerStart('xxx YYY ')).toBe(4); expect(partialXmlOpenerStart('xxx YYY ')).toBe(4); }); }); describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => { // Spec case 1 (extraction-level) it('extracts a single block (spec case 1)', () => { const input = '/tmp/foo'; const result = extractToolCallBlocks(input); expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]); expect(result.flushed).toBe(''); expect(result.remaining).toBe(''); }); // Spec case 5: opener arrives in one chunk, closer in the next. it('holds the partial chunk when the closer has not arrived (spec case 5, first chunk)', () => { const firstChunk = '/tmp/foo'; const result = extractToolCallBlocks(firstChunk); expect(result.calls).toEqual([]); expect(result.flushed).toBe(''); expect(result.remaining).toBe(firstChunk); }); it('extracts the block once the closer arrives in a later chunk (spec case 5, completion)', () => { const firstChunk = '/tmp/foo'; const r1 = extractToolCallBlocks(firstChunk); const combined = r1.remaining + ''; const r2 = extractToolCallBlocks(combined); expect(r2.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]); expect(r2.flushed).toBe(''); expect(r2.remaining).toBe(''); }); // Spec case 6: prose interleaving it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => { const input = 'I will read the file.\n/tmp/foo\nThanks.'; const result = extractToolCallBlocks(input); expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]); expect(result.flushed).toBe('I will read the file.\n\nThanks.'); expect(result.remaining).toBe(''); }); // Spec case 7 regression it('extracts a Qwen block alongside the new code path (spec case 7 regression)', () => { const input = '/tmp/foo'; const result = extractToolCallBlocks(input); expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]); expect(result.flushed).toBe(''); expect(result.remaining).toBe(''); }); it('extracts mixed-format blocks in source order (hand-back: shared counter)', () => { const input = '/a' + ' middle ' + 'foo'; const result = extractToolCallBlocks(input); expect(result.calls).toEqual([ { name: 'view_file', args: { path: '/a' } }, { name: 'grep', args: { pattern: 'foo' } }, ]); expect(result.flushed).toBe(' middle '); expect(result.remaining).toBe(''); }); it('drops a malformed block silently (matches existing behavior)', () => { const input = 'prose /a trailing'; const result = extractToolCallBlocks(input); expect(result.calls).toEqual([]); expect(result.flushed).toBe('prose trailing'); expect(result.remaining).toBe(''); }); it('holds a tail with a fresh partial opener after extracting earlier complete blocks', () => { const input = '/a next: { const input = 'just some text with a < character but no opener'; const result = extractToolCallBlocks(input); expect(result.calls).toEqual([]); expect(result.flushed).toBe(input); expect(result.remaining).toBe(''); }); }); describe('levenshtein', () => { it('returns 0 for identical strings', () => { expect(levenshtein('view_file', 'view_file')).toBe(0); }); it('returns the length when one string is empty', () => { expect(levenshtein('', 'view_file')).toBe(9); expect(levenshtein('view_file', '')).toBe(9); }); it('computes a small distance for a single-character substitution', () => { expect(levenshtein('cat', 'bat')).toBe(1); }); it('computes a known case: read_file → view_file is 4', () => { // r→v, e→i, a→e, d→w → 4 substitutions, same length expect(levenshtein('read_file', 'view_file')).toBe(4); }); }); describe('suggestToolName (v1.13.16)', () => { const tools = [ 'view_file', 'list_dir', 'grep', 'find_files', 'view_truncated_output', 'ask_user_input', 'web_search', ]; it('suggests the closest match when distance is small', () => { expect(suggestToolName('view_files', tools)).toBe('view_file'); }); it('suggests via substring match when distance alone would miss', () => { // 'file' is a substring of multiple tools; closest by distance wins. expect(suggestToolName('file', tools)).toBe('view_file'); }); it('returns null when nothing is close', () => { expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull(); }); it('is case-insensitive in the distance check', () => { expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file'); }); }); describe('formatUnknownToolError (v1.13.16)', () => { const tools = ['view_file', 'list_dir', 'grep', 'find_files']; it('includes the wrong name and the available tools list', () => { const msg = formatUnknownToolError('read_file', tools); expect(msg).toContain("Tool 'read_file' not found"); expect(msg).toContain('Available tools:'); expect(msg).toContain('view_file'); expect(msg).toContain('find_files'); }); it('includes a suggestion when the drifted name is within threshold', () => { // distance(view_files, view_file) = 1 (one extra char) const msg = formatUnknownToolError('view_files', tools); expect(msg).toContain('Did you mean: view_file?'); }); it('omits the suggestion clause when no tool is close enough', () => { const msg = formatUnknownToolError('zzzzzzz', tools); expect(msg).toContain("Tool 'zzzzzzz' not found"); expect(msg).toContain('Available tools:'); expect(msg).not.toContain('Did you mean'); }); // The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the // model emit . lev(read_file, view_file) = 4, so // the spec's threshold (<=3) doesn't suggest view_file — the model still // gets the available-tools list to pick from. This pins that behavior so a // future loosening of the threshold is a deliberate choice. it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => { const msg = formatUnknownToolError('read_file', tools); expect(msg).not.toContain('Did you mean'); }); });