import { beforeEach, describe, expect, it } from 'vitest';
import { Agent } from '../agent.js';
import { AfterInvocationEvent, AfterModelCallEvent, AfterToolCallEvent, AfterToolsEvent, AgentResultEvent, BeforeInvocationEvent, BeforeModelCallEvent, BeforeToolCallEvent, BeforeToolsEvent, MessageAddedEvent, ModelStreamUpdateEvent, InitializedEvent, HookableEvent, ModelMessageEvent, } from '../../hooks/index.js';
import { MockMessageModel } from '../../__fixtures__/mock-message-model.js';
import { MockPlugin } from '../../__fixtures__/mock-plugin.js';
import { collectIterator } from '../../__fixtures__/model-test-helpers.js';
import { createMockTool } from '../../__fixtures__/tool-helpers.js';
import { expectAgentResult } from '../../__fixtures__/agent-helpers.js';
import { Message, TextBlock, ToolResultBlock } from '../../types/messages.js';
describe('Agent Hooks Integration', () => {
    let mockPlugin;
    beforeEach(() => {
        mockPlugin = new MockPlugin();
    });
    describe('invocation lifecycle', () => {
        it('fires hooks during invoke', async () => {
            const lifecyclePlugin = new MockPlugin();
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [lifecyclePlugin] });
            await agent.invoke('Hi');
            expect(lifecyclePlugin.invocations).toHaveLength(7);
            expect(lifecyclePlugin.invocations[0]).toEqual(new InitializedEvent({ agent }));
            expect(lifecyclePlugin.invocations[1]).toEqual(new BeforeInvocationEvent({ agent, invocationState: {} }));
            expect(lifecyclePlugin.invocations[2]).toEqual(new MessageAddedEvent({
                agent,
                message: new Message({ role: 'user', content: [new TextBlock('Hi')] }),
                invocationState: {},
            }));
            expect(lifecyclePlugin.invocations[3]).toEqual(new BeforeModelCallEvent({
                agent,
                model: agent.model,
                invocationState: {},
                projectedInputTokens: expect.any(Number),
            }));
            expect(lifecyclePlugin.invocations[4]).toEqual(new AfterModelCallEvent({
                agent,
                model: agent.model,
                invocationState: {},
                attemptCount: 1,
                stopData: {
                    stopReason: 'endTurn',
                    message: new Message({ role: 'assistant', content: [new TextBlock('Hello')] }),
                },
            }));
            expect(lifecyclePlugin.invocations[5]).toEqual(new MessageAddedEvent({
                agent,
                message: new Message({ role: 'assistant', content: [new TextBlock('Hello')] }),
                invocationState: {},
            }));
            expect(lifecyclePlugin.invocations[6]).toEqual(new AfterInvocationEvent({ agent, invocationState: {} }));
        });
        it('fires hooks during stream', async () => {
            const lifecyclePlugin = new MockPlugin();
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [lifecyclePlugin] });
            await collectIterator(agent.stream('Hi'));
            expect(lifecyclePlugin.invocations).toHaveLength(7);
            expect(lifecyclePlugin.invocations[0]).toEqual(new InitializedEvent({ agent }));
            expect(lifecyclePlugin.invocations[1]).toEqual(new BeforeInvocationEvent({ agent, invocationState: {} }));
            expect(lifecyclePlugin.invocations[2]).toEqual(new MessageAddedEvent({
                agent,
                message: new Message({ role: 'user', content: [new TextBlock('Hi')] }),
                invocationState: {},
            }));
            expect(lifecyclePlugin.invocations[3]).toEqual(new BeforeModelCallEvent({
                agent,
                model: agent.model,
                invocationState: {},
                projectedInputTokens: expect.any(Number),
            }));
            expect(lifecyclePlugin.invocations[4]).toEqual(new AfterModelCallEvent({
                agent,
                model: agent.model,
                invocationState: {},
                attemptCount: 1,
                stopData: {
                    stopReason: 'endTurn',
                    message: new Message({ role: 'assistant', content: [new TextBlock('Hello')] }),
                },
            }));
            expect(lifecyclePlugin.invocations[5]).toEqual(new MessageAddedEvent({
                agent,
                message: new Message({ role: 'assistant', content: [new TextBlock('Hello')] }),
                invocationState: {},
            }));
            expect(lifecyclePlugin.invocations[6]).toEqual(new AfterInvocationEvent({ agent, invocationState: {} }));
        });
    });
    describe('runtime hook registration', () => {
        it('allows adding hooks after agent creation via addHook', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model });
            // Track events via individual hook registrations
            const invocations = [];
            agent.addHook(BeforeInvocationEvent, (e) => {
                invocations.push(e);
            });
            agent.addHook(AfterInvocationEvent, (e) => {
                invocations.push(e);
            });
            await agent.invoke('Hi');
            expect(invocations).toHaveLength(2);
            expect(invocations[0]).toEqual(new BeforeInvocationEvent({ agent, invocationState: {} }));
            expect(invocations[1]).toEqual(new AfterInvocationEvent({ agent, invocationState: {} }));
        });
    });
    describe('multi-turn conversations', () => {
        it('fires hooks for each invoke call', async () => {
            const lifecyclePlugin = new MockPlugin();
            const model = new MockMessageModel()
                .addTurn({ type: 'textBlock', text: 'First response' })
                .addTurn({ type: 'textBlock', text: 'Second response' });
            const agent = new Agent({ model, plugins: [lifecyclePlugin] });
            await agent.invoke('First message');
            // First turn: InitializedEvent + BeforeInvocation, MessageAdded, BeforeModelCall, AfterModelCall, MessageAdded, AfterInvocation
            expect(lifecyclePlugin.invocations).toHaveLength(7);
            await agent.invoke('Second message');
            // Should have 13 events total (7 for first turn + 6 for second turn, no InitializedEvent on second)
            expect(lifecyclePlugin.invocations).toHaveLength(13);
            // Filter for just Invocation events to verify they fire for each turn
            const invocationEvents = lifecyclePlugin.invocations.filter((e) => e instanceof BeforeInvocationEvent || e instanceof AfterInvocationEvent);
            expect(invocationEvents).toHaveLength(4); // 2 for each turn
        });
    });
    describe('tool execution hooks', () => {
        it('fires tool hooks during tool execution', async () => {
            const tool = createMockTool('testTool', () => {
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Tool result')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'testTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Final response' });
            const agent = new Agent({
                model,
                tools: [tool],
                plugins: [mockPlugin],
            });
            await agent.invoke('Test with tool');
            // Find key events
            const beforeToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof BeforeToolCallEvent);
            const afterToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolCallEvent);
            const messageAddedEvents = mockPlugin.invocations.filter((e) => e instanceof MessageAddedEvent);
            // Verify tool hooks fired
            expect(beforeToolCallEvents.length).toBe(1);
            expect(afterToolCallEvents.length).toBe(1);
            // Verify 3 MessageAdded events: input message, assistant with tool use, tool result, final assistant
            expect(messageAddedEvents.length).toBe(4);
            // Verify BeforeToolCallEvent
            const beforeToolCall = beforeToolCallEvents[0];
            expect(beforeToolCall).toEqual(new BeforeToolCallEvent({
                agent,
                toolUse: { name: 'testTool', toolUseId: 'tool-1', input: {} },
                tool,
                invocationState: {},
            }));
            // Verify AfterToolCallEvent
            const afterToolCall = afterToolCallEvents[0];
            expect(afterToolCall).toEqual(new AfterToolCallEvent({
                agent,
                toolUse: { name: 'testTool', toolUseId: 'tool-1', input: {} },
                tool,
                result: new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock('Tool result')],
                }),
                invocationState: {},
            }));
        });
        it('fires AfterToolCallEvent with error when tool fails', async () => {
            const tool = createMockTool('failingTool', () => {
                throw new Error('Tool execution failed');
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'failingTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Handled error' });
            const agent = new Agent({
                model,
                tools: [tool],
                plugins: [mockPlugin],
            });
            // Agent should complete successfully (tool errors are handled gracefully)
            const result = await agent.invoke('Test with failing tool');
            expect(result.stopReason).toBe('endTurn');
            // Find AfterToolCallEvent
            const afterToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolCallEvent);
            expect(afterToolCallEvents.length).toBe(1);
            const afterToolCall = afterToolCallEvents[0];
            expect(afterToolCall).toEqual(new AfterToolCallEvent({
                agent,
                toolUse: { name: 'failingTool', toolUseId: 'tool-1', input: {} },
                tool,
                result: new ToolResultBlock({
                    error: new Error('Tool execution failed'),
                    toolUseId: 'tool-1',
                    status: 'error',
                    content: [new TextBlock('Tool execution failed')],
                }),
                error: new Error('Tool execution failed'),
                invocationState: {},
            }));
        });
    });
    describe('ModelStreamUpdateEvent', () => {
        it('is yielded in the stream and dispatched to hooks', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const streamUpdateEvents = [];
            const agent = new Agent({ model });
            agent.addHook(ModelStreamUpdateEvent, (event) => {
                streamUpdateEvents.push(event);
            });
            // Collect all stream events
            const allStreamEvents = [];
            for await (const event of agent.stream('Test')) {
                allStreamEvents.push(event);
            }
            // Should be yielded in the stream
            const streamUpdates = allStreamEvents.filter((e) => e instanceof ModelStreamUpdateEvent);
            expect(streamUpdates.length).toBeGreaterThan(0);
            // Should also fire as hook
            expect(streamUpdateEvents.length).toBeGreaterThan(0);
            // Stream and hook should receive the same event instances
            expect(streamUpdates).toStrictEqual(streamUpdateEvents);
        });
    });
    describe('MessageAddedEvent', () => {
        it('fires for initial user input', async () => {
            const initialMessage = { role: 'user', content: [{ type: 'textBlock', text: 'Initial' }] };
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Response' });
            const agent = new Agent({
                model,
                messages: [initialMessage],
                plugins: [mockPlugin],
            });
            await agent.invoke('New message');
            const messageAddedEvents = mockPlugin.invocations.filter((e) => e instanceof MessageAddedEvent);
            // Should have 2 MessageAdded event
            expect(messageAddedEvents).toHaveLength(2);
            expect(messageAddedEvents[0]).toEqual(new MessageAddedEvent({
                agent,
                message: new Message({ role: 'user', content: [new TextBlock('New message')] }),
                invocationState: {},
            }));
            expect(messageAddedEvents[1]).toEqual(new MessageAddedEvent({
                agent,
                message: new Message({ role: 'assistant', content: [new TextBlock('Response')] }),
                invocationState: {},
            }));
        });
    });
    describe('AfterModelCallEvent retry', () => {
        it('does not duplicate user messages on error retry', async () => {
            const model = new MockMessageModel()
                .addTurn(new Error('context overflow'))
                .addTurn({ type: 'textBlock', text: 'Success' });
            const agent = new Agent({ model, printer: false });
            agent.addHook(AfterModelCallEvent, (event) => {
                if (event.error) {
                    event.retry = true;
                }
            });
            await agent.invoke('Hello');
            // Count user messages with "Hello" — should be exactly 1
            const userMessages = agent.messages.filter((m) => m.role === 'user' && m.content.some((b) => b.type === 'textBlock' && b.text === 'Hello'));
            expect(userMessages).toHaveLength(1);
        });
        it('does not duplicate user messages on success retry', async () => {
            let callCount = 0;
            const model = new MockMessageModel()
                .addTurn({ type: 'textBlock', text: 'First' })
                .addTurn({ type: 'textBlock', text: 'Second' });
            const agent = new Agent({ model, printer: false });
            agent.addHook(AfterModelCallEvent, (event) => {
                callCount++;
                if (callCount === 1 && !event.error) {
                    event.retry = true;
                }
            });
            await agent.invoke('Hello');
            const userMessages = agent.messages.filter((m) => m.role === 'user' && m.content.some((b) => b.type === 'textBlock' && b.text === 'Hello'));
            expect(userMessages).toHaveLength(1);
        });
        it('retries model call when hook sets retry', async () => {
            let callCount = 0;
            const model = new MockMessageModel()
                .addTurn(new Error('First attempt failed'))
                .addTurn({ type: 'textBlock', text: 'Success after retry' });
            const agent = new Agent({ model });
            agent.addHook(AfterModelCallEvent, (event) => {
                callCount++;
                if (callCount === 1 && event.error) {
                    event.retry = true;
                }
            });
            const result = await agent.invoke('Test');
            expect(result.lastMessage.content[0]).toEqual({ type: 'textBlock', text: 'Success after retry' });
            expect(callCount).toBe(2);
        });
        it('does not retry when retry is not set', async () => {
            const model = new MockMessageModel().addTurn(new Error('Failure'));
            const agent = new Agent({ model });
            await expect(agent.invoke('Test')).rejects.toThrow('Failure');
        });
        it('retries model call on success when hook requests it', async () => {
            let callCount = 0;
            const model = new MockMessageModel()
                .addTurn({ type: 'textBlock', text: 'First response' })
                .addTurn({ type: 'textBlock', text: 'Second response after retry' });
            const agent = new Agent({ model });
            agent.addHook(AfterModelCallEvent, (event) => {
                callCount++;
                if (callCount === 1 && !event.error) {
                    event.retry = true;
                }
            });
            const result = await agent.invoke('Test');
            expect(result.lastMessage.content[0]).toEqual({ type: 'textBlock', text: 'Second response after retry' });
            expect(callCount).toBe(2);
        });
    });
    describe('AfterToolCallEvent retry', () => {
        it('retries tool call when hook sets retry', async () => {
            let toolCallCount = 0;
            const tool = createMockTool('retryableTool', () => {
                toolCallCount++;
                if (toolCallCount === 1) {
                    throw new Error('First attempt failed');
                }
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Success')] });
            });
            let hookCallCount = 0;
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'retryableTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolCallEvent, (event) => {
                hookCallCount++;
                if (hookCallCount === 1 && event.error) {
                    event.retry = true;
                }
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(toolCallCount).toBe(2);
            expect(hookCallCount).toBe(2);
        });
        it('does not retry tool call when retry is not set', async () => {
            let toolCallCount = 0;
            const tool = createMockTool('failingTool', () => {
                toolCallCount++;
                throw new Error('Tool failed');
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'failingTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Handled error' });
            const agent = new Agent({ model, tools: [tool] });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(toolCallCount).toBe(1);
        });
        it('fires BeforeToolCallEvent on each retry', async () => {
            let toolCallCount = 0;
            const tool = createMockTool('retryableTool', () => {
                toolCallCount++;
                return new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock(`Result ${toolCallCount}`)],
                });
            });
            let beforeCount = 0;
            let afterCount = 0;
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'retryableTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(BeforeToolCallEvent, () => {
                beforeCount++;
            });
            agent.addHook(AfterToolCallEvent, (event) => {
                afterCount++;
                if (afterCount === 1) {
                    event.retry = true;
                }
            });
            await agent.invoke('Test');
            expect(beforeCount).toBe(2);
            expect(afterCount).toBe(2);
            expect(toolCallCount).toBe(2);
        });
        it('retries tool call on success when hook requests it', async () => {
            let toolCallCount = 0;
            const tool = createMockTool('successTool', () => {
                toolCallCount++;
                return new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock(`Result ${toolCallCount}`)],
                });
            });
            let hookCallCount = 0;
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'successTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolCallEvent, (event) => {
                hookCallCount++;
                if (hookCallCount === 1) {
                    event.retry = true;
                }
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(toolCallCount).toBe(2);
            expect(hookCallCount).toBe(2);
        });
    });
    describe('cancel tool via hooks', () => {
        it('cancels individual tool call with default message when cancel is true', async () => {
            let toolExecuted = false;
            const tool = createMockTool('blockedTool', () => {
                toolExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Success')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'blockedTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool], plugins: [mockPlugin] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.cancel = true;
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(toolExecuted).toBe(false);
            const afterToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolCallEvent);
            expect(afterToolCallEvents).toHaveLength(1);
            const afterEvent = afterToolCallEvents[0];
            expect(afterEvent.result).toEqual(new ToolResultBlock({
                toolUseId: 'tool-1',
                status: 'error',
                content: [new TextBlock('Tool cancelled by hook')],
            }));
        });
        it('cancels individual tool call with custom message when cancel is a string', async () => {
            let toolExecuted = false;
            const tool = createMockTool('blockedTool', () => {
                toolExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Success')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'blockedTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool], plugins: [mockPlugin] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.cancel = 'Tool call limit exceeded';
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(toolExecuted).toBe(false);
            const afterToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolCallEvent);
            expect(afterToolCallEvents).toHaveLength(1);
            const afterEvent = afterToolCallEvents[0];
            expect(afterEvent.result).toEqual(new ToolResultBlock({
                toolUseId: 'tool-1',
                status: 'error',
                content: [new TextBlock('Tool call limit exceeded')],
            }));
        });
        it('cancels only specific tools when BeforeToolCallEvent selectively cancels', async () => {
            const executedTools = [];
            const tool1 = createMockTool('allowedTool', () => {
                executedTools.push('allowedTool');
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Allowed')] });
            });
            const tool2 = createMockTool('blockedTool', () => {
                executedTools.push('blockedTool');
                return new ToolResultBlock({ toolUseId: 'tool-2', status: 'success', content: [new TextBlock('Blocked')] });
            });
            const model = new MockMessageModel()
                .addTurn([
                { type: 'toolUseBlock', name: 'allowedTool', toolUseId: 'tool-1', input: {} },
                { type: 'toolUseBlock', name: 'blockedTool', toolUseId: 'tool-2', input: {} },
            ])
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool1, tool2], plugins: [mockPlugin] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                if (event.toolUse.name === 'blockedTool') {
                    event.cancel = 'This tool is blocked';
                }
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(executedTools).toEqual(['allowedTool']);
            const afterToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolCallEvent);
            expect(afterToolCallEvents).toHaveLength(2);
            expect(afterToolCallEvents[0].result).toEqual(new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Allowed')] }));
            expect(afterToolCallEvents[1].result).toEqual(new ToolResultBlock({
                toolUseId: 'tool-2',
                status: 'error',
                content: [new TextBlock('This tool is blocked')],
            }));
        });
        it('cancels all tools with default message when BeforeToolsEvent.cancel is true', async () => {
            let toolExecuted = false;
            const tool = createMockTool('blockedTool', () => {
                toolExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Success')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'blockedTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool], plugins: [mockPlugin] });
            agent.addHook(BeforeToolsEvent, (event) => {
                event.cancel = true;
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(toolExecuted).toBe(false);
            const afterToolsEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolsEvent);
            expect(afterToolsEvents).toHaveLength(1);
            const afterEvent = afterToolsEvents[0];
            expect(afterEvent.message.content[0]).toEqual(new ToolResultBlock({
                toolUseId: 'tool-1',
                status: 'error',
                content: [new TextBlock('Tool cancelled by hook')],
            }));
        });
        it('cancels all tools with custom message when BeforeToolsEvent.cancel is a string', async () => {
            let toolExecuted = false;
            const tool = createMockTool('blockedTool', () => {
                toolExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Success')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'blockedTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool], plugins: [mockPlugin] });
            agent.addHook(BeforeToolsEvent, (event) => {
                event.cancel = 'All tools blocked';
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(toolExecuted).toBe(false);
            const afterToolsEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolsEvent);
            expect(afterToolsEvents).toHaveLength(1);
            const afterEvent = afterToolsEvents[0];
            expect(afterEvent.message.content[0]).toEqual(new ToolResultBlock({
                toolUseId: 'tool-1',
                status: 'error',
                content: [new TextBlock('All tools blocked')],
            }));
        });
        it('cancels all tools in a batch via BeforeToolsEvent with correct toolUseIds', async () => {
            const executedTools = [];
            const tool1 = createMockTool('tool1', () => {
                executedTools.push('tool1');
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Result 1')] });
            });
            const tool2 = createMockTool('tool2', () => {
                executedTools.push('tool2');
                return new ToolResultBlock({ toolUseId: 'tool-2', status: 'success', content: [new TextBlock('Result 2')] });
            });
            const model = new MockMessageModel()
                .addTurn([
                { type: 'toolUseBlock', name: 'tool1', toolUseId: 'tool-1', input: {} },
                { type: 'toolUseBlock', name: 'tool2', toolUseId: 'tool-2', input: {} },
            ])
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool1, tool2], plugins: [mockPlugin] });
            agent.addHook(BeforeToolsEvent, (event) => {
                event.cancel = 'Batch cancelled';
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(executedTools).toEqual([]);
            const afterToolsEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolsEvent);
            expect(afterToolsEvents).toHaveLength(1);
            const afterEvent = afterToolsEvents[0];
            expect(afterEvent.message.content).toEqual([
                new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'error',
                    content: [new TextBlock('Batch cancelled')],
                }),
                new ToolResultBlock({
                    toolUseId: 'tool-2',
                    status: 'error',
                    content: [new TextBlock('Batch cancelled')],
                }),
            ]);
        });
        it('emits cancel events correctly via stream()', async () => {
            let toolExecuted = false;
            const tool = createMockTool('blockedTool', () => {
                toolExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Success')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'blockedTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.cancel = 'Cancelled via stream';
            });
            const items = await collectIterator(agent.stream('Test'));
            expect(toolExecuted).toBe(false);
            const beforeToolCallEvents = items.filter((e) => e instanceof BeforeToolCallEvent);
            const afterToolCallEvents = items.filter((e) => e instanceof AfterToolCallEvent);
            expect(beforeToolCallEvents).toHaveLength(1);
            expect(afterToolCallEvents).toHaveLength(1);
            const afterEvent = afterToolCallEvents[0];
            expect(afterEvent.result).toEqual(new ToolResultBlock({
                toolUseId: 'tool-1',
                status: 'error',
                content: [new TextBlock('Cancelled via stream')],
            }));
        });
        it('allows retry after cancel on BeforeToolCallEvent', async () => {
            let toolCallCount = 0;
            const tool = createMockTool('retryTool', () => {
                toolCallCount++;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Success')] });
            });
            let beforeCount = 0;
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'retryTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                beforeCount++;
                if (beforeCount === 1) {
                    event.cancel = 'Not yet';
                }
            });
            agent.addHook(AfterToolCallEvent, (event) => {
                if (event.result.status === 'error' && beforeCount === 1) {
                    event.retry = true;
                }
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(beforeCount).toBe(2);
            expect(toolCallCount).toBe(1); // Only executed on second attempt
        });
        it('allows hooks to replace result on AfterToolCallEvent', async () => {
            const tool = createMockTool('myTool', () => {
                return new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock('original result')],
                });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'myTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolCallEvent, (event) => {
                event.result = new ToolResultBlock({
                    toolUseId: event.result.toolUseId,
                    status: 'success',
                    content: [new TextBlock('replaced result')],
                });
            });
            await agent.invoke('Test');
            const toolResultMessage = agent.messages.find((m) => m.role === 'user' && m.content.some((b) => b.type === 'toolResultBlock'));
            const toolResultBlock = toolResultMessage.content.find((b) => b.type === 'toolResultBlock');
            expect(toolResultBlock).toStrictEqual(new ToolResultBlock({
                toolUseId: 'tool-1',
                status: 'success',
                content: [new TextBlock('replaced result')],
            }));
        });
    });
    describe('AfterToolsEvent.endTurn', () => {
        const makeSingleToolSetup = () => ({
            tool: createMockTool('myTool', () => {
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('result')] });
            }),
            model: new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'myTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Should not reach this' }),
        });
        it('halts the loop when endTurn is true with default message', async () => {
            const { tool, model } = makeSingleToolSetup();
            const agent = new Agent({ model, tools: [tool], plugins: [mockPlugin] });
            agent.addHook(AfterToolsEvent, (event) => {
                event.endTurn = true;
            });
            const result = await agent.invoke('Test');
            expect(result).toEqual(expect.objectContaining({
                type: 'agentResult',
                stopReason: 'endTurn',
                lastMessage: expect.objectContaining({
                    role: 'assistant',
                    content: expect.arrayContaining([
                        expect.objectContaining({ type: 'textBlock', text: 'Turn ended early by hook after tool execution' }),
                    ]),
                }),
            }));
            expect(model.callCount).toBe(1);
        });
        it('halts the loop with custom assistant message when endTurn is a string', async () => {
            const { tool, model } = makeSingleToolSetup();
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolsEvent, (event) => {
                event.endTurn = 'enough information gathered';
            });
            const result = await agent.invoke('Test');
            expect(result).toEqual(expect.objectContaining({
                type: 'agentResult',
                stopReason: 'endTurn',
                lastMessage: expect.objectContaining({
                    role: 'assistant',
                    content: expect.arrayContaining([
                        expect.objectContaining({ type: 'textBlock', text: 'enough information gathered' }),
                    ]),
                }),
            }));
            expect(model.callCount).toBe(1);
        });
        it('does not halt when endTurn is false (default)', async () => {
            const { tool, model } = makeSingleToolSetup();
            const agent = new Agent({ model, tools: [tool] });
            const result = await agent.invoke('Test');
            expect(result).toEqual(expect.objectContaining({
                type: 'agentResult',
                stopReason: 'endTurn',
                lastMessage: expect.objectContaining({ role: 'assistant' }),
            }));
            expect(model.callCount).toBe(2);
        });
        it('treats empty string endTurn as falsy (does not halt)', async () => {
            const { tool, model } = makeSingleToolSetup();
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolsEvent, (event) => {
                event.endTurn = '';
            });
            const result = await agent.invoke('Test');
            expect(result).toEqual(expect.objectContaining({
                type: 'agentResult',
                stopReason: 'endTurn',
                lastMessage: expect.objectContaining({ role: 'assistant' }),
            }));
            expect(model.callCount).toBe(2);
        });
        it('appends tool results and default endTurn message to conversation history', async () => {
            const { tool, model } = makeSingleToolSetup();
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolsEvent, (event) => {
                event.endTurn = true;
            });
            await agent.invoke('Test');
            expect(agent.messages).toHaveLength(4);
            expect(agent.messages[0].role).toBe('user');
            expect(agent.messages[1].role).toBe('assistant');
            expect(agent.messages[1].content).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'toolUseBlock' })]));
            expect(agent.messages[2].role).toBe('user');
            expect(agent.messages[2].content).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'toolResultBlock' })]));
            expect(agent.messages[3].role).toBe('assistant');
            expect(agent.messages[3].content).toEqual(expect.arrayContaining([
                expect.objectContaining({ type: 'textBlock', text: 'Turn ended early by hook after tool execution' }),
            ]));
        });
        it('halts the loop with concurrent tool execution', async () => {
            const tool1 = createMockTool('tool1', () => {
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('Result 1')] });
            });
            const tool2 = createMockTool('tool2', () => {
                return new ToolResultBlock({ toolUseId: 'tool-2', status: 'success', content: [new TextBlock('Result 2')] });
            });
            const model = new MockMessageModel()
                .addTurn([
                { type: 'toolUseBlock', name: 'tool1', toolUseId: 'tool-1', input: {} },
                { type: 'toolUseBlock', name: 'tool2', toolUseId: 'tool-2', input: {} },
            ])
                .addTurn({ type: 'textBlock', text: 'Should not reach this' });
            const agent = new Agent({ model, tools: [tool1, tool2], toolExecutor: 'concurrent' });
            agent.addHook(AfterToolsEvent, (event) => {
                event.endTurn = true;
            });
            const result = await agent.invoke('Test');
            expect(result).toEqual(expect.objectContaining({
                type: 'agentResult',
                stopReason: 'endTurn',
                lastMessage: expect.objectContaining({ role: 'assistant' }),
            }));
            expect(model.callCount).toBe(1);
        });
        it('emits AfterToolsEvent with endTurn via stream()', async () => {
            const { tool, model } = makeSingleToolSetup();
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolsEvent, (event) => {
                event.endTurn = true;
            });
            const items = await collectIterator(agent.stream('Test'));
            const afterToolsEvents = items.filter((e) => e instanceof AfterToolsEvent);
            expect(afterToolsEvents).toHaveLength(1);
            expect(afterToolsEvents[0].endTurn).toBe(true);
            const resultEvents = items.filter((e) => e instanceof AgentResultEvent);
            expect(resultEvents).toHaveLength(1);
            expect(resultEvents[0].result.stopReason).toBe('endTurn');
        });
        it('halts even when set on a cancelled-tools AfterToolsEvent', async () => {
            const { tool, model } = makeSingleToolSetup();
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(BeforeToolsEvent, (event) => {
                event.cancel = true;
            });
            agent.addHook(AfterToolsEvent, (event) => {
                event.endTurn = true;
            });
            const result = await agent.invoke('Test');
            expect(result).toEqual(expect.objectContaining({
                type: 'agentResult',
                stopReason: 'endTurn',
                lastMessage: expect.objectContaining({ role: 'assistant' }),
            }));
            expect(model.callCount).toBe(1);
        });
    });
    describe('cancel invocation via hooks', () => {
        it('cancels invocation with default message when cancel is true', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeInvocationEvent, (event) => {
                event.cancel = true;
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(result.lastMessage.content[0]).toEqual(new TextBlock('invocation denied by hook'));
            const beforeModelCallEvents = mockPlugin.invocations.filter((e) => e instanceof BeforeModelCallEvent);
            expect(beforeModelCallEvents).toHaveLength(0);
        });
        it('cancels invocation with custom message when cancel is a string', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeInvocationEvent, (event) => {
                event.cancel = 'Unauthorized user';
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(result.lastMessage.content[0]).toEqual(new TextBlock('Unauthorized user'));
        });
        it('does not append user message when invocation is cancelled', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model });
            agent.addHook(BeforeInvocationEvent, (event) => {
                event.cancel = true;
            });
            await agent.invoke('Test');
            expect(agent.messages).toHaveLength(1);
            expect(agent.messages[0].role).toBe('assistant');
        });
        it('emits AfterInvocationEvent when invocation is cancelled', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeInvocationEvent, (event) => {
                event.cancel = true;
            });
            await agent.invoke('Test');
            const beforeInvocationEvents = mockPlugin.invocations.filter((e) => e instanceof BeforeInvocationEvent);
            const afterInvocationEvents = mockPlugin.invocations.filter((e) => e instanceof AfterInvocationEvent);
            expect(beforeInvocationEvents).toHaveLength(1);
            expect(afterInvocationEvents).toHaveLength(1);
        });
    });
    describe('cancel model call via hooks', () => {
        it('cancels model call with default message when cancel is true', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeModelCallEvent, (event) => {
                event.cancel = true;
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(result.lastMessage.content[0]).toEqual(new TextBlock('model call denied by hook'));
        });
        it('cancels model call with custom message when cancel is a string', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeModelCallEvent, (event) => {
                event.cancel = 'Rate limited';
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(result.lastMessage.content[0]).toEqual(new TextBlock('Rate limited'));
        });
        it('emits AfterModelCallEvent when model call is cancelled', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeModelCallEvent, (event) => {
                event.cancel = true;
            });
            await agent.invoke('Test');
            const beforeModelCallEvents = mockPlugin.invocations.filter((e) => e instanceof BeforeModelCallEvent);
            const afterModelCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterModelCallEvent);
            expect(beforeModelCallEvents).toHaveLength(1);
            expect(afterModelCallEvents).toHaveLength(1);
        });
        it('does not emit ModelMessageEvent when model call is cancelled', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeModelCallEvent, (event) => {
                event.cancel = true;
            });
            await agent.invoke('Test');
            const modelMessageEvents = mockPlugin.invocations.filter((e) => e instanceof ModelMessageEvent);
            expect(modelMessageEvents).toHaveLength(0);
        });
        it('allows retry after cancel on model call', async () => {
            let beforeCount = 0;
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [mockPlugin] });
            agent.addHook(BeforeModelCallEvent, (event) => {
                beforeCount++;
                if (beforeCount === 1) {
                    event.cancel = 'Not yet';
                }
            });
            agent.addHook(AfterModelCallEvent, (event) => {
                if (beforeCount === 1) {
                    event.retry = true;
                }
            });
            const result = await agent.invoke('Test');
            expect(result.stopReason).toBe('endTurn');
            expect(beforeCount).toBe(2);
            expect(result.lastMessage.content[0]).toEqual(new TextBlock('Hello'));
        });
    });
    describe('BeforeToolCallEvent selectedTool', () => {
        it('invokes the replacement tool instead of the registry tool', async () => {
            let originalExecuted = false;
            let replacementExecuted = false;
            const originalTool = createMockTool('originalTool', () => {
                originalExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('original')] });
            });
            const replacementTool = createMockTool('replacementTool', () => {
                replacementExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('replacement')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'originalTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [originalTool], plugins: [mockPlugin] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.selectedTool = replacementTool;
            });
            await agent.invoke('Test');
            expect(originalExecuted).toBe(false);
            expect(replacementExecuted).toBe(true);
            const afterToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolCallEvent);
            expect(afterToolCallEvents).toHaveLength(1);
            expect(afterToolCallEvents[0].result.content).toEqual([new TextBlock('replacement')]);
        });
        it('cancel wins over selectedTool', async () => {
            let replacementExecuted = false;
            const replacementTool = createMockTool('replacementTool', () => {
                replacementExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('replacement')] });
            });
            const registryTool = createMockTool('registryTool', () => {
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('registry')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'registryTool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [registryTool], plugins: [mockPlugin] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.selectedTool = replacementTool;
                event.cancel = 'blocked';
            });
            await agent.invoke('Test');
            expect(replacementExecuted).toBe(false);
            // AfterToolCallEvent.tool should report the selectedTool even on the cancel path,
            // so observability hooks see a consistent `tool` value regardless of branch.
            const afterToolCallEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolCallEvent);
            expect(afterToolCallEvents).toHaveLength(1);
            expect(afterToolCallEvents[0].tool).toBe(replacementTool);
        });
        it('works with concurrent tool executor', async () => {
            let originalExecuted = false;
            let replacementExecuted = false;
            const originalTool = createMockTool('originalTool', () => {
                originalExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('original')] });
            });
            const replacementTool = createMockTool('replacementTool', () => {
                replacementExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('replacement')] });
            });
            const otherTool = createMockTool('otherTool', () => {
                return new ToolResultBlock({ toolUseId: 'tool-2', status: 'success', content: [new TextBlock('other')] });
            });
            const model = new MockMessageModel()
                .addTurn([
                { type: 'toolUseBlock', name: 'originalTool', toolUseId: 'tool-1', input: {} },
                { type: 'toolUseBlock', name: 'otherTool', toolUseId: 'tool-2', input: {} },
            ])
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({
                model,
                tools: [originalTool, otherTool],
                toolExecutor: 'concurrent',
            });
            agent.addHook(BeforeToolCallEvent, (event) => {
                if (event.toolUse.name === 'originalTool') {
                    event.selectedTool = replacementTool;
                }
            });
            await agent.invoke('Test');
            expect(originalExecuted).toBe(false);
            expect(replacementExecuted).toBe(true);
        });
    });
    describe('BeforeToolCallEvent toolUse mutation', () => {
        it('passes mutated input to the tool', async () => {
            const capturedInputs = [];
            const tool = createMockTool('tool', () => {
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('ok')] });
            });
            // Wrap to capture input via the context the tool receives.
            const capturingTool = {
                ...tool,
                async *stream(context) {
                    capturedInputs.push(context.toolUse.input);
                    return yield* tool.stream(context);
                },
            };
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'tool', toolUseId: 'tool-1', input: { a: 1 } })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [capturingTool] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.toolUse.input = { a: 2, injected: true };
            });
            await agent.invoke('Test');
            expect(capturedInputs).toEqual([{ a: 2, injected: true }]);
        });
        it('re-resolves the tool when hook renames toolUse.name', async () => {
            let origExecuted = false;
            let renamedExecuted = false;
            const origTool = createMockTool('orig', () => {
                origExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('orig')] });
            });
            const renamedTool = createMockTool('renamed', () => {
                renamedExecuted = true;
                return new ToolResultBlock({ toolUseId: 'tool-1', status: 'success', content: [new TextBlock('renamed')] });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'orig', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [origTool, renamedTool] });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.toolUse.name = 'renamed';
            });
            await agent.invoke('Test');
            expect(origExecuted).toBe(false);
            expect(renamedExecuted).toBe(true);
        });
        it('works with concurrent tool executor', async () => {
            const capturedInputs = {};
            const baseA = createMockTool('toolA', () => {
                return new ToolResultBlock({ toolUseId: 'a', status: 'success', content: [new TextBlock('a done')] });
            });
            const baseB = createMockTool('toolB', () => {
                return new ToolResultBlock({ toolUseId: 'b', status: 'success', content: [new TextBlock('b done')] });
            });
            const toolA = {
                ...baseA,
                async *stream(context) {
                    capturedInputs[context.toolUse.name] = context.toolUse.input;
                    return yield* baseA.stream(context);
                },
            };
            const toolB = {
                ...baseB,
                async *stream(context) {
                    capturedInputs[context.toolUse.name] = context.toolUse.input;
                    return yield* baseB.stream(context);
                },
            };
            const model = new MockMessageModel()
                .addTurn([
                { type: 'toolUseBlock', name: 'toolA', toolUseId: 'a', input: { original: 'a' } },
                { type: 'toolUseBlock', name: 'toolB', toolUseId: 'b', input: { original: 'b' } },
            ])
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [toolA, toolB], toolExecutor: 'concurrent' });
            agent.addHook(BeforeToolCallEvent, (event) => {
                event.toolUse.input = { mutated: event.toolUse.name };
            });
            await agent.invoke('Test');
            expect(capturedInputs).toEqual({
                toolA: { mutated: 'toolA' },
                toolB: { mutated: 'toolB' },
            });
        });
    });
    describe('AfterToolCallEvent result mutation', () => {
        it('propagates mutated result into the conversation message', async () => {
            const tool = createMockTool('tool', () => {
                return new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock('SECRET_VALUE')],
                });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'tool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool] });
            agent.addHook(AfterToolCallEvent, (event) => {
                event.result = new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock('[REDACTED]')],
                });
            });
            await agent.invoke('Test');
            const toolResultMessage = agent.messages.find((m) => m.content.some((b) => b.type === 'toolResultBlock' && b.toolUseId === 'tool-1'));
            expect(toolResultMessage).toBeDefined();
            const block = toolResultMessage.content.find((b) => b.type === 'toolResultBlock' && b.toolUseId === 'tool-1');
            expect(block.content).toEqual([new TextBlock('[REDACTED]')]);
        });
        it('propagates mutated result into AfterToolsEvent', async () => {
            const tool = createMockTool('tool', () => {
                return new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock('SECRET_VALUE')],
                });
            });
            const model = new MockMessageModel()
                .addTurn({ type: 'toolUseBlock', name: 'tool', toolUseId: 'tool-1', input: {} })
                .addTurn({ type: 'textBlock', text: 'Done' });
            const agent = new Agent({ model, tools: [tool], plugins: [mockPlugin] });
            agent.addHook(AfterToolCallEvent, (event) => {
                event.result = new ToolResultBlock({
                    toolUseId: 'tool-1',
                    status: 'success',
                    content: [new TextBlock('[REDACTED]')],
                });
            });
            await agent.invoke('Test');
            const afterToolsEvents = mockPlugin.invocations.filter((e) => e instanceof AfterToolsEvent);
            expect(afterToolsEvents).toHaveLength(1);
            const block = afterToolsEvents[0].message.content.find((b) => b.type === 'toolResultBlock' && b.toolUseId === 'tool-1');
            expect(block.content).toEqual([new TextBlock('[REDACTED]')]);
        });
    });
    describe('AfterInvocationEvent resume', () => {
        it('re-invokes the agent with the resume args', async () => {
            const model = new MockMessageModel()
                .addTurn({ type: 'textBlock', text: 'first' })
                .addTurn({ type: 'textBlock', text: 'second' });
            let invocationCount = 0;
            const agent = new Agent({ model });
            agent.addHook(AfterInvocationEvent, (event) => {
                invocationCount++;
                if (invocationCount === 1) {
                    event.resume = 'follow-up';
                }
            });
            const result = await agent.invoke('initial');
            expect(invocationCount).toBe(2);
            expect(result).toEqual(expectAgentResult({
                stopReason: 'endTurn',
                messageText: 'second',
                // Meter cycleCount is cumulative across the resume chain (1 cycle per invocation x 2).
                cycleCount: 2,
            }));
        });
        it('chains multiple resumes', async () => {
            const model = new MockMessageModel()
                .addTurn({ type: 'textBlock', text: 'a' })
                .addTurn({ type: 'textBlock', text: 'b' })
                .addTurn({ type: 'textBlock', text: 'c' });
            let invocationCount = 0;
            const agent = new Agent({ model });
            agent.addHook(AfterInvocationEvent, (event) => {
                invocationCount++;
                if (invocationCount === 1)
                    event.resume = 'second';
                else if (invocationCount === 2)
                    event.resume = 'third';
            });
            const result = await agent.invoke('first');
            expect(invocationCount).toBe(3);
            expect(result.lastMessage.content[0]).toEqual({ type: 'textBlock', text: 'c' });
        });
        it('does not resume when resume is left undefined', async () => {
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'only' });
            let invocationCount = 0;
            const agent = new Agent({ model });
            agent.addHook(AfterInvocationEvent, () => {
                invocationCount++;
            });
            await agent.invoke('hi');
            expect(invocationCount).toBe(1);
        });
        it('does not resume when the invocation errors', async () => {
            const model = new MockMessageModel().addTurn(new Error('boom'));
            let invocationCount = 0;
            const agent = new Agent({ model });
            agent.addHook(AfterInvocationEvent, (event) => {
                invocationCount++;
                event.resume = 'should-not-run';
            });
            await expect(agent.invoke('hi')).rejects.toThrow('boom');
            expect(invocationCount).toBe(1);
        });
        it('first-registered hook wins when multiple hooks set resume', async () => {
            // AfterInvocationEvent reverses callback order (_shouldReverseCallbacks=true),
            // so the first-registered hook fires last and its resume value wins.
            const model = new MockMessageModel()
                .addTurn({ type: 'textBlock', text: 'first' })
                .addTurn({ type: 'textBlock', text: 'second' });
            let invocationCount = 0;
            const agent = new Agent({ model });
            agent.addHook(BeforeInvocationEvent, () => {
                invocationCount++;
            });
            agent.addHook(AfterInvocationEvent, (event) => {
                if (invocationCount === 1)
                    event.resume = 'first-registered wins';
            });
            agent.addHook(AfterInvocationEvent, (event) => {
                if (invocationCount === 1)
                    event.resume = 'second-registered loses';
            });
            await agent.invoke('initial');
            const userTexts = agent.messages
                .filter((m) => m.role === 'user')
                .flatMap((m) => m.content.filter((b) => b.type === 'textBlock').map((b) => b.text));
            expect(userTexts).toEqual(['initial', 'first-registered wins']);
        });
        it('ignores resume set during an erroring invocation', async () => {
            // Resume should not fire when the invocation ends with an error, even if
            // AfterInvocationEvent (which fires in _stream's finally) still runs.
            const model = new MockMessageModel().addTurn(new Error('boom'));
            let resumeFired = false;
            const agent = new Agent({ model });
            agent.addHook(AfterInvocationEvent, (event) => {
                event.resume = 'should not run';
            });
            agent.addHook(BeforeInvocationEvent, () => {
                // Track whether BeforeInvocationEvent fires a second time (would indicate resume ran).
                if (resumeFired)
                    throw new Error('unexpected second invocation');
                resumeFired = true;
            });
            await expect(agent.invoke('hi')).rejects.toThrow('boom');
        });
        it('emits only one AgentResultEvent for a resumed chain', async () => {
            const model = new MockMessageModel()
                .addTurn({ type: 'textBlock', text: 'first' })
                .addTurn({ type: 'textBlock', text: 'second' });
            let invocationCount = 0;
            const agent = new Agent({ model });
            agent.addHook(AfterInvocationEvent, (event) => {
                invocationCount++;
                if (invocationCount === 1) {
                    event.resume = 'follow-up';
                }
            });
            const items = await collectIterator(agent.stream('initial'));
            const agentResults = items.filter((e) => e instanceof AgentResultEvent);
            expect(agentResults).toHaveLength(1);
            const afterInvocations = items.filter((e) => e instanceof AfterInvocationEvent);
            expect(afterInvocations).toHaveLength(2);
        });
    });
    describe('queue-based lifecycle plugin (WASM bridge pattern)', () => {
        function createLifecycleBridgePlugin(queue) {
            return {
                name: 'strands:lifecycle-bridge',
                initAgent(agent) {
                    agent.addHook(InitializedEvent, () => {
                        queue.push('initialized');
                    });
                    agent.addHook(BeforeInvocationEvent, () => {
                        queue.push('before-invocation');
                    });
                    agent.addHook(AfterInvocationEvent, () => {
                        queue.push('after-invocation');
                    });
                    agent.addHook(BeforeModelCallEvent, () => {
                        queue.push('before-model-call');
                    });
                    agent.addHook(AfterModelCallEvent, () => {
                        queue.push('after-model-call');
                    });
                    agent.addHook(MessageAddedEvent, () => {
                        queue.push('message-added');
                    });
                    agent.addHook(BeforeToolCallEvent, () => {
                        queue.push('before-tool-call');
                    });
                    agent.addHook(AfterToolCallEvent, () => {
                        queue.push('after-tool-call');
                    });
                },
            };
        }
        it('receives lifecycle events when registered via plugins config', async () => {
            const queue = [];
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, plugins: [createLifecycleBridgePlugin(queue)] });
            await agent.invoke('Hi');
            expect(queue).toStrictEqual([
                'initialized',
                'before-invocation',
                'message-added',
                'before-model-call',
                'after-model-call',
                'message-added',
                'after-invocation',
            ]);
        });
        it('receives no events when passed via non-existent hooks config field', async () => {
            const queue = [];
            const model = new MockMessageModel().addTurn({ type: 'textBlock', text: 'Hello' });
            const agent = new Agent({ model, hooks: [createLifecycleBridgePlugin(queue)] });
            await agent.invoke('Hi');
            expect(queue).toHaveLength(0);
        });
    });
});
//# sourceMappingURL=agent.hook.test.js.map