111 lines
4.0 KiB
TypeScript
111 lines
4.0 KiB
TypeScript
|
|
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<string, unknown>) => `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);
|
||
|
|
});
|
||
|
|
});
|