GPU_GUARD_MONOREPO/packages/backend/test/runtime_attempt.test.ts

111 lines
4.0 KiB
TypeScript
Raw Normal View History

2026-05-20 21:39:12 +08:00
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);
});
});