/** * AgentLoopEngine 单元测试(P1-14 测试基线) * 覆盖:完成终止、死循环检测、最大迭代、Provider 故障转移(P1) */ 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 { AgentLoopEngine } from '../engine'; import { AgentLoopState, TerminationReason } from '../types'; import type { IMetonaProviderAdapter, MetonaResponse, MetonaStreamEvent } from '../../types'; import { MetonaStreamEventType } from '../../types'; /** 构造 Mock Adapter:sendStream 按脚本产出事件 */ function createMockAdapter( scripts: MetonaStreamEvent[][], opts?: { failWith?: Error }, ): 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* (): AsyncIterable { if (opts?.failWith) throw opts.failWith; const script = scripts[call % scripts.length]; call++; for (const ev of script) yield ev; }), setAbortSignal: vi.fn(), healthCheck: async () => true, }; } 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(), }, ]; } 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_test', name, args, iteration: 1, timestamp: Date.now() }, }, { type: MetonaStreamEventType.DONE, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now(), }, ]; } const userMessage = { role: 'user' as const, content: 'hello', timestamp: Date.now() }; const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' }; describe('AgentLoopEngine', () => { it('无工具调用时正常完成(COMPLETED)', async () => { const adapter = createMockAdapter([textDoneEvent('final answer')]); const engine = new AgentLoopEngine({}, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.COMPLETED); expect(output.finalAnswer).toBe('final answer'); }); it('死循环检测:连续 3 轮相同工具调用触发 DEAD_LOOP', async () => { // 每轮都返回相同的工具调用(read_file + 相同参数) const adapter = createMockAdapter([toolCallEvent('read_file', { file_path: 'same.ts' })]); const engine = new AgentLoopEngine({ maxIterations: 10 }, adapter); const deadLoopEvents: unknown[] = []; engine.on('deadLoop', (d) => deadLoopEvents.push(d)); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP); expect(deadLoopEvents.length).toBe(1); }); it('参数不同的相同工具不触发死循环(签名不同)', async () => { const scripts = [ toolCallEvent('read_file', { file_path: 'a.ts' }), toolCallEvent('read_file', { file_path: 'b.ts' }), ]; const adapter = createMockAdapter(scripts); const engine = new AgentLoopEngine({ maxIterations: 3 }, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); // 3 轮工具调用后达到 MAX_ITERATIONS(非 DEAD_LOOP) expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS); }); it('达到最大迭代次数触发 MAX_ITERATIONS', async () => { // 交替不同的工具调用避免死循环 const scripts = [ toolCallEvent('read_file', { file_path: 'a.ts' }), toolCallEvent('read_file', { file_path: 'b.ts' }), ]; const adapter = createMockAdapter(scripts); const engine = new AgentLoopEngine({ maxIterations: 2 }, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS); expect(output.iterations.length).toBe(2); }); it('状态机经过 THINKING → PARSING → OBSERVING', async () => { const adapter = createMockAdapter([textDoneEvent('answer')]); const engine = new AgentLoopEngine({}, adapter); const states: string[] = []; engine.on('stateChange', (d: { current?: string }) => { if (d.current) states.push(d.current); }); await engine.runStream(userMessage, 's1', [], systemPrompt); expect(states).toContain(AgentLoopState.THINKING); expect(states).toContain(AgentLoopState.PARSING); expect(states).toContain(AgentLoopState.OBSERVING); expect(states[states.length - 1]).toBe(AgentLoopState.TERMINATED); }); it('不可重试错误直接 ERROR(无 fallback 时)', async () => { const adapter = createMockAdapter([], { failWith: Object.assign(new Error('401 unauthorized'), { status: 401 }), }); const engine = new AgentLoopEngine({ retryCount: 0 }, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.ERROR); }); it('P1 故障转移:主 Provider 失败后切换到 fallback Provider', async () => { // 主 adapter 每次都失败(401 不可重试) const primary = createMockAdapter([], { failWith: Object.assign(new Error('401 invalid key'), { status: 401 }), }); // fallback 正常返回 const fallback = createMockAdapter([textDoneEvent('fallback answer')]); const engine = new AgentLoopEngine({ retryCount: 0 }, primary); engine.setFallbackAdapter(fallback); const switchEvents: Array<{ from?: string; to?: string }> = []; engine.on('providerSwitched', (d) => switchEvents.push(d)); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.COMPLETED); expect(output.finalAnswer).toBe('fallback answer'); expect(switchEvents.length).toBe(1); expect(switchEvents[0].from).toBe('mock'); expect(switchEvents[0].to).toBe('mock'); // fallback 的 sendStream 被调用 expect(fallback.sendStream).toHaveBeenCalled(); }); it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => { const primary = createMockAdapter([], { failWith: Object.assign(new Error('401'), { status: 401 }), }); const fallback = createMockAdapter([], { failWith: Object.assign(new Error('500'), { status: 500 }), }); const engine = new AgentLoopEngine({ retryCount: 0 }, primary); engine.setFallbackAdapter(fallback); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); // fallback 失败 → ERROR(不回切 primary) expect(output.terminationReason).toBe(TerminationReason.ERROR); }); }); // ===== v0.7.3 P4-4 / P3-1: 死循环乒乓检测 + REFLECTING 状态接线 ===== describe('AgentLoopEngine — 死循环乒乓检测(ABAB,P4-4)', () => { it('最近 4 轮 A→B→A→B 交替(A≠B)触发 DEAD_LOOP(驻留模式抓不住的乒乓)', async () => { const readScript = toolCallEvent('read_file', { file_path: 'x.ts' }); const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' }); // 1:read 2:write 3:read 4:write ← 第 4 轮 PARSING 时滑窗构成 ABAB const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]); const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter); const deadLoopEvents: unknown[] = []; engine.on('deadLoop', (d) => deadLoopEvents.push(d)); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP); expect(deadLoopEvents.length).toBe(1); }); it('A→B→C 交替(无重复模式)不误报,按 MAX_ITERATIONS 终止', async () => { const scripts = [ toolCallEvent('read_file', { file_path: 'a.ts' }), toolCallEvent('write_file', { file_path: 'a.ts' }), toolCallEvent('lint_code', {}), ]; const adapter = createMockAdapter(scripts); const engine = new AgentLoopEngine({ maxIterations: 4 }, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS); }); it('A→B→B→B 前缀不构成 ABAB(A≠B 约束),由驻留模式在 3 连 B 时接管', async () => { const readScript = toolCallEvent('read_file', { file_path: 'x.ts' }); const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' }); // 1:read 2:write 3:write 4:write —— 第 4 轮时 ABAB 不成立,但 3 连 write 命中驻留模式 const adapter = createMockAdapter([readScript, writeScript, writeScript, writeScript]); const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.DEAD_LOOP); }); }); describe('AgentLoopEngine — REFLECTING 状态接线(P3-1 enableReflection)', () => { const collectStates = async (config: Record): Promise => { const adapter = createMockAdapter([ toolCallEvent('read_file', { file_path: 'a.ts' }), textDoneEvent('done'), ]); const engine = new AgentLoopEngine(config as never, adapter); const states: string[] = []; engine.on('stateChange', (d: { state?: string; current?: string }) => { const s = d.state ?? d.current ?? ''; if (!states.includes(s)) states.push(s); }); await engine.runStream(userMessage, 's1', [], systemPrompt); return states; }; it('enableReflection=true 时工具执行后进入 REFLECTING 状态', async () => { const states = await collectStates({ maxIterations: 2, enableReflection: true }); expect(states).toContain('REFLECTING'); expect(states).toContain('EXECUTING'); expect(states).toContain('OBSERVING'); }); it('enableReflection=false(默认)时不进入 REFLECTING', async () => { const states = await collectStates({ maxIterations: 2, enableReflection: false }); expect(states).not.toContain('REFLECTING'); }); });