Files
metona-ai-desktop/electron/harness/agent-loop/__tests__/engine-reliability.test.ts
T
thzxx 3940716dc2
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s
feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
P1 修复面收口: v0.6.3 截断自愈推全量(Anthropic/Ollama/非流式/引擎兜底); SSE 上游错误帧检测进重试通道;
clearMessages 摘要游标根治; truncateResult 内联图片白名单统一; 前端四 bug(确认弹窗锁死/MemoryViewer/
Virtuoso Footer/abort 尾部过滤) + reasoning 缓冲跨迭代污染; 托盘通知过滤与新建会话死链接线

P2 安全纵深: MCP 审批闭环(ConfirmationHook×PolicyEngine 联动+重名拒注册); SSRF 收敛 ssrf-guard 共享模块
(web_fetch 双通道校验+重定向终态复检); Electron 加固(preload CJS 化→sandbox:true/CSP/权限白名单/will-navigate);
run_command cmd.exe 白名单通道元字符守门; diff_viewer 10MB 预检; Anthropic thinking 预算下限; Agnes 思考显式关闭

P3 架构还债: OpenAICompatibleAdapter 中间基类收敛四家样板; 错误分类单轨化(删 mapError/getFetchSignal,
超时显式 ETIMEDOUT); PRAGMA user_version 迁移版本化; 死代码清理专项(cn.ts/SHORTCUTS/ContextMenu 分支/
getWindowState/modifiedArgs/sandbox 空壳); i18next 引入; a11y 第一轮; SearXNG 页批量草稿模型统一

P4 能力演进: Ollama pull 可取消/capabilities 探测/num_ctx 实测缓存; UpdateService feed 比对式自动更新
(app:updateCheck IPC + StatusBar 入口); MiMo providerOptions(web_search 服务端工具/strict JSON);
web_fetch extract_mode=markdown(turndown); network.proxyUrl 全局代理(Chromium sessions+undici dispatcher)

测试: 264 → 507 用例(Electron ABI 全绿零跳过), 覆盖引擎压缩管线/重试竞速/MEMORY.md 闸门/file_editor 五操作/
filesystem 七工具实体夹具/git 真实仓库/SSE 错误帧/全线截断自愈/Provider 请求形态矩阵/SSRF 表测/钩子分级矩阵/
OutputValidator 全量/SLO 指标/MCP 安全纯函数/task_manager 链路/渲染层纯域/i18n 桥契约
2026-08-27 17:06:58 +08:00

356 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<string, unknown>): 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<MetonaRequest['messages']>;
} {
let call = 0;
const requests: Array<MetonaRequest['messages']> = [];
const adapter: IMetonaProviderAdapter = {
providerId: 'mock',
supportedModels: ['m'],
supportsToolCalling: true,
supportsThinking: false,
getContextWindow: () => 1_000_000,
send: async (): Promise<MetonaResponse> => ({
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<MetonaStreamEvent> {
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 tokens1200 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<MetonaStreamEvent> {
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<MetonaStreamEvent> {
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<MetonaStreamEvent> {
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<void>((r) => (release = r));
const { adapter } = recordingAdapter();
const slowAdapter: IMetonaProviderAdapter = {
...adapter,
sendStream: async function* (): AsyncIterable<MetonaStreamEvent> {
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<string, unknown>): 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<MetonaStreamEvent> {
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<MetonaStreamEvent> {
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<MetonaStreamEvent> {
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<string, unknown>;
expect(args._truncatedArguments).toBe(true);
expect(String(args._truncatedReason)).toContain('truncated');
});
});