/** * AgentLoopEngine 工具调用链路集成测试(v0.5.1 复检) * * 验证「模型工具调用能力」全链路: * adapter 流式 tool_call 事件 → 引擎 PARSING 组装 → preToolHooks 真实管道 * (PermissionCheckHook + RateLimitHook + ConfirmationHook)→ ToolRegistry 执行 * → 工具结果回填消息历史 → 下一轮 LLM 调用 → COMPLETED。 * * 重点覆盖 v0.5.0 会话隔离改动对工具链的影响: * - sessionId 从 engine.currentSessionId → hooks → ConfirmationHook 记忆键的正确传递 * - 会话 A 的拒绝记忆不阻断会话 B 的工具(端到端,非 Hook 单元级) * - 工具被 Hook 拦截时结果以 Blocked 错误回传 LLM(不中断循环) * * 运行:无需 SQLite,系统 Node 即可(npm test)。 */ import { describe, it, expect, vi } from 'vitest'; vi.mock('electron-log', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, })); import type { BrowserWindow } from 'electron'; import { AgentLoopEngine } from '../engine'; import { TerminationReason } from '../types'; import type { IMetonaProviderAdapter, MetonaRequest, MetonaResponse, MetonaStreamEvent, MetonaToolDef, } from '../../types'; import { MetonaStreamEventType, MetonaToolCategory, MetonaRiskLevel } from '../../types'; import { ToolRegistry } from '../../tools/registry'; import type { IMetonaTool } from '../../types/metona-tool'; import { PermissionCheckHook, RateLimitHook } from '../../hooks/pre-tool'; import { ConfirmationHook } from '../../hooks/confirmation-hook'; import { PolicyEngine } from '../../sandbox/permissions'; // ===== Mock Adapter(流式 tool_call → 文本收尾;记录请求供契约断言) ===== // v0.5.2 教训:mock 无条件吐 tool_call 事件会掩盖"请求未携带工具定义"的契约缺陷 // (模型只有收到 tools 才能真正发起 tool_call)— 此处记录 requests 供断言。 const recordedRequests: MetonaRequest[] = []; function createMockAdapter(scripts: MetonaStreamEvent[][]): IMetonaProviderAdapter { let call = 0; return { providerId: 'mock', supportedModels: ['mock-model'], supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 1_000_000, send: vi.fn( async (): Promise => ({ meta: { requestId: 'r_test', provider: 'mock', model: 'mock-model', latencyMs: 1, timestamp: Date.now(), }, content: 'ok', usage: { inputTokens: 10, outputTokens: 5, totalTokens: 15 }, finishReason: 'stop' as never, }), ), sendStream: vi.fn(async function* (req: MetonaRequest): AsyncIterable { recordedRequests.push(req); const script = scripts[call % scripts.length]; call++; for (const ev of script) yield ev; }), setAbortSignal: vi.fn(), healthCheck: async () => true, }; } function toolCallEvent(name: string, args: Record): MetonaStreamEvent[] { return [ { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), toolCall: { id: `tc_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`, name, args, iteration: 1, timestamp: Date.now(), }, }, { type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now(), }, ]; } function textDoneEvent(text: string): MetonaStreamEvent[] { return [ { type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), delta: text, }, { type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now(), }, ]; } // ===== 真实工具(记录执行 + 返回结果) ===== const executedTools: Array<{ name: string; args: Record; sessionId: string }> = []; function makeTool(name: string): IMetonaTool { return { definition: { name, description: `${name} (test fixture)`, parameters: { type: 'object', properties: {}, required: [] }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 5_000, }, async execute(args, context) { executedTools.push({ name, args, sessionId: context.sessionId }); return { ok: true, tool: name }; }, }; } const userMessage = { role: 'user' as const, content: 'run the tool', timestamp: Date.now() }; const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' }; /** run_command 的 HIGH 风险工具定义(供 ConfirmationHook 查询) */ const HIGH_RISK_DEF: MetonaToolDef = { name: 'run_command', description: 'Execute shell command (test fixture)', parameters: { type: 'object', properties: {}, required: [] }, category: MetonaToolCategory.CODE_EXECUTION, riskLevel: MetonaRiskLevel.HIGH, requiresPermission: true, timeoutMs: 5_000, }; function makeMockWindow(): BrowserWindow { return { isDestroyed: () => false, webContents: { send: vi.fn() }, } as unknown as BrowserWindow; } /** 构造带真实 Hook 管道的引擎(PermissionCheck + RateLimit + Confirmation) */ function makeEngineWithHooks( adapterScripts: MetonaStreamEvent[][], hook: ConfirmationHook, maxIterations = 5, ): AgentLoopEngine { const registry = new ToolRegistry(); registry.registerBuiltin(makeTool('read_file')); registry.registerBuiltin(makeTool('run_command')); const engine = new AgentLoopEngine( { maxIterations }, createMockAdapter(adapterScripts), registry, [new PermissionCheckHook(new PolicyEngine()), new RateLimitHook(100), hook], [], ); // 模拟 AgentEngineManager.createEngine 的正确接线(v0.5.2 修复): // 引擎不自动从 registry 拉取请求工具,必须显式 setTools engine.setTools(registry.listTools()); return engine; } describe('AgentLoopEngine 工具调用链路(adapter → 引擎 → 真实 Hook 管道 → registry)', () => { it('SAFE 工具免确认直接执行:结果回填 + 工具上下文 sessionId 正确', async () => { executedTools.length = 0; recordedRequests.length = 0; const hook = new ConfirmationHook(makeMockWindow(), null); const engine = makeEngineWithHooks( [toolCallEvent('read_file', { file_path: 'a.ts' }), textDoneEvent('done after tool')], hook, ); const output = await engine.runStream(userMessage, 'sess-chain-1', [], systemPrompt); // 请求契约:LLM 请求必须携带工具定义(v0.5.2 回归点 — // 真实模型只有收到 tools 才能发起 tool_call,缺失即"口头说调工具实际不调") expect(recordedRequests.length).toBeGreaterThan(0); expect(recordedRequests[0].tools).toBeDefined(); expect(recordedRequests[0].tools!.map((t) => t.name)).toContain('read_file'); // 循环完成:工具轮 → 文本轮 expect(output.terminationReason).toBe(TerminationReason.COMPLETED); expect(output.finalAnswer).toBe('done after tool'); expect(output.iterations).toHaveLength(2); // 工具真实执行,且 ToolExecutionContext.sessionId 传递正确 expect(executedTools).toHaveLength(1); expect(executedTools[0].name).toBe('read_file'); expect(executedTools[0].sessionId).toBe('sess-chain-1'); // 工具结果成功回填到迭代记录 const toolResult = output.iterations[0].toolResults?.[0]; expect(toolResult?.success).toBe(true); expect(toolResult?.result).toEqual({ ok: true, tool: 'read_file' }); // 无确认请求产生(SAFE 工具) expect(hook.getPendingConfirmations()).toHaveLength(0); }); it('HIGH 风险工具经用户批准后执行(pending → approve → 工具运行)', async () => { executedTools.length = 0; const hook = new ConfirmationHook(makeMockWindow(), null); hook.setToolDefs([HIGH_RISK_DEF]); const engine = makeEngineWithHooks( [toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('approved and done')], hook, ); const runPromise = engine.runStream(userMessage, 'sess-chain-2', [], systemPrompt); // 等待确认请求产生(引擎阻塞在 waitForConfirmation) await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1)); const pending = hook.getPendingConfirmations(); // pending 携带正确的 sessionId(v0.5.1) expect(pending[0].sessionId).toBe('sess-chain-2'); expect(pending[0].toolName).toBe('run_command'); // 用户批准 hook.resolveConfirmation(pending[0].toolCallId, true, false, false); const output = await runPromise; expect(output.terminationReason).toBe(TerminationReason.COMPLETED); // 工具在批准后执行 expect(executedTools).toHaveLength(1); expect(executedTools[0].name).toBe('run_command'); expect(output.iterations[0].toolResults?.[0].success).toBe(true); }); it('用户拒绝后工具以 Blocked 错误回传 LLM(循环继续,不崩溃)', async () => { executedTools.length = 0; const hook = new ConfirmationHook(makeMockWindow(), null); hook.setToolDefs([HIGH_RISK_DEF]); const engine = makeEngineWithHooks( [toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('denied, continuing')], hook, ); const runPromise = engine.runStream(userMessage, 'sess-chain-3', [], systemPrompt); await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1)); hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, false, false); const output = await runPromise; expect(output.terminationReason).toBe(TerminationReason.COMPLETED); // 工具未执行 expect(executedTools).toHaveLength(0); // 拒绝以 Blocked 错误回传(LLM 下一轮可见,可调整策略) const toolResult = output.iterations[0].toolResults?.[0]; expect(toolResult?.success).toBe(false); expect(toolResult?.error).toContain('User denied'); }); it('会话隔离端到端:会话 A 的拒绝记忆阻断 A 的后续调用,不影响会话 B', async () => { executedTools.length = 0; const hook = new ConfirmationHook(makeMockWindow(), null); hook.setToolDefs([HIGH_RISK_DEF]); // --- 会话 A 第 1 次:拒绝 + remember --- const engineA1 = makeEngineWithHooks( [toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('A1 done')], hook, ); const runA1 = engineA1.runStream(userMessage, 'sess-iso-a', [], systemPrompt); await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1)); hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false); const outA1 = await runA1; expect(outA1.iterations[0].toolResults?.[0].error).toContain('User denied'); // --- 会话 A 第 2 次:拒绝记忆直接阻断(无新 pending、无 LLM 等待) --- const engineA2 = makeEngineWithHooks( [toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('A2 done')], hook, ); const outA2 = await engineA2.runStream(userMessage, 'sess-iso-a', [], systemPrompt); expect(outA2.iterations[0].toolResults?.[0].error).toContain('previously denied'); // 记忆阻断不产生确认请求 expect(hook.getPendingConfirmations()).toHaveLength(0); // --- 会话 B:同工具正常询问并批准(A 的拒绝记忆不影响 B) --- const engineB = makeEngineWithHooks( [toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('B done')], hook, ); const runB = engineB.runStream(userMessage, 'sess-iso-b', [], systemPrompt); await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1)); const pendingB = hook.getPendingConfirmations(); expect(pendingB[0].sessionId).toBe('sess-iso-b'); hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false); const outB = await runB; expect(outB.iterations[0].toolResults?.[0].success).toBe(true); expect(executedTools.filter((t) => t.sessionId === 'sess-iso-b')).toHaveLength(1); // A 会话的工具始终未执行 expect(executedTools.filter((t) => t.sessionId === 'sess-iso-a')).toHaveLength(0); }); it('工具抛出异常时以错误结果回传(不中断引擎循环)', async () => { executedTools.length = 0; const registry = new ToolRegistry(); // read_file 已有 PolicyEngine 策略(SAFE,免确认)— 此处注册抛错实现验证异常路径 registry.registerBuiltin({ definition: { name: 'read_file', description: 'always throws (test fixture)', parameters: { type: 'object', properties: {}, required: [] }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 5_000, }, async execute() { throw new Error('tool exploded'); }, }); const engine = new AgentLoopEngine( { maxIterations: 3 }, createMockAdapter([toolCallEvent('read_file', {}), textDoneEvent('recovered')]), registry, [new PermissionCheckHook(new PolicyEngine()), new RateLimitHook(100)], [], ); const output = await engine.runStream(userMessage, 'sess-chain-4', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.COMPLETED); expect(output.finalAnswer).toBe('recovered'); const toolResult = output.iterations[0].toolResults?.[0]; expect(toolResult?.success).toBe(false); expect(toolResult?.error).toContain('tool exploded'); }); it('PermissionCheckHook 拦截系统路径(read_file /etc 被策略拒绝)', async () => { executedTools.length = 0; const hook = new ConfirmationHook(makeMockWindow(), null); const engine = makeEngineWithHooks( [toolCallEvent('read_file', { file_path: '/etc/passwd' }), textDoneEvent('blocked path')], hook, ); const output = await engine.runStream(userMessage, 'sess-chain-5', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.COMPLETED); // 工具未执行(被 deniedPatterns 拦截) expect(executedTools).toHaveLength(0); expect(output.iterations[0].toolResults?.[0].error).toContain('Blocked'); expect(output.iterations[0].toolResults?.[0].error).toContain('security policy'); }); });