feat: v0.7.0 四阶段全量迭代 — 修复面收口 · 安全纵深 · 架构还债 · 能力演进
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m45s
CI / 全量测试 (Electron ABI) (push) Failing after 5m22s
CI / 产物编译验证 (push) Successful in 10m3s

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 桥契约
This commit is contained in:
2026-08-27 17:06:58 +08:00
parent b6e2a8bd25
commit 3940716dc2
78 changed files with 6369 additions and 1341 deletions
@@ -0,0 +1,355 @@
/**
* 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');
});
});