import { describe, expect, it } from 'vitest'; import { parseXmlToolCall, parseInvokeToolCall, partialXmlOpenerStart, extractToolCallBlocks, parseToolCallsFromText, stripToolMarkup, hasToolSignal, XML_TOOL_OPEN, XML_TOOL_CLOSE, INVOKE_TOOL_OPEN, INVOKE_TOOL_CLOSE, TOOL_XML_SIGNALS, BUDGET_EXHAUSTED_NUDGE, DUPLICATE_CALL_NUDGE, TOOL_ERROR_NUDGE, TOOL_ERROR_PREFIXES, } 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(''); }); }); }); // ── New tests: Unsloth-ported functions ────────────────────────────────── describe('hasToolSignal', () => { it('returns true for ', () => { expect(hasToolSignal('prefix suffix')).toBe(true); }); it('returns true for { expect(hasToolSignal('prefix suffix')).toBe(true); }); it('returns true for { expect(hasToolSignal('prefix suffix')).toBe(true); }); it('returns false for near-miss ', () => { expect(hasToolSignal('prefix suffix')).toBe(false); }); it('returns false for near-miss ', () => { expect(hasToolSignal('prefix suffix')).toBe(false); }); it('returns false for near-miss ', () => { expect(hasToolSignal('')).toBe(false); }); it('returns false for plain text', () => { expect(hasToolSignal('just some text')).toBe(false); }); }); 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('parseToolCallsFromText', () => { describe('pattern 1: {json}', () => { it('parses a well-formed JSON tool call', () => { const input = '{"name":"web_search","arguments":{"query":"hello"}}'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.id).toBe('call_0'); expect(calls[0]!.type).toBe('function'); expect(calls[0]!.function.name).toBe('web_search'); expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ query: 'hello' }); }); it('handles string arguments field', () => { const input = '{"name":"x","arguments":"already a string"}'; const calls = parseToolCallsFromText(input); expect(calls[0]!.function.arguments).toBe('already a string'); }); it('handles balanced braces inside JSON strings', () => { const input = '{"name":"x","arguments":{"q":"} { extra "}}'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); const parsed = JSON.parse(calls[0]!.function.arguments); expect(parsed.q).toBe('} { extra '); }); it('respects idOffset', () => { const input = '{"name":"a","arguments":{}}'; const calls = parseToolCallsFromText(input, { idOffset: 5 }); expect(calls[0]!.id).toBe('call_5'); }); it('parses multiple JSON tool calls', () => { const input = '{"name":"a","arguments":{}}' + '{"name":"b","arguments":{}}'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(2); expect(calls[0]!.id).toBe('call_0'); expect(calls[1]!.id).toBe('call_1'); }); it('skips malformed JSON', () => { const input = '{not json}'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(0); }); it('handles missing closing tag', () => { const input = '{"name":"x","arguments":{"q":"hello"}}'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('x'); }); }); describe('pattern 2: value', () => { it('parses a single-parameter function call', () => { const input = '/tmp/foo'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('view_file'); expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); }); it('single-param fast path preserves embedded ', () => { const input = 'echo ""'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(JSON.parse(calls[0]!.function.arguments).command).toBe('echo ""'); }); it('multi-param: value of first stops at start of second', () => { const input = 'foosrc/'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); const args = JSON.parse(calls[0]!.function.arguments); expect(args.pattern).toBe('foo'); expect(args.path).toBe('src/'); }); it('tolerates missing closing tags', () => { const input = '/tmp/foo'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('view_file'); expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); }); it('does not fire when pattern 1 found results', () => { const input = '{"name":"a","arguments":{}}y'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('a'); }); }); describe('pattern 3: value (Anthropic)', () => { it('parses a single-parameter invoke call', () => { const input = '/tmp/foo'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('view_file'); expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); }); it('parses multi-parameter invoke call', () => { const input = 'foosrc/'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); const args = JSON.parse(calls[0]!.function.arguments); expect(args.pattern).toBe('foo'); expect(args.path).toBe('src/'); }); it('does not fire when pattern 1 found results', () => { const input = '{"name":"a","arguments":{}}y'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('a'); }); it('does not fire when pattern 2 found results', () => { const input = 'yy'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('a'); }); it('tolerates missing closing tags', () => { const input = '/tmp/foo'; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(JSON.parse(calls[0]!.function.arguments)).toEqual({ path: '/tmp/foo' }); }); it('supports single-quoted attributes', () => { const input = "/tmp/foo"; const calls = parseToolCallsFromText(input); expect(calls).toHaveLength(1); expect(calls[0]!.function.name).toBe('view_file'); }); }); }); describe('constants', () => { it('TOOL_XML_SIGNALS includes all three signal prefixes', () => { expect(TOOL_XML_SIGNALS).toContain(''); expect(TOOL_XML_SIGNALS).toContain(' { expect(BUDGET_EXHAUSTED_NUDGE.length).toBeGreaterThan(0); expect(DUPLICATE_CALL_NUDGE.length).toBeGreaterThan(0); expect(TOOL_ERROR_NUDGE.length).toBeGreaterThan(0); }); it('TOOL_ERROR_PREFIXES is a non-empty tuple', () => { expect(TOOL_ERROR_PREFIXES.length).toBeGreaterThan(0); expect(TOOL_ERROR_PREFIXES).toContain('Error'); }); });