Files
thzxx 26169b7be4
CI / 类型检查 + Lint + 单元测试 (push) Failing after 5m43s
CI / 全量测试 (Electron ABI) (push) Failing after 5m25s
CI / 产物编译验证 (push) Successful in 10m1s
feat: v0.7.2 安全收口 · 断链接线 · 观测补洞 — 230 用例扩充与全量回归
P1 修复面收口: /clear 全链路根治(前端清空联动 DB messages+摘要游标+TRACE 快照,
IPC 语义改"操作完成"; 流式中拒绝); web_browser open 补 SSRF 校验(Chromium 旁路关闭,
与 web_fetch/http_request 同源 validateSSRF); MCP 工具结果纳入注入扫描(mcp_* 前缀
按网络来源同级 full 模式, 收敛 resolveScanMode 单点); Trace 落库/入 store 双重瘦身
(tool_result base64/超长字段剥离, metadata 防 MB 级膨胀); 文本附件 512KB 闸门
(file.slice 首段读取+truncated 标志随消息持久化+主进程附件提示感知截断);
单实例锁(requestSingleInstanceLock + second-instance 聚焦已有窗口)

P2 安全纵深: ConfirmationHook 多窗口化(确认请求/超时提示改全窗口广播,
getAllWindows 空时回退 mainWindow, fail-closed 判定升级双通道); mcp_servers.headers
全链路接线(safeParseHeaders 容错解析+SSE/StreamableHTTP requestInit 注入+IPC 逐项
校验+设置页 JSON 输入, 远程 MCP 鉴权头可用)

P3 断链接线: llm:listModels IPC(六家 adapter 动态模型发现首次接线, 配置完整性
前置校验); Ollama pullModel IPC+设置页下载卡片(进度/取消/能力徽标, v0.7.0 死代码
激活); 后台会话运行指示(sessionRunStates 图+Sidebar 状态点, 多会话并发可见);
IR 卫生(移除 THINKING_START/END 死枚举, constraints 标注预留)

P4 质量与文档: i18n 第二阶段(确认弹框/侧栏/状态栏/AgentMonitor/终止原因出层,
外观设置 zh-CN/en-US 切换, ui.locale 持久化, 渲染时求值规避异步注册); README/D1
文档对齐(http_request 风险等级/用例数/实现状态注记); 版本号 0.7.2

测试: 507 → 737 用例(+230, 11 个新文件)。覆盖补齐: context-builder/consolidator/
orchestrator/workspace.service/session-recorder/config-layering/secure-config/
network-proxy + IPC mcp/tasks/memory/app/data 域 + 渲染层 store 与流事件管线纯函数。
测试驱动修复: workspace.appendMemory 中文分区 \b 词边界失效(JS \b 不含 CJK),
固化条目恒追加文件末尾产生重复分区头 → (?=\n|$) 前瞻断言根治

回归: typecheck 双端 0 错误; ESLint 0/0; 系统 Node 687 通过 50 跳过;
Electron ABI 全量 737/737 零跳过
2026-08-30 00:09:25 +08:00

