import { describe, expect, it } from 'vitest';
import { Agent } from '../agent.js';
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js';
import { createMockTool } from '../../__fixtures__/tool-helpers.js';
import { Message, ToolResultBlock, TextBlock, ToolUseBlock } from '../../types/messages.js';
import { ConcurrentInvocationError, ToolNotFoundError } from '../../errors.js';
import { ToolStreamEvent } from '../../tools/tool.js';
describe('ToolCaller', () => {
    describe('basic tool calling via .invoke()', () => {
        it('calls a tool by name and returns the result', async () => {
            const tool = createMockTool('calculator', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('8')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            const result = await agent.tool.calculator.invoke({ a: 5, b: 3 });
            expect(result).toStrictEqual(new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('8')],
            }));
        });
        it('calls a tool with empty input when no input provided', async () => {
            const tool = createMockTool('ping', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('pong')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            const result = await agent.tool.ping.invoke();
            expect(result).toStrictEqual(new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('pong')],
            }));
        });
        it('throws when tool is not found', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [] });
            await expect(agent.tool.nonexistent.invoke()).rejects.toThrow(ToolNotFoundError);
            await expect(agent.tool.nonexistent.invoke()).rejects.toThrow("Tool 'nonexistent' not found");
        });
    });
    describe('underscore-to-hyphen normalization', () => {
        it('resolves underscore names to hyphenated tool names', async () => {
            const tool = createMockTool('my-tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('ok')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            const result = await agent.tool.my_tool.invoke();
            expect(result.status).toBe('success');
        });
        it('prefers exact name match over normalized match', async () => {
            const exactTool = createMockTool('my_tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('exact')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [exactTool] });
            const result = await agent.tool.my_tool.invoke();
            expect(result).toStrictEqual(new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('exact')],
            }));
        });
    });
    describe('case-insensitive name resolution', () => {
        it('resolves tool names case-insensitively', async () => {
            const tool = createMockTool('MyTool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('ok')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            const result = await agent.tool.mytool.invoke();
            expect(result.status).toBe('success');
        });
        it('prefers exact match over case-insensitive match', async () => {
            const exactTool = createMockTool('myTool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('exact')],
            }));
            const upperTool = createMockTool('MYTOOL', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('upper')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [exactTool, upperTool] });
            const result = await agent.tool.myTool.invoke();
            expect(result.content[0]).toStrictEqual(new TextBlock('exact'));
        });
    });
    describe('message history recording', () => {
        it('records tool call in message history by default', async () => {
            const tool = createMockTool('calculator', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('8')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            await agent.tool.calculator.invoke({ a: 5, b: 3 });
            // Per TESTING.md, prefer full-object assertions over per-field checks.
            // toolUseId is non-deterministic (UUID), so use expect.stringMatching.
            expect(agent.messages).toEqual([
                new Message({
                    role: 'assistant',
                    content: [
                        new ToolUseBlock({
                            toolUseId: expect.stringMatching(/^tooluse_/),
                            name: 'calculator',
                            input: { a: 5, b: 3 },
                        }),
                    ],
                }),
                new Message({
                    role: 'user',
                    content: [
                        new ToolResultBlock({
                            toolUseId: 'test-id',
                            status: 'success',
                            content: [new TextBlock('8')],
                        }),
                    ],
                }),
                new Message({
                    role: 'assistant',
                    content: [new TextBlock('agent.tool.calculator was called.')],
                }),
            ]);
        });
        it('does not record when recordDirectToolCall is false per-call', async () => {
            const tool = createMockTool('calculator', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('8')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            await agent.tool.calculator.invoke({ a: 5, b: 3 }, { recordDirectToolCall: false });
            expect(agent.messages).toHaveLength(0);
        });
        it('records when explicitly set to true per-call', async () => {
            const tool = createMockTool('calculator', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('8')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            await agent.tool.calculator.invoke({ a: 5, b: 3 }, { recordDirectToolCall: true });
            expect(agent.messages).toHaveLength(3);
        });
        it('records full input without filtering', async () => {
            const tool = createMockTool('my-tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('ok')],
            }));
            Object.defineProperty(tool, 'toolSpec', {
                value: {
                    name: 'my-tool',
                    description: 'Tool with strict schema',
                    inputSchema: {
                        type: 'object',
                        properties: {
                            allowed: { type: 'string' },
                        },
                    },
                },
            });
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            await agent.tool.my_tool.invoke({ allowed: 'yes', extra: 'also-recorded' });
            // Input is recorded as-is — no filtering
            const recToolUseBlock = agent.messages[0].content[0];
            expect(recToolUseBlock).toBeInstanceOf(ToolUseBlock);
            expect(recToolUseBlock.input).toStrictEqual({ allowed: 'yes', extra: 'also-recorded' });
        });
    });
    describe('concurrency protection', () => {
        it('throws ConcurrentInvocationError when agent is invoking and recording is enabled', async () => {
            const tool = createMockTool('slow-tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('done')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            // Simulate the agent being in the middle of an invocation by mocking isInvoking
            Object.defineProperty(agent, 'isInvoking', { get: () => true });
            await expect(agent.tool.slow_tool.invoke()).rejects.toThrow(ConcurrentInvocationError);
            await expect(agent.tool.slow_tool.invoke()).rejects.toThrow('Direct tool call cannot be made while the agent is in the middle of an invocation');
        });
        it('allows direct tool call during invocation when recordDirectToolCall is false', async () => {
            const tool = createMockTool('quick-tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('ok')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            // Simulate the agent being in the middle of an invocation
            Object.defineProperty(agent, 'isInvoking', { get: () => true });
            // Should NOT throw when recording is disabled
            const result = await agent.tool.quick_tool.invoke({}, { recordDirectToolCall: false });
            expect(result.status).toBe('success');
        });
        it('isInvoking is false on a fresh agent', () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model });
            expect(agent.isInvoking).toBe(false);
        });
    });
    describe('tool error handling', () => {
        it('propagates errors when tool throws', async () => {
            const throwingTool = createMockTool('thrower', () => {
                throw new Error('Boom!');
            });
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [throwingTool] });
            await expect(agent.tool.thrower.invoke()).rejects.toThrow('Boom!');
        });
    });
    describe('agent.tool accessor', () => {
        it('is accessible as a property', () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model });
            expect(agent.tool).toBeDefined();
        });
        it('returns same instance on multiple accesses', () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model });
            expect(agent.tool).toBe(agent.tool);
        });
        it('returns a ToolHandle with invoke and stream methods', () => {
            const tool = createMockTool('calculator', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('ok')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            const handle = agent.tool.calculator;
            expect(typeof handle.invoke).toBe('function');
            expect(typeof handle.stream).toBe('function');
        });
    });
    describe('tool use ID generation', () => {
        it('generates unique tool use IDs using crypto.randomUUID', async () => {
            const tool = createMockTool('id-tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('ok')],
            }));
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            await agent.tool.id_tool.invoke();
            await agent.tool.id_tool.invoke();
            // Each call records 3 messages: [0]=assistant(toolUse), [1]=user(toolResult), [2]=assistant(ack)
            // Second call: [3]=assistant(toolUse), [4]=user(toolResult), [5]=assistant(ack)
            expect(agent.messages).toHaveLength(6);
            const toolUse1 = agent.messages[0].content[0];
            const toolUse2 = agent.messages[3].content[0];
            // Verify both are ToolUseBlocks at the correct indices
            expect(toolUse1).toBeInstanceOf(ToolUseBlock);
            expect(toolUse2).toBeInstanceOf(ToolUseBlock);
            // Verify IDs are unique and follow the expected format
            expect(toolUse1.toolUseId).toMatch(/^tooluse_/);
            expect(toolUse2.toolUseId).toMatch(/^tooluse_/);
            expect(toolUse1.toolUseId).not.toBe(toolUse2.toolUseId);
        });
    });
    describe('streaming via .stream()', () => {
        it('yields intermediate events and returns final result', async () => {
            const yields = [];
            const streamingTool = {
                name: 'streamer',
                description: 'A tool that yields progress events',
                toolSpec: {
                    name: 'streamer',
                    description: 'A tool that yields progress events',
                    inputSchema: { type: 'object', properties: {} },
                },
                async *stream() {
                    yields.push('first');
                    yield new ToolStreamEvent({ data: 'step 1' });
                    yields.push('second');
                    yield new ToolStreamEvent({ data: 'step 2' });
                    yields.push('third');
                    yield new ToolStreamEvent({ data: 'step 3' });
                    return new ToolResultBlock({
                        toolUseId: 'stream-id',
                        status: 'success',
                        content: [new TextBlock('complete')],
                    });
                },
            };
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [streamingTool] });
            const events = [];
            const gen = agent.tool.streamer.stream();
            let result = await gen.next();
            while (!result.done) {
                events.push(result.value);
                result = await gen.next();
            }
            const finalResult = result.value;
            expect(finalResult.status).toBe('success');
            expect(finalResult.content[0]).toStrictEqual(new TextBlock('complete'));
            // Verify all yields were consumed (generator fully iterated)
            expect(yields).toStrictEqual(['first', 'second', 'third']);
            // Verify we received all 3 stream events
            expect(events).toHaveLength(3);
        });
        it('invoke() also fully consumes multi-yield generator', async () => {
            const yields = [];
            const streamingTool = {
                name: 'streamer',
                description: 'A tool that yields progress events',
                toolSpec: {
                    name: 'streamer',
                    description: 'A tool that yields progress events',
                    inputSchema: { type: 'object', properties: {} },
                },
                async *stream() {
                    yields.push('first');
                    yield new ToolStreamEvent({ data: 'step 1' });
                    yields.push('second');
                    yield new ToolStreamEvent({ data: 'step 2' });
                    yields.push('third');
                    yield new ToolStreamEvent({ data: 'step 3' });
                    return new ToolResultBlock({
                        toolUseId: 'stream-id',
                        status: 'success',
                        content: [new TextBlock('complete')],
                    });
                },
            };
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [streamingTool] });
            const result = await agent.tool.streamer.invoke();
            expect(result.status).toBe('success');
            expect(result.content[0]).toStrictEqual(new TextBlock('complete'));
            // Verify all yields were consumed even when using .invoke()
            expect(yields).toStrictEqual(['first', 'second', 'third']);
        });
    });
    describe('tool input passthrough', () => {
        it('passes ALL parameters to tool execution', async () => {
            let receivedInput = null;
            const tool = createMockTool('capture-tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('captured')],
            }));
            // Override stream to capture input
            const originalStream = tool.stream.bind(tool);
            tool.stream = function (context) {
                receivedInput = context.toolUse.input;
                return originalStream(context);
            };
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [tool] });
            await agent.tool.capture_tool.invoke({ allowed: 'yes', extra: 'should-pass-through' });
            // Tool receives ALL parameters
            expect(receivedInput).toStrictEqual({ allowed: 'yes', extra: 'should-pass-through' });
        });
    });
    describe('dynamically added tools', () => {
        it('can call a tool that was added after agent creation', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, tools: [] });
            // Add tool after creation
            const laterTool = createMockTool('later-tool', () => new ToolResultBlock({
                toolUseId: 'test-id',
                status: 'success',
                content: [new TextBlock('dynamic')],
            }));
            agent.toolRegistry.add(laterTool);
            const result = await agent.tool.later_tool.invoke();
            expect(result.status).toBe('success');
            expect(result.content[0]).toStrictEqual(new TextBlock('dynamic'));
        });
    });
});
describe('MessageAddedEvent hooks', () => {
    it('fires MessageAddedEvent for each message recorded during direct tool call', async () => {
        const { MessageAddedEvent } = await import('../../hooks/events.js');
        const tool = createMockTool('calculator', () => new ToolResultBlock({
            toolUseId: 'test-id',
            status: 'success',
            content: [new TextBlock('8')],
        }));
        const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
        const agent = new Agent({ model, tools: [tool] });
        const firedEvents = [];
        agent.addHook(MessageAddedEvent, (event) => {
            firedEvents.push(event);
        });
        await agent.tool.calculator.invoke({ a: 5, b: 3 });
        // Should fire 3 MessageAddedEvents (one per recorded message).
        // Use full-object assertions per TESTING.md.
        expect(firedEvents).toHaveLength(3);
        expect(firedEvents[0].message).toEqual(new Message({
            role: 'assistant',
            content: [
                new ToolUseBlock({
                    toolUseId: expect.stringMatching(/^tooluse_/),
                    name: 'calculator',
                    input: { a: 5, b: 3 },
                }),
            ],
        }));
        expect(firedEvents[1].message).toEqual(new Message({
            role: 'user',
            content: [
                new ToolResultBlock({
                    toolUseId: 'test-id',
                    status: 'success',
                    content: [new TextBlock('8')],
                }),
            ],
        }));
        expect(firedEvents[2].message).toEqual(new Message({
            role: 'assistant',
            content: [new TextBlock('agent.tool.calculator was called.')],
        }));
    });
    it('does not fire MessageAddedEvent when recordDirectToolCall is false', async () => {
        const { MessageAddedEvent } = await import('../../hooks/events.js');
        const tool = createMockTool('calculator', () => new ToolResultBlock({
            toolUseId: 'test-id',
            status: 'success',
            content: [new TextBlock('8')],
        }));
        const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
        const agent = new Agent({ model, tools: [tool] });
        const firedEvents = [];
        agent.addHook(MessageAddedEvent, (event) => {
            firedEvents.push(event);
        });
        await agent.tool.calculator.invoke({ a: 5, b: 3 }, { recordDirectToolCall: false });
        // No events should fire when recording is disabled
        expect(firedEvents).toHaveLength(0);
    });
});
//# sourceMappingURL=tool-caller.test.js.map