/** * AgentLoopEngine 可靠性扩展测试(v0.7.0 覆盖补齐) * * 覆盖此前完全未测的引擎核心分支: * 1. 上下文压缩管线 —— 阈值触发、#30 摘要注入形态(assistant 角色 + 占位 user)、 * compressed 事件载荷、负向回归。 * 2. 重试/退避 —— 503×2 后第三次成功;RETRY 伪 ERROR 不透传前端。 * 3. abort 竞速 —— 工具执行中 abort → USER_INTERRUPT;迟到工具结果不转发; * waitForAbort 超时 false / 完成 true 双路径(#29)。 * 4. H-5 引擎层 MEMORY.md 根文件闸门 —— 参数别名矩阵与子目录放行。 * 5. finalizeToolCallsFromBuffer 兜底缓冲 —— 合法拼接与截断自愈分支。 */ 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 { TerminationReason } from '../types'; import type { IMetonaProviderAdapter, MetonaRequest, MetonaResponse, MetonaStreamEvent, } from '../../types'; import { MetonaStreamEventType } from '../../types'; const userMessage = { role: 'user' as const, content: 'hello', timestamp: Date.now() }; const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' }; function textDone(text: string): MetonaStreamEvent[] { return [ { type: MetonaStreamEventType.TEXT_DELTA, requestId: 'r', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), delta: text }, { type: MetonaStreamEventType.DONE, requestId: 'r', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() }, ]; } function toolCallScript(name: string, args: Record): MetonaStreamEvent[] { return [ { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, requestId: 'r', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), toolCall: { id: 'tc_1', name, args, iteration: 1, timestamp: Date.now() } }, { type: MetonaStreamEventType.DONE, requestId: 'r', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() }, ]; } function recordingAdapter(): { adapter: IMetonaProviderAdapter; requests: Array; } { let call = 0; const requests: Array = []; const adapter: IMetonaProviderAdapter = { providerId: 'mock', supportedModels: ['m'], supportsToolCalling: true, supportsThinking: false, getContextWindow: () => 1_000_000, send: async (): Promise => ({ meta: { requestId: 'r_sum', provider: 'mock', model: 'm', latencyMs: 0, timestamp: Date.now() }, content: 'SUMMARY-OF-EARLIER-TALK', usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 }, finishReason: 'stop' as never, }), async *sendStream(request): AsyncIterable { const idx = call++; requests.push(request.messages); void idx; for (const ev of textDone('ok')) yield ev; }, setAbortSignal: vi.fn(), healthCheck: async () => true, }; return { adapter, requests }; } describe('引擎 — 上下文压缩管线', () => { it('估算超阈值触发 LLM 摘要:第二轮请求以 [Context Summary] 开头并携带占位 user', async () => { // contextWindow=1000, threshold=0.8 ⇒ 触发线 800 tokens // 6 条历史各约 300 tokens(1200 ASCII 字符 ≈ 300 tok)+ 本轮 assistant/tool const heavy = 'A'.repeat(1200); const history: import('../../types').MetonaMessage[] = [ { role: 'user', content: heavy, timestamp: Date.now() }, { role: 'assistant', content: heavy, timestamp: Date.now() }, { role: 'user', content: heavy, timestamp: Date.now() }, { role: 'assistant', content: heavy, timestamp: Date.now() }, { role: 'user', content: heavy, timestamp: Date.now() }, { role: 'assistant', content: heavy, timestamp: Date.now() }, ]; const base = recordingAdapter(); // 迭代 1:发起一次 read_file 工具调用 → 进入压缩检查 → 迭代 2 收到压缩后的消息 // 注意:#3 修复会让引擎在每次 run 开始时从 adapter 同步 contextWindow, // 因此小窗口必须声明在 adapter 上(而非仅引擎配置),才能在真实接线形态下生效。 const adapter: IMetonaProviderAdapter = { ...base.adapter, getContextWindow: () => 1_000, sendStream: (() => { let n = 0; return async function* (request: MetonaRequest): AsyncIterable { base.requests.push(request.messages); // 记录每轮实际请求(含压缩后的形态) if (n === 0) { n++; for (const ev of toolCallScript('read_file', { path: 'a.txt' })) yield ev; return; } for (const ev of textDone('final answer after compression')) yield ev; }; })(), }; const engine = new AgentLoopEngine( { maxIterations: 5, contextWindow: 1_000, compressionThreshold: 0.8 }, adapter, ); const compressedEvents: Array<{ originalTokens: number; compressedTokens: number }> = []; engine.on('compressed', (e) => compressedEvents.push(e as { originalTokens: number; compressedTokens: number }), ); const output = await engine.runStream(userMessage, 's1', history, systemPrompt); expect(output.terminationReason).toBe(TerminationReason.COMPLETED); expect(compressedEvents).toHaveLength(1); expect(compressedEvents[0].originalTokens).toBeGreaterThan(compressedEvents[0].compressedTokens); // 第二轮请求首部:#30 assistant 角色 + 占位 user(防连续 assistant 触发部分 Provider 400) expect(base.requests.length).toBeGreaterThanOrEqual(2); const secondRound = base.requests[1]!; const firstMsg = secondRound[0] as { role: string; content?: string }; expect(firstMsg.role).toBe('assistant'); expect(String(firstMsg.content)).toContain('[Context Summary]'); expect(String(firstMsg.content)).toContain('SUMMARY-OF-EARLIER-TALK'); const secondMsg = secondRound[1] as { role: string; content?: string }; expect(secondMsg.role).toBe('user'); expect(String(secondMsg.content)).toContain('[Continue from the summary above.]'); }); it('低占用时不触发压缩(负向回归)', async () => { const { adapter } = recordingAdapter(); const engine = new AgentLoopEngine({ contextWindow: 1_000_000 }, adapter); const compressedEvents: unknown[] = []; engine.on('compressed', (e) => compressedEvents.push(e)); await engine.runStream(userMessage, 's1', [{ role: 'user' as const, content: 'tiny', timestamp: Date.now() }], systemPrompt); expect(compressedEvents).toHaveLength(0); }); }); describe('引擎 — 指数退避重试(RETRY 伪事件端到端)', () => { it('503 ×2 后第三次成功;RETRY ERROR 与文本增量均正确分离', async () => { vi.useFakeTimers(); try { let attempt = 0; const adapter: IMetonaProviderAdapter = { ...recordingAdapter().adapter, sendStream: async function* (): AsyncIterable { if (attempt < 2) { attempt++; throw Object.assign(new Error('upstream unavailable'), { status: 503 }); } for (const ev of textDone('recovered')) yield ev; }, }; const streamEvents: string[] = []; const engine = new AgentLoopEngine({ retryCount: 3 }, adapter); engine.on('streamEvent', (ev: MetonaStreamEvent) => { if (ev.type === MetonaStreamEventType.ERROR && String(ev.error?.code ?? '').includes('retry')) return; streamEvents.push(ev.type); }); const runPromise = engine.runStream(userMessage, 's1', [], systemPrompt); await vi.advanceTimersByTimeAsync(10_000); // 1s*2^0 + 2s*2^1 的 ±20% jitter 上限远小于此 const output = await runPromise; expect(attempt).toBe(2); expect(output.terminationReason).toBe(TerminationReason.COMPLETED); expect(output.finalAnswer).toBe('recovered'); expect(streamEvents.filter((t) => t === MetonaStreamEventType.TEXT_DELTA)).toHaveLength(1); } finally { vi.useRealTimers(); } }); }); describe('引擎 — abort 竞速与 waitForAbort(#29 双路径)', () => { function hangRegistry(): { registry: unknown; resolveTool: (v: unknown) => void; calledRef: { v: boolean } } { let resolver!: (v: unknown) => void; const calledRef = { v: false }; const registry = { get: () => ({ definition: { name: 'slow_tool', timeoutMs: 60_000 } }), execute: () => { calledRef.v = true; return new Promise((resolve) => { resolver = resolve; }); }, }; // 注意:resolveTool 以闭包转发而非按值捕获 —— 调用方拿到时 resolver 可能尚未赋值 return { registry, resolveTool: (v: unknown) => resolver(v), calledRef }; } it('abort 在工具执行中触发竞速 → USER_INTERRUPT;迟到的工具结果不再转发', async () => { const { registry, resolveTool, calledRef } = hangRegistry(); const hangBase = recordingAdapter(); const toolAdapter: IMetonaProviderAdapter = { ...hangBase.adapter, sendStream: async function* (): AsyncIterable { for (const ev of toolCallScript('slow_tool', {})) yield ev; }, }; const engine = new AgentLoopEngine( { maxIterations: 3 }, toolAdapter, registry as never, [], [], ); const toolResults: unknown[] = []; engine.on('streamEvent', (ev: MetonaStreamEvent) => { if (ev.type === MetonaStreamEventType.TOOL_RESULT) toolResults.push(ev.toolResult); }); const runPromise = engine.runStream(userMessage, 's1', [], systemPrompt); // 确定性等待:registry.execute 已被调用(工具挂起、resolver 已捕获) await vi.waitFor(() => expect(calledRef.v).toBe(true)); engine.abort(); resolveTool({ ok: true }); // 中止后才 settle —— 结果必须被丢弃而非转发 const output = await runPromise; expect(output.terminationReason).toBe(TerminationReason.USER_INTERRUPT); expect(toolResults).toHaveLength(0); }); it('run 正常结束后 waitForAbort 返回 true;挂起中超时返回 false', async () => { let release!: (v: void) => void; const gate = new Promise((r) => (release = r)); const { adapter } = recordingAdapter(); const slowAdapter: IMetonaProviderAdapter = { ...adapter, sendStream: async function* (): AsyncIterable { await gate; for (const ev of textDone('late but done')) yield ev; }, }; const engine = new AgentLoopEngine({}, slowAdapter); const runPromise = engine.runStream(userMessage, 's1', [], systemPrompt); await new Promise((r) => setTimeout(r, 5)); await expect(engine.waitForAbort(30)).resolves.toBe(false); // 挂起中:超时 false release(); const output = await runPromise; expect(output.terminationReason).toBe(TerminationReason.COMPLETED); await expect(engine.waitForAbort(1_000)).resolves.toBe(true); }); it('无进行中 run 时 waitForAbort 直接 true', async () => { const { adapter } = recordingAdapter(); const engine = new AgentLoopEngine({}, adapter); await expect(engine.waitForAbort()).resolves.toBe(true); }); }); describe('引擎 — H-5 MEMORY.md 根文件闸门(参数别名矩阵 + 子目录放行)', () => { const workspacePath = process.platform === 'win32' ? 'C:\\ws\\demo' : '/ws/demo'; function makeGuardedToolRunner(args: Record): Promise<{ blockedError: string | null; executed: boolean }> { let executed = false; const registry = { get: () => ({ definition: { name: 'read_file', timeoutMs: 1_000 } }), execute: () => { executed = true; return Promise.resolve({ data: 'should-not-run' }); }, }; const base = recordingAdapter(); const adapter: IMetonaProviderAdapter = { ...base.adapter, sendStream: async function* (): AsyncIterable { yield { type: MetonaStreamEventType.TOOL_CALL_COMPLETE, requestId: 'r', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), toolCall: { id: 'tc_g', name: 'read_file', args, iteration: 1, timestamp: Date.now() } } as MetonaStreamEvent; yield { type: MetonaStreamEventType.DONE, requestId: 'r', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() } as MetonaStreamEvent; }, }; const engine = new AgentLoopEngine({ maxIterations: 2 }, adapter, registry as never, [], []); engine.setWorkspacePath(workspacePath); return engine.runStream(userMessage, 's1', [], systemPrompt).then((output) => { const errText = String(output.iterations[0]?.toolResults?.[0]?.error ?? ''); return { blockedError: errText.includes('protected by security policy') ? errText : null, executed, }; }); } it.each([ ['path 别名命中根 MEMORY.md', { path: `${workspacePath}/MEMORY.md` }], ['file_path 别名(反斜杠)命中', { file_path: `${workspacePath}\\MEMORY.md` }], ['filePath 别名(混合斜杠)命中', { filePath: `${workspacePath}/MEMORY.md` }], ['file 别名命中', { file: `${workspacePath}/MEMORY.md` }], ['target 别名命中', { target: `${workspacePath}/MEMORY.md` }], ])('%s → 拦截且工具未执行', async (_label, args) => { const { blockedError, executed } = await makeGuardedToolRunner(args); expect(blockedError).toContain('protected by security policy'); expect(executed).toBe(false); }); it('子目录 MEMORY.md 放行(H-5 的精确保护语义核心)', async () => { const { blockedError, executed } = await makeGuardedToolRunner({ path: `${workspacePath}/docs/MEMORY.md` }); expect(blockedError).toBeNull(); expect(executed).toBe(true); }); it('普通文件放行', async () => { const { blockedError, executed } = await makeGuardedToolRunner({ path: `${workspacePath}/a.txt` }); expect(blockedError).toBeNull(); expect(executed).toBe(true); }); }); describe('引擎 — finalizeToolCallsFromBuffer 兜底缓冲', () => { it('仅 DELTA 无 COMPLETE:合法 JSON 组装出 step.toolCalls', async () => { const { adapter } = recordingAdapter(); const deltaAdapter: IMetonaProviderAdapter = { ...adapter, sendStream: async function* (): AsyncIterable { yield { type: MetonaStreamEventType.TOOL_CALL_DELTA, requestId: 'r', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), toolCallDelta: { index: 0, name: 'write_file', argsDelta: '{"pa' } } as MetonaStreamEvent; yield { type: MetonaStreamEventType.TOOL_CALL_DELTA, requestId: 'r', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now(), toolCallDelta: { index: 0, argsDelta: 'th":"a.txt"}' } } as MetonaStreamEvent; yield { type: MetonaStreamEventType.DONE, requestId: 'r', sessionId: 's1', iteration: 1, seq: 2, timestamp: Date.now() } as MetonaStreamEvent; }, }; const engine = new AgentLoopEngine({ maxIterations: 1 }, deltaAdapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); const tcs = output.iterations[0]?.toolCalls ?? []; expect(tcs).toHaveLength(1); expect(tcs[0].name).toBe('write_file'); expect(tcs[0].args).toEqual({ path: 'a.txt' }); }); it('仅 DELTA 且 JSON 半截:自愈载荷进入兜底解析(引擎侧最后防线)', async () => { const { adapter } = recordingAdapter(); const deltaAdapter: IMetonaProviderAdapter = { ...adapter, sendStream: async function* (): AsyncIterable { yield { type: MetonaStreamEventType.TOOL_CALL_DELTA, requestId: 'r', sessionId: 's1', iteration: 1, seq: 0, timestamp: Date.now(), toolCallDelta: { index: 0, name: 'write_file', argsDelta: '{"content": "AAAA' } } as MetonaStreamEvent; yield { type: MetonaStreamEventType.DONE, requestId: 'r', sessionId: 's1', iteration: 1, seq: 1, timestamp: Date.now() } as MetonaStreamEvent; }, }; const engine = new AgentLoopEngine({ maxIterations: 1 }, deltaAdapter); const output = await engine.runStream(userMessage, 's1', [], systemPrompt); const args = (output.iterations[0]?.toolCalls?.[0]?.args ?? {}) as Record; expect(args._truncatedArguments).toBe(true); expect(String(args._truncatedReason)).toContain('truncated'); }); });