/** * Unit tests for `toolCallNormalization`. * * Exercises every resolution branch of `normalizeToolCallName` and * `normalizeMessageToolCalls` so future edits can't silently regress the * fault-tolerance guarantees the downstream ToolNode relies on. */ import { normalizeToolCallName, normalizeMessageToolCalls, } from '../toolCallNormalization'; const allowed = new Set([ 'outlook_operations', 'teams_operations', 'sharepoint_operations', 'person_lookup', ]); describe('normalizeToolCallName', () => { describe('exact match fast path', () => { it('returns the name unchanged when it matches exactly', () => { expect(normalizeToolCallName('outlook_operations', allowed)).toBe( 'outlook_operations' ); }); }); describe('delimiter normalization', () => { it('maps slash delimiters to underscore', () => { expect(normalizeToolCallName('outlook/operations', allowed)).toBe( 'outlook_operations' ); }); it('maps dot delimiters to underscore', () => { expect(normalizeToolCallName('outlook.operations', allowed)).toBe( 'outlook_operations' ); }); it('maps dash delimiters to underscore', () => { expect(normalizeToolCallName('outlook-operations', allowed)).toBe( 'outlook_operations' ); }); it('returns the trimmed input when delimiter-normalized form is not registered', () => { // `outlook_operations_v2` is not in the allowed set, so the resolver // leaves the name untouched rather than guessing which variant the // model meant. expect(normalizeToolCallName('outlook.operations-v2', allowed)).toBe( 'outlook.operations-v2' ); }); }); describe('case folding', () => { it('resolves camelCase to registered snake_case', () => { expect(normalizeToolCallName('Outlook_Operations', allowed)).toBe( 'outlook_operations' ); }); it('resolves SCREAMING_CASE', () => { expect(normalizeToolCallName('OUTLOOK_OPERATIONS', allowed)).toBe( 'outlook_operations' ); }); }); describe('structured candidates — prefix stripping', () => { it('strips functions. prefix', () => { expect( normalizeToolCallName('functions.outlook_operations', allowed) ).toBe('outlook_operations'); }); it('strips tools. prefix', () => { expect(normalizeToolCallName('tools.teams_operations', allowed)).toBe( 'teams_operations' ); }); it('takes suffix segment when dotted path has multiple segments', () => { expect( normalizeToolCallName('namespace.group.person_lookup', allowed) ).toBe('person_lookup'); }); }); describe('infer from tool_call id when name is empty', () => { it('recovers name from an id containing the tool name', () => { expect( normalizeToolCallName('', allowed, 'call_outlook_operations_42') ).toBe('outlook_operations'); }); it('strips counter suffix from id', () => { expect(normalizeToolCallName('', allowed, 'outlook_operations_1')).toBe( 'outlook_operations' ); }); it('returns empty string when id is also absent', () => { expect(normalizeToolCallName('', allowed, undefined)).toBe(''); }); }); describe('fail-safe behavior', () => { it('returns the trimmed input when no match is possible', () => { expect(normalizeToolCallName('totally_unknown_tool', allowed)).toBe( 'totally_unknown_tool' ); }); it('returns the trimmed input when allowed set is empty', () => { expect(normalizeToolCallName('outlook_operations', new Set())).toBe( 'outlook_operations' ); }); it('fails closed when case-insensitive match is ambiguous', () => { const ambiguous = new Set(['Tool', 'tool']); // Two allowed names fold to the same lowercase — resolver must not // guess. It returns the input unchanged (via structured fallthrough). const out = normalizeToolCallName('TOOL', ambiguous); expect(out).toBe('TOOL'); }); }); }); describe('normalizeMessageToolCalls', () => { it('rewrites LangChain-style tool_calls in place', () => { const msg = { tool_calls: [ { name: 'functions.outlook_operations', id: 'call_1' }, { name: 'Teams_Operations', id: 'call_2' }, ], }; const changed = normalizeMessageToolCalls(msg, allowed); expect(changed).toBe(true); expect(msg.tool_calls[0].name).toBe('outlook_operations'); expect(msg.tool_calls[1].name).toBe('teams_operations'); }); it('rewrites Anthropic-style tool_use content blocks in place', () => { const msg = { content: [ { type: 'text', text: 'ok' }, { type: 'tool_use', name: 'outlook/operations', id: 'toolu_1' }, ], }; const changed = normalizeMessageToolCalls(msg, allowed); expect(changed).toBe(true); const toolBlock = msg.content[1] as { name?: string }; expect(toolBlock.name).toBe('outlook_operations'); }); it('returns false when nothing needed rewriting', () => { const msg = { tool_calls: [{ name: 'outlook_operations', id: 'call_1' }], }; expect(normalizeMessageToolCalls(msg, allowed)).toBe(false); }); it('is a no-op for non-message objects', () => { expect(normalizeMessageToolCalls(null, allowed)).toBe(false); expect(normalizeMessageToolCalls(undefined, allowed)).toBe(false); expect(normalizeMessageToolCalls('string', allowed)).toBe(false); }); it('handles empty name by inferring from id', () => { const msg = { tool_calls: [{ name: '', id: 'call_person_lookup_99' }], }; const changed = normalizeMessageToolCalls(msg, allowed); expect(changed).toBe(true); expect(msg.tool_calls[0].name).toBe('person_lookup'); }); });