Files
metona-ai-desktop/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts
T
thzxx c727ad075c
CI / 产物编译验证 (push) Successful in 10m9s
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m4s
CI / 全量测试 (Electron ABI) (push) Failing after 5m24s
fix: v0.5.1 工具调用链路复检修复 — SubAgent 孤儿工具拦截 + 确认弹框会话隔离
背景:v0.5.0 发布后对工具调用链路(adapter 流式 tool_call → 引擎 PARSING →
preToolHooks 管道 → ToolRegistry → 结果回填)做全链路复检,发现并修复两处问题。

安全修复:
- SubAgent 中止时 pending 确认未清理(安全回归):SubEngine 以 taskId 为
  sessionId 写入 ConfirmationHook,abortSession 的 clearPending(sessionId)
  清不到它们。后果:中止会话后残留弹框若被补批,孤儿工具会真实执行副作用
  (v0.4.x 的全局清空反而能拦截)。修复:abortByParent 返回被中止的
  taskId 列表,abortSession 一并 clearPending(taskId)。

确认弹框会话隔离(前端对齐后端 v0.5.0 语义):
- ConfirmationRequest 新增 sessionId 字段(主会话为 sessionId,SubAgent 为
  taskId),弹框在会话 INIT/TERMINATED 时只清除该会话的请求 —— 修复并发
  会话下任意会话结束误清其他会话等待中确认的问题
- 选中计数按当前 requests 收敛(selectedIds 残留 id 无害化)

测试(207 → 215 用例):
- 新增引擎级工具调用链路集成测试 ×6(engine-toolchain.test.ts):真实
  PermissionCheckHook + RateLimitHook + ConfirmationHook 管道 + 真实
  ToolRegistry,覆盖 SAFE 工具直通 / HIGH 工具批准执行 / 拒绝以 Blocked
  错误回传 LLM / 会话隔离端到端 / 工具异常不中断循环 / 策略拦截系统路径
- 新增 FTS 触发器 × NULL content 删除安全性测试 ×2:验证 truncateAfter
  (编辑重发/重新生成)删除 content=NULL 的 assistant 消息(模型仅发
  tool_calls 的标准场景)不抛错且索引保持一致
- agent.test.ts 的 orchestrator mock 适配 abortByParent 新返回类型

验证: lint 0 problems / typecheck 双工程 0 errors / test:electron 215 全过 / build 成功
2026-08-21 21:47:03 +08:00

367 lines
13 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.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() },
}));
import type { BrowserWindow } from 'electron';
import { AgentLoopEngine } from '../engine';
import { TerminationReason } from '../types';
import type {
IMetonaProviderAdapter,
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 → 文本收尾) =====
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* (): AsyncIterable<MetonaStreamEvent> {
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'));
return new AgentLoopEngine(
{ maxIterations },
createMockAdapter(adapterScripts),
registry,
[new PermissionCheckHook(new PolicyEngine()), new RateLimitHook(100), hook],
[],
);
}
describe('AgentLoopEngine 工具调用链路(adapter → 引擎 → 真实 Hook 管道 → registry', () => {
it('SAFE 工具免确认直接执行:结果回填 + 工具上下文 sessionId 正确', async () => {
executedTools.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);
// 循环完成:工具轮 → 文本轮
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('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');
});
});