/** * 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; providerId?: string }, ): IMetonaProviderAdapter { let call = 0; const providerId = opts?.providerId ?? 'mock'; return { providerId, supportedModels: ['mock-model'], supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 1_000_000, send: vi.fn( async (): Promise => ({ meta: { requestId: 'r_test', provider: providerId, 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 }), providerId: 'primary-mock', }); // fallback 正常返回 —— v0.7.4: 使用不同 providerId,修复旧断言 `to==='mock'` // 无法证明"切换到了正确的 fallback"(primary 与 fallback 共用 'mock' 的假阳性) const fallback = createMockAdapter([textDoneEvent('fallback answer')], { providerId: 'fallback-mock', }); 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('primary-mock'); expect(switchEvents[0].to).toBe('fallback-mock'); // fallback 的 sendStream 被调用 expect(fallback.sendStream).toHaveBeenCalled(); }); it('P1 故障转移仅触发一次(fallback 也失败不回切)', async () => { const primary = createMockAdapter([], { failWith: Object.assign(new Error('401'), { status: 401 }), providerId: 'primary-mock', }); const fallback = createMockAdapter([], { failWith: Object.assign(new Error('500'), { status: 500 }), providerId: 'fallback-mock', }); 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.4 P1-1: 超时误判三态 ===== it('P1-1 真实网络超时(ETIMEDOUT)映射为 TIMEOUT 而非 USER_INTERRUPT', async () => { // 模拟 fetchWithTimeout 超时:message 含 "timed out" 且 code=ETIMEDOUT const adapter = createMockAdapter([], { failWith: Object.assign(new Error('Request timed out after 30000ms'), { code: 'ETIMEDOUT', }), }); const engine = new AgentLoopEngine({ retryCount: 0 }, adapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); expect(output.terminationReason).toBe(TerminationReason.TIMEOUT); }); it('P1-1 用户主动 abort 映射为 USER_INTERRUPT(非超时)', async () => { // 模拟用户中断:sendStream 挂起(模拟 LLM 长响应),run 进行中调用 abort() // 引擎 chatStreamWithRetry 的 catch 首先检查 this.aborted → 抛错 → // executeRunStream catch 中 this.aborted=true → USER_INTERRUPT。 // (真实实现中 adapter 的 fetch 会因 abortController.abort() 而 reject) const hangingAdapter = { providerId: 'mock', supportedModels: ['mock-model'], supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 1_000_000, send: vi.fn(async () => ({ meta: { requestId: 'r1', provider: 'mock', model: 'm', latencyMs: 1, timestamp: Date.now(), }, content: '', usage: { inputTokens: 0, outputTokens: 0, totalTokens: 0 }, finishReason: 'stop' as never, })), sendStream: vi.fn(async function* (): AsyncIterable { // 挂起直到 abort 信号触发(真实流读取被 abort 打断的表现) // 先 yield 一个占位值满足 generator 契约(require-yield),随后挂起 await new Promise((resolve, reject) => { const t = setTimeout(() => reject(new Error('The operation was aborted')), 30); // 让外层 abort() 有机会先执行;abort 后 adapter 的 setAbortSignal 收到信号 (t as unknown as { unref?: () => void }).unref?.(); }); // 不可达——上方 Promise 永不 resolve(只 reject 或挂起);此处 yield 仅为满足 generator 契约 yield { type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r1', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), delta: '', }; }), setAbortSignal: vi.fn(), healthCheck: async () => true, } as unknown as IMetonaProviderAdapter; const engine = new AgentLoopEngine({ retryCount: 0 }, hangingAdapter); // 启动 run,稍后 abort(模拟用户在响应期间点击停止) const runPromise = engine.runStream(userMessage, 's1', [], systemPrompt); await new Promise((r) => setTimeout(r, 10)); engine.abort(); const output = await runPromise; expect(output.terminationReason).toBe(TerminationReason.USER_INTERRUPT); }); it('P1-1 其他错误(401)映射为 ERROR', 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); }); }); // ===== 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); }); // ===== v0.7.4 P4-1: 乒乓检测进度信号二次确认 ===== it('P4-1 ABAB 但工具结果有差异(进度推进)不误报死循环', async () => { // A=read_file B=write_file,参数相同构成 ABAB;但 write 的**结果**每轮变化 // (如写入内容随文件内容推进而不同)→ 进度信号存在 → 不应判定死循环 const readScript = toolCallEvent('read_file', { file_path: 'x.ts' }); const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' }); // 工具执行结果由 ToolRegistry 提供;此处用"伪造 registry 返回变化结果"的方式: // 引擎的 detectPingPong 在 EXECUTING 之后用 step.toolResults 做进度比对, // 而 toolResults 来自 registry.execute —— 注入一个结果变化的 registry。 let execCounter = 0; const registry = { execute: vi.fn(async (tc: { id: string; name: string; args: Record }) => { // 每次执行返回递增 revision(模拟结果随推进变化) execCounter++; return { toolCallId: tc.id, toolName: tc.name, result: { ok: true, revision: execCounter }, success: true, durationMs: 1, timestamp: Date.now(), }; }), get: () => ({ definition: { timeoutMs: 5000 } }), listTools: () => [], } as never; const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]); const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter, registry); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); // 结果在推进 → 不判定死循环 → 因 maxIterations=6 且持续调用工具,最终 MAX_ITERATIONS expect(output.terminationReason).toBe(TerminationReason.MAX_ITERATIONS); }); it('P4-1 ABAB 且工具结果完全相同(空转)仍判定死循环', async () => { const readScript = toolCallEvent('read_file', { file_path: 'x.ts' }); const writeScript = toolCallEvent('write_file', { file_path: 'x.ts' }); // 结果恒相同:模拟 read 返回同一内容、write 返回同一字节数(空转无推进) const registry = { execute: vi.fn(async (tc: { id: string; name: string; args: Record }) => ({ toolCallId: tc.id, toolName: tc.name, result: { ok: true, hash: 'same' }, success: true, durationMs: 1, timestamp: Date.now(), })), get: () => ({ definition: { timeoutMs: 5000 } }), listTools: () => [], } as never; const adapter = createMockAdapter([readScript, writeScript, readScript, writeScript]); const engine = new AgentLoopEngine({ maxIterations: 6 }, adapter, registry); 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'); }); });