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 桥契约
356 lines
16 KiB
TypeScript
356 lines
16 KiB
TypeScript
/**
|
||
* 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 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<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');
|
||
});
|
||
});
|