import { runAttempt } from '../src/modules/netaclaw/runtime/attempt.js'; jest.mock('../src/modules/netaclaw/runtime/model_selection.js', () => ({ getProvider: jest.fn(), })); describe('runAttempt tool lifecycle hooks', () => { const { getProvider } = require('../src/modules/netaclaw/runtime/model_selection.js'); beforeEach(() => { jest.resetAllMocks(); }); it('applies beforeToolCall args override and afterToolCall result override', async () => { const execute = jest.fn(async (_id: string, args: Record) => `result:${args.value}`); const chat = jest.fn() .mockResolvedValueOnce({ content: 'tool turn', toolCalls: [ { id: 'tool-1', name: 'demo_tool', arguments: JSON.stringify({ value: 'raw' }) }, ], usage: { inputTokens: 1, outputTokens: 1 }, }) .mockResolvedValueOnce({ content: 'done', toolCalls: [], usage: { inputTokens: 1, outputTokens: 1 }, }); getProvider.mockReturnValue({ chat, }); const result = await runAttempt({ messages: [{ role: 'user', content: 'hello' }], tools: [{ name: 'demo_tool', execute } as any], config: { provider: 'mock', model: 'mock', apiKey: 'k' }, beforeToolCall: async () => ({ args: { value: 'patched' } }), afterToolCall: async () => ({ result: 'after-result' }), }); expect(execute).toHaveBeenCalledWith('tool-1', { value: 'patched' }); expect(result.messages.some(message => message.role === 'tool' && message.content === 'after-result')).toBe(true); }); it('blocks tool execution from beforeToolCall', async () => { const execute = jest.fn(); const chat = jest.fn() .mockResolvedValueOnce({ content: 'tool turn', toolCalls: [ { id: 'tool-1', name: 'demo_tool', arguments: JSON.stringify({ value: 'raw' }) }, ], usage: { inputTokens: 1, outputTokens: 1 }, }) .mockResolvedValueOnce({ content: 'done', toolCalls: [], usage: { inputTokens: 1, outputTokens: 1 }, }); getProvider.mockReturnValue({ chat, }); const result = await runAttempt({ messages: [{ role: 'user', content: 'hello' }], tools: [{ name: 'demo_tool', execute } as any], config: { provider: 'mock', model: 'mock', apiKey: 'k' }, beforeToolCall: async () => ({ block: true, reason: 'denied' }), }); expect(execute).not.toHaveBeenCalled(); expect(result.messages.some(message => message.role === 'tool' && message.content === 'Tool call blocked: denied')).toBe(true); }); it('preserves structured tool results for lifecycle hooks while sending text to the model', async () => { const rawImage = { type: 'image' as const, url: 'data:image/png;base64,abc', mimeType: 'image/png' }; const execute = jest.fn(async () => rawImage); const onToolResult = jest.fn(); const afterToolCall = jest.fn(async () => undefined); const chat = jest.fn() .mockResolvedValueOnce({ content: 'tool turn', toolCalls: [ { id: 'tool-1', name: 'demo_tool', arguments: JSON.stringify({}) }, ], usage: { inputTokens: 1, outputTokens: 1 }, }) .mockResolvedValueOnce({ content: 'done', toolCalls: [], usage: { inputTokens: 1, outputTokens: 1 }, }); getProvider.mockReturnValue({ chat }); const result = await runAttempt({ messages: [{ role: 'user', content: 'hello' }], tools: [{ name: 'demo_tool', execute } as any], config: { provider: 'mock', model: 'mock', apiKey: 'k' }, afterToolCall, onToolResult, }); expect(afterToolCall).toHaveBeenCalledWith(expect.objectContaining({ result: 'Read image file [image/png]', rawResult: rawImage, })); expect(onToolResult).toHaveBeenCalledWith('demo_tool', 'Read image file [image/png]', 'tool-1', rawImage); expect(result.messages.some(message => message.role === 'tool' && message.content === 'Read image file [image/png]')).toBe(true); }); });