import { describe, expect, it } from 'vitest'; import { parseXmlToolCall, parseInvokeToolCall, partialXmlOpenerStart, extractToolCallBlocks, stripToolMarkup, XML_TOOL_OPEN, XML_TOOL_CLOSE, INVOKE_TOOL_OPEN, INVOKE_TOOL_CLOSE, } from '../inference/tool-call-parser.js'; // ── Ported from xml-parser.test.ts ─────────────────────────────────────── 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', () => { 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' }, }); }); it('parses a multi-parameter call (spec case 2)', () => { const block = 'foosrc/'; expect(parseInvokeToolCall(block)).toEqual({ name: 'grep', args: { pattern: 'foo', path: 'src/' }, }); }); 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' }, }); }); 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)', () => { 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(''); }); 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(''); }); 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(''); }); 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('placeholder arg rejection (qwen3.6 answer-then-spurious-tools)', () => { it('rejects with path "..." — 0 calls, block in flushed', () => { const block = '...'; const result = extractToolCallBlocks(`Answer text.\n${block}`); expect(result.calls).toEqual([]); expect(result.flushed).toContain('Answer text.'); expect(result.flushed).toContain(block); expect(result.remaining).toBe(''); }); it('rejects with empty path — 0 calls, block in flushed', () => { const block = ''; const result = extractToolCallBlocks(block); expect(result.calls).toEqual([]); expect(result.flushed).toBe(block); expect(result.remaining).toBe(''); }); it('rejects with path "" — 0 calls', () => { const block = ''; const result = extractToolCallBlocks(block); expect(result.calls).toEqual([]); expect(result.flushed).toBe(block); }); it('returns 1 valid call and flushes placeholder block when mixed in same buffer', () => { const valid = '/opt/boocode/README.md'; const placeholder = '...'; const result = extractToolCallBlocks(`${valid} tail ${placeholder}`); expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/opt/boocode/README.md' } }]); expect(result.flushed).toContain('tail'); expect(result.flushed).toContain(placeholder); expect(result.remaining).toBe(''); }); }); }); describe('stripToolMarkup', () => { it('strips closed blocks', () => { const input = 'before {"name":"x"} after'; expect(stripToolMarkup(input)).toBe('before after'); }); it('strips closed blocks', () => { const input = 'before z after'; expect(stripToolMarkup(input)).toBe('before after'); }); it('strips closed blocks', () => { const input = 'before z after'; expect(stripToolMarkup(input)).toBe('before after'); }); it('leaves trailing unclosed block when final=false', () => { const input = 'text {"name":"x"'; expect(stripToolMarkup(input)).toBe('text {"name":"x"'); }); it('strips trailing unclosed when final=true', () => { const input = 'text {"name":"x"'; expect(stripToolMarkup(input, { final: true })).toBe('text'); }); it('strips trailing unclosed { const input = 'text ls'; expect(stripToolMarkup(input, { final: true })).toBe('text'); }); it('strips trailing unclosed { const input = 'text val'; expect(stripToolMarkup(input, { final: true })).toBe('text'); }); it('trims whitespace when final=true', () => { const input = ' text partial'; expect(stripToolMarkup(input, { final: true })).toBe('text'); }); it('strips multiple closed blocks', () => { const input = 'a mid b'; expect(stripToolMarkup(input)).toBe(' mid '); }); }); describe('delimiter constants', () => { it('exports the expected delimiters', () => { expect(INVOKE_TOOL_OPEN).toBe(''); expect(XML_TOOL_OPEN).toBe(''); expect(XML_TOOL_CLOSE).toBe(''); }); });