425 lines
16 KiB
TypeScript
Raw Permalink 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.5.1 复检)
*
* 验证「模型工具调用能力」全链路:
* adapter 流式 tool_call 事件 → 引擎 PARSING 组装 → preToolHooks 真实管道
* PermissionCheckHook + RateLimitHook + ConfirmationHook)→ ToolRegistry 执行
* → 工具结果回填消息历史 → 下一轮 LLM 调用 → COMPLETED。
*
* 重点覆盖 v0.5.0 会话隔离改动对工具链的影响:
* - sessionId 从 engine.currentSessionId → hooks → ConfirmationHook 记忆键的正确传递
* - 会话 A 的拒绝记忆不阻断会话 B 的工具(端到端,非 Hook 单元级)
* - 工具被 Hook 拦截时结果以 Blocked 错误回传 LLM(不中断循环)
*
* 运行:无需 SQLite,系统 Node 即可(npm test)。
*/
import { describe, it, expect, vi } from 'vitest';
vi.mock('electron-log', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}));
// v0.7.2 P2-7: ConfirmationHook 的请求分发升级为 BrowserWindow.getAllWindows()
// 全窗口广播 —— node vitest 下 electron 的 BrowserWindow 为 undefined,须 mock
// 为空数组(广播回退到注入的 mock mainWindow,与既有用例的窗口桩兼容)。
const getAllWindowsMock = vi.fn((): BrowserWindow[] => []);
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => getAllWindowsMock(),
},
}));
import type { BrowserWindow } from 'electron';
import { AgentLoopEngine } from '../engine';
import { TerminationReason } from '../types';
import type {
IMetonaProviderAdapter,
MetonaRequest,
MetonaResponse,
MetonaStreamEvent,
MetonaToolDef,
} from '../../types';
import { MetonaStreamEventType, MetonaToolCategory, MetonaRiskLevel } from '../../types';
import { ToolRegistry } from '../../tools/registry';
import type { IMetonaTool } from '../../types/metona-tool';
import { PermissionCheckHook, RateLimitHook } from '../../hooks/pre-tool';
import { ConfirmationHook } from '../../hooks/confirmation-hook';
import { PolicyEngine } from '../../sandbox/permissions';
// ===== Mock Adapter(流式 tool_call → 文本收尾;记录请求供契约断言) =====
// v0.5.2 教训:mock 无条件吐 tool_call 事件会掩盖"请求未携带工具定义"的契约缺陷
// (模型只有收到 tools 才能真正发起 tool_call)— 此处记录 requests 供断言。
const recordedRequests: MetonaRequest[] = [];
function createMockAdapter(scripts: MetonaStreamEvent[][]): IMetonaProviderAdapter {
let call = 0;
return {
providerId: 'mock',
supportedModels: ['mock-model'],
supportsToolCalling: true,
supportsThinking: false,
getContextWindow: () => 1_000_000,
send: vi.fn(
async (): Promise<MetonaResponse> => ({
meta: {
requestId: 'r_test',
provider: 'mock',
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* (req: MetonaRequest): AsyncIterable<MetonaStreamEvent> {
recordedRequests.push(req);
const script = scripts[call % scripts.length];
call++;
for (const ev of script) yield ev;
}),
setAbortSignal: vi.fn(),
healthCheck: async () => true,
};
}
function toolCallEvent(name: string, args: Record<string, unknown>): MetonaStreamEvent[] {
return [
{
type: MetonaStreamEventType.TOOL_CALL_COMPLETE,
requestId: 'r1',
sessionId: 's1',
iteration: 1,
seq: 0,
timestamp: Date.now(),
toolCall: {
id: `tc_${name}_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
name,
args,
iteration: 1,
timestamp: Date.now(),
},
},
{
type: MetonaStreamEventType.DONE,
requestId: 'r1',
sessionId: 's1',
iteration: 1,
seq: 1,
timestamp: Date.now(),
},
];
}
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(),
},
];
}
// ===== 真实工具(记录执行 + 返回结果) =====
const executedTools: Array<{ name: string; args: Record<string, unknown>; sessionId: string }> = [];
function makeTool(name: string): IMetonaTool {
return {
definition: {
name,
description: `${name} (test fixture)`,
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 5_000,
},
async execute(args, context) {
executedTools.push({ name, args, sessionId: context.sessionId });
return { ok: true, tool: name };
},
};
}
const userMessage = { role: 'user' as const, content: 'run the tool', timestamp: Date.now() };
const systemPrompt = { roleDefinition: '', outputConstraints: '', safetyGuidelines: '' };
/** run_command 的 HIGH 风险工具定义(供 ConfirmationHook 查询) */
const HIGH_RISK_DEF: MetonaToolDef = {
name: 'run_command',
description: 'Execute shell command (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.CODE_EXECUTION,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 5_000,
};
function makeMockWindow(): BrowserWindow {
return {
isDestroyed: () => false,
webContents: { send: vi.fn() },
} as unknown as BrowserWindow;
}
/** 构造带真实 Hook 管道的引擎(PermissionCheck + RateLimit + Confirmation */
function makeEngineWithHooks(
adapterScripts: MetonaStreamEvent[][],
hook: ConfirmationHook,
maxIterations = 5,
): AgentLoopEngine {
const registry = new ToolRegistry();
registry.registerBuiltin(makeTool('read_file'));
registry.registerBuiltin(makeTool('run_command'));
const engine = new AgentLoopEngine(
{ maxIterations },
createMockAdapter(adapterScripts),
registry,
[new PermissionCheckHook(new PolicyEngine()), new RateLimitHook(100), hook],
[],
);
// 模拟 AgentEngineManager.createEngine 的正确接线(v0.5.2 修复):
// 引擎不自动从 registry 拉取请求工具,必须显式 setTools
engine.setTools(registry.listTools());
return engine;
}
describe('AgentLoopEngine 工具调用链路(adapter → 引擎 → 真实 Hook 管道 → registry', () => {
it('SAFE 工具免确认直接执行:结果回填 + 工具上下文 sessionId 正确', async () => {
executedTools.length = 0;
recordedRequests.length = 0;
const hook = new ConfirmationHook(makeMockWindow(), null);
const engine = makeEngineWithHooks(
[toolCallEvent('read_file', { file_path: 'a.ts' }), textDoneEvent('done after tool')],
hook,
);
const output = await engine.runStream(userMessage, 'sess-chain-1', [], systemPrompt);
// 请求契约:LLM 请求必须携带工具定义(v0.5.2 回归点 —
// 真实模型只有收到 tools 才能发起 tool_call,缺失即"口头说调工具实际不调")
expect(recordedRequests.length).toBeGreaterThan(0);
expect(recordedRequests[0].tools).toBeDefined();
expect(recordedRequests[0].tools!.map((t) => t.name)).toContain('read_file');
// 循环完成:工具轮 → 文本轮
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
expect(output.finalAnswer).toBe('done after tool');
expect(output.iterations).toHaveLength(2);
// 工具真实执行,且 ToolExecutionContext.sessionId 传递正确
expect(executedTools).toHaveLength(1);
expect(executedTools[0].name).toBe('read_file');
expect(executedTools[0].sessionId).toBe('sess-chain-1');
// 工具结果成功回填到迭代记录
const toolResult = output.iterations[0].toolResults?.[0];
expect(toolResult?.success).toBe(true);
expect(toolResult?.result).toEqual({ ok: true, tool: 'read_file' });
// 无确认请求产生(SAFE 工具)
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
it('v0.6.2 回归:纯 tool_calls 轮(零文本零思考)后,下一轮请求的 tool 消息前必须有带 tool_calls 的 assistant', async () => {
// 会话停止根因(DeepSeek 400 "Messages with role 'tool' must be a response
// to a preceding message with 'tool_calls'"):模型纯工具调用轮不产生
// thought → 原实现跳过 assistant 消息 push → 孤立 tool 消息 → 下一轮 400。
// 契约断言:第二次 sendStream 收到的 messages 中,tool 消息的前一条
// 必须是带 tool_calls 的 assistant。
executedTools.length = 0;
recordedRequests.length = 0;
const hook = new ConfirmationHook(makeMockWindow(), null);
const engine = makeEngineWithHooks(
[toolCallEvent('read_file', { file_path: 'x.ts' }), textDoneEvent('finished')],
hook,
);
await engine.runStream(userMessage, 'sess-orphan-tool', [], systemPrompt);
expect(recordedRequests.length).toBe(2);
const second = recordedRequests[1].messages;
// 找到 tool 消息
const toolIdx = second.findIndex((m) => m.role === 'tool');
expect(toolIdx).toBeGreaterThan(0);
// 前一条必须是带 tool_calls 的 assistant(修复前这里是 user — 孤立 tool)
const prev = second[toolIdx - 1];
expect(prev.role).toBe('assistant');
expect(prev.toolCalls?.length).toBeGreaterThan(0);
// 且该 assistant 的 toolCalls id 与 tool 消息的 toolCallId 配对
const toolMsg = second[toolIdx];
expect(prev.toolCalls!.some((tc) => tc.id === toolMsg.toolResult!.toolCallId)).toBe(true);
});
it('HIGH 风险工具经用户批准后执行(pending → approve → 工具运行)', async () => {
executedTools.length = 0;
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const engine = makeEngineWithHooks(
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('approved and done')],
hook,
);
const runPromise = engine.runStream(userMessage, 'sess-chain-2', [], systemPrompt);
// 等待确认请求产生(引擎阻塞在 waitForConfirmation
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
const pending = hook.getPendingConfirmations();
// pending 携带正确的 sessionIdv0.5.1
expect(pending[0].sessionId).toBe('sess-chain-2');
expect(pending[0].toolName).toBe('run_command');
// 用户批准
hook.resolveConfirmation(pending[0].toolCallId, true, false, false);
const output = await runPromise;
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
// 工具在批准后执行
expect(executedTools).toHaveLength(1);
expect(executedTools[0].name).toBe('run_command');
expect(output.iterations[0].toolResults?.[0].success).toBe(true);
});
it('用户拒绝后工具以 Blocked 错误回传 LLM(循环继续,不崩溃)', async () => {
executedTools.length = 0;
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const engine = makeEngineWithHooks(
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('denied, continuing')],
hook,
);
const runPromise = engine.runStream(userMessage, 'sess-chain-3', [], systemPrompt);
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, false, false);
const output = await runPromise;
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
// 工具未执行
expect(executedTools).toHaveLength(0);
// 拒绝以 Blocked 错误回传(LLM 下一轮可见,可调整策略)
const toolResult = output.iterations[0].toolResults?.[0];
expect(toolResult?.success).toBe(false);
expect(toolResult?.error).toContain('User denied');
});
it('会话隔离端到端:会话 A 的拒绝记忆阻断 A 的后续调用,不影响会话 B', async () => {
executedTools.length = 0;
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// --- 会话 A 第 1 次:拒绝 + remember ---
const engineA1 = makeEngineWithHooks(
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('A1 done')],
hook,
);
const runA1 = engineA1.runStream(userMessage, 'sess-iso-a', [], systemPrompt);
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
const outA1 = await runA1;
expect(outA1.iterations[0].toolResults?.[0].error).toContain('User denied');
// --- 会话 A 第 2 次:拒绝记忆直接阻断(无新 pending、无 LLM 等待) ---
const engineA2 = makeEngineWithHooks(
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('A2 done')],
hook,
);
const outA2 = await engineA2.runStream(userMessage, 'sess-iso-a', [], systemPrompt);
expect(outA2.iterations[0].toolResults?.[0].error).toContain('previously denied');
// 记忆阻断不产生确认请求
expect(hook.getPendingConfirmations()).toHaveLength(0);
// --- 会话 B:同工具正常询问并批准(A 的拒绝记忆不影响 B) ---
const engineB = makeEngineWithHooks(
[toolCallEvent('run_command', { command: 'ls' }), textDoneEvent('B done')],
hook,
);
const runB = engineB.runStream(userMessage, 'sess-iso-b', [], systemPrompt);
await vi.waitFor(() => expect(hook.getPendingConfirmations()).toHaveLength(1));
const pendingB = hook.getPendingConfirmations();
expect(pendingB[0].sessionId).toBe('sess-iso-b');
hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false);
const outB = await runB;
expect(outB.iterations[0].toolResults?.[0].success).toBe(true);
expect(executedTools.filter((t) => t.sessionId === 'sess-iso-b')).toHaveLength(1);
// A 会话的工具始终未执行
expect(executedTools.filter((t) => t.sessionId === 'sess-iso-a')).toHaveLength(0);
});
it('工具抛出异常时以错误结果回传(不中断引擎循环)', async () => {
executedTools.length = 0;
const registry = new ToolRegistry();
// read_file 已有 PolicyEngine 策略(SAFE,免确认)— 此处注册抛错实现验证异常路径
registry.registerBuiltin({
definition: {
name: 'read_file',
description: 'always throws (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 5_000,
},
async execute() {
throw new Error('tool exploded');
},
});
const engine = new AgentLoopEngine(
{ maxIterations: 3 },
createMockAdapter([toolCallEvent('read_file', {}), textDoneEvent('recovered')]),
registry,
[new PermissionCheckHook(new PolicyEngine()), new RateLimitHook(100)],
[],
);
const output = await engine.runStream(userMessage, 'sess-chain-4', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
expect(output.finalAnswer).toBe('recovered');
const toolResult = output.iterations[0].toolResults?.[0];
expect(toolResult?.success).toBe(false);
expect(toolResult?.error).toContain('tool exploded');
});
it('PermissionCheckHook 拦截系统路径(read_file /etc 被策略拒绝)', async () => {
executedTools.length = 0;
const hook = new ConfirmationHook(makeMockWindow(), null);
const engine = makeEngineWithHooks(
[toolCallEvent('read_file', { file_path: '/etc/passwd' }), textDoneEvent('blocked path')],
hook,
);
const output = await engine.runStream(userMessage, 'sess-chain-5', [], systemPrompt);
expect(output.terminationReason).toBe(TerminationReason.COMPLETED);
// 工具未执行(被 deniedPatterns 拦截)
expect(executedTools).toHaveLength(0);
expect(output.iterations[0].toolResults?.[0].error).toContain('Blocked');
expect(output.iterations[0].toolResults?.[0].error).toContain('security policy');
});
});