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 成功
This commit is contained in:
@@ -10,7 +10,7 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/version-0.5.0-blue?style=flat-square" alt="Version" />
|
<img src="https://img.shields.io/badge/version-0.5.1-blue?style=flat-square" alt="Version" />
|
||||||
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
<img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="License" />
|
||||||
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
<img src="https://img.shields.io/badge/Electron-35-47848F?style=flat-square&logo=electron" alt="Electron" />
|
||||||
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
<img src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react" alt="React" />
|
||||||
@@ -865,7 +865,7 @@ npm run format # Prettier 格式化
|
|||||||
|
|
||||||
# ─── 测试 ─────────────────────────────────
|
# ─── 测试 ─────────────────────────────────
|
||||||
npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过)
|
npm test # 运行单元测试 (Vitest, 系统 Node — audit 套件因 better-sqlite3 ABI 自动跳过)
|
||||||
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 199 用例全执行, 含 SQLite 审计链哈希)
|
npm run test:electron # 运行全量单元测试 (Electron Node ABI, 215 用例全执行, 含 SQLite 审计链哈希 + 引擎工具链集成)
|
||||||
npm run test:watch # 测试监听模式
|
npm run test:watch # 测试监听模式
|
||||||
|
|
||||||
# ─── 构建 ─────────────────────────────────
|
# ─── 构建 ─────────────────────────────────
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* 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 携带正确的 sessionId(v0.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -22,6 +22,13 @@ export interface ConfirmationRequest {
|
|||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
riskLevel: string;
|
riskLevel: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
/**
|
||||||
|
* 发起确认的会话 ID(v0.5.1 新增)
|
||||||
|
* 主会话为 sessionId;SubAgent 委派的工具确认为 taskId。
|
||||||
|
* 前端确认弹框据此在会话 TERMINATED 时只清除该会话的请求,
|
||||||
|
* 避免并发会话场景下误清其他会话等待中的确认。
|
||||||
|
*/
|
||||||
|
sessionId?: string;
|
||||||
/**
|
/**
|
||||||
* 过期时间戳(ms),由 waitForConfirmation 注入,用于前端倒计时 UI。
|
* 过期时间戳(ms),由 waitForConfirmation 注入,用于前端倒计时 UI。
|
||||||
* 注意:beforeExecute 构造 request 时不带此字段,仅在 waitForConfirmation 中追加。
|
* 注意:beforeExecute 构造 request 时不带此字段,仅在 waitForConfirmation 中追加。
|
||||||
@@ -276,6 +283,7 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
args: pending.args ?? {},
|
args: pending.args ?? {},
|
||||||
riskLevel: pending.riskLevel ?? 'medium',
|
riskLevel: pending.riskLevel ?? 'medium',
|
||||||
reason: pending.reason ?? `Tool "${pending.toolName}" requires confirmation`,
|
reason: pending.reason ?? `Tool "${pending.toolName}" requires confirmation`,
|
||||||
|
sessionId: pending.sessionId,
|
||||||
expiresAt: pending.expiresAt,
|
expiresAt: pending.expiresAt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -385,6 +393,7 @@ export class ConfirmationHook implements PreToolHook {
|
|||||||
reason: def.requiresPermission
|
reason: def.requiresPermission
|
||||||
? `Tool "${toolCall.name}" requires permission (risk: ${def.riskLevel})`
|
? `Tool "${toolCall.name}" requires permission (risk: ${def.riskLevel})`
|
||||||
: `Tool "${toolCall.name}" has high risk level: ${def.riskLevel}`,
|
: `Tool "${toolCall.name}" has high risk level: ${def.riskLevel}`,
|
||||||
|
sessionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// 等待用户响应(带超时)
|
// 等待用户响应(带超时)
|
||||||
|
|||||||
@@ -356,19 +356,26 @@ export class TaskOrchestrator extends EventEmitter {
|
|||||||
/**
|
/**
|
||||||
* P2-10: 中断指定父会话派生的所有 SubAgent
|
* P2-10: 中断指定父会话派生的所有 SubAgent
|
||||||
* (用户中断会话时由 IPC abort handler 联动调用,消除"会话停了子任务还在跑")
|
* (用户中断会话时由 IPC abort handler 联动调用,消除"会话停了子任务还在跑")
|
||||||
|
*
|
||||||
|
* v0.5.1: 返回值从数量改为被中止的 taskId 列表 — 调用方需据此清理这些
|
||||||
|
* SubAgent 的 pending 工具确认(SubEngine 以 taskId 为 sessionId 写入
|
||||||
|
* ConfirmationHook,父会话的 clearPending(sessionId) 清不到它们;不清理
|
||||||
|
* 会导致中止后孤儿工具在用户补批时执行副作用)
|
||||||
*/
|
*/
|
||||||
abortByParent(parentSessionId: string): number {
|
abortByParent(parentSessionId: string): string[] {
|
||||||
let aborted = 0;
|
const abortedTaskIds: string[] = [];
|
||||||
for (const handle of this.activeSubAgents.values()) {
|
for (const handle of this.activeSubAgents.values()) {
|
||||||
if (handle.parentSessionId === parentSessionId && handle.status === 'running') {
|
if (handle.parentSessionId === parentSessionId && handle.status === 'running') {
|
||||||
handle.abort();
|
handle.abort();
|
||||||
aborted++;
|
abortedTaskIds.push(handle.taskId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (aborted > 0) {
|
if (abortedTaskIds.length > 0) {
|
||||||
log.info(`[Orchestrator] Aborted ${aborted} SubAgent(s) of session ${parentSessionId}`);
|
log.info(
|
||||||
|
`[Orchestrator] Aborted ${abortedTaskIds.length} SubAgent(s) of session ${parentSessionId}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return aborted;
|
return abortedTaskIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -137,7 +137,8 @@ function makeCtx(overrides: Record<string, unknown> = {}) {
|
|||||||
},
|
},
|
||||||
// v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制),
|
// v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制),
|
||||||
// mock 改用 EventEmitter 提供真实 on/emit 行为
|
// mock 改用 EventEmitter 提供真实 on/emit 行为
|
||||||
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn() }),
|
// v0.5.1: abortByParent 返回 taskId[](abortSession 据此清理 SubAgent pending 确认)
|
||||||
|
orchestrator: Object.assign(new EventEmitter(), { abortByParent: vi.fn(() => []) }),
|
||||||
confirmationHook: { clearPending: vi.fn() },
|
confirmationHook: { clearPending: vi.fn() },
|
||||||
reloadAdapter: vi.fn(() => true),
|
reloadAdapter: vi.fn(() => true),
|
||||||
...overrides,
|
...overrides,
|
||||||
|
|||||||
@@ -714,13 +714,19 @@ export function registerAgentHandlers(ctx: IPCContext): void {
|
|||||||
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
ipcMain.handle('agent:abortSession', async (_event, sessionId) => {
|
||||||
log.info('[AGENT] Abort:', sessionId);
|
log.info('[AGENT] Abort:', sessionId);
|
||||||
// P2-10: 联动中断该会话派生的所有 SubAgent(消除"会话停了子任务还在跑")
|
// P2-10: 联动中断该会话派生的所有 SubAgent(消除"会话停了子任务还在跑")
|
||||||
orchestrator.abortByParent(sessionId);
|
// v0.5.1: 记录被中止的 taskId — SubAgent 的 pending 确认以 taskId 为 sessionId,
|
||||||
|
// 需一并清理,否则中止后孤儿工具在用户补批残留弹框时会真实执行副作用
|
||||||
|
const abortedTaskIds = orchestrator.abortByParent(sessionId);
|
||||||
agentEngineManager.abort(sessionId);
|
agentEngineManager.abort(sessionId);
|
||||||
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
|
// MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中
|
||||||
await agentEngineManager.waitForAbort(sessionId);
|
await agentEngineManager.waitForAbort(sessionId);
|
||||||
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
// v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出
|
||||||
// v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
|
// v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认
|
||||||
confirmationHook.clearPending(sessionId);
|
confirmationHook.clearPending(sessionId);
|
||||||
|
// v0.5.1: 被中止 SubAgent 的 pending 确认一并拒绝(含 SubAgent 递归派生的孙任务)
|
||||||
|
for (const taskId of abortedTaskIds) {
|
||||||
|
confirmationHook.clearPending(taskId);
|
||||||
|
}
|
||||||
|
|
||||||
// TOOL 层:记录中断
|
// TOOL 层:记录中断
|
||||||
auditService.log({
|
auditService.log({
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ const metonaAPI = {
|
|||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
riskLevel: string;
|
riskLevel: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
sessionId?: string;
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
}>;
|
}>;
|
||||||
}>,
|
}>,
|
||||||
|
|||||||
@@ -166,6 +166,61 @@ describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => {
|
|||||||
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||||||
expect(fts).toBe(msg);
|
expect(fts).toBe(msg);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('删除 NULL content 消息不抛错且索引保持一致(truncateAfter 安全性)', () => {
|
||||||
|
// 模拟真实会话混合场景:user 文本 + assistant(NULL, 仅 tool_calls) + tool 结果 + assistant 文本
|
||||||
|
insertMessage('m7', 's3', 'user', 'ftsdelete-test 用户消息', 7000);
|
||||||
|
insertMessage('m8', 's3', 'assistant', null, 7100);
|
||||||
|
insertMessage('m9', 's3', 'tool', '工具结果包含 ftsdelete-test 关键词', 7200);
|
||||||
|
insertMessage('m10', 's3', 'assistant', '最终回答', 7300);
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO sessions (id, title, created_at, updated_at) VALUES ('s3', '会话三', 1, 1)`,
|
||||||
|
).run();
|
||||||
|
|
||||||
|
const ftsBefore = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number })
|
||||||
|
.c;
|
||||||
|
const msgBefore = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||||||
|
expect(ftsBefore).toBe(msgBefore);
|
||||||
|
|
||||||
|
// 删除 NULL content 行 — delete 触发器以 old.content=NULL 执行 'delete' 命令
|
||||||
|
expect(() => db.prepare(`DELETE FROM messages WHERE id = 'm8'`).run()).not.toThrow();
|
||||||
|
|
||||||
|
// 行数仍一致
|
||||||
|
const ftsAfter = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number })
|
||||||
|
.c;
|
||||||
|
const msgAfter = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||||||
|
expect(ftsAfter).toBe(msgAfter);
|
||||||
|
expect(ftsAfter).toBe(ftsBefore - 1);
|
||||||
|
|
||||||
|
// 索引仍可正常查询(未被 NULL 删除破坏)
|
||||||
|
const hit = sessionService.searchMessages('ftsdelete-test');
|
||||||
|
expect(hit.some((r: any) => r.sessionId === 's3')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('混合 NULL/非 NULL 的批量删除(truncateAfter 全场景)不抛错', () => {
|
||||||
|
// 再插入一批混合消息
|
||||||
|
insertMessage('m11', 's3', 'assistant', null, 8000);
|
||||||
|
insertMessage('m12', 's3', 'user', 'ftsdelete-test 第二轮', 8100);
|
||||||
|
insertMessage('m13', 's3', 'assistant', null, 8200);
|
||||||
|
|
||||||
|
// 批量删除(模拟 truncateMessagesAfter 的 rowid 范围删除)
|
||||||
|
expect(() =>
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`DELETE FROM messages WHERE session_id = 's3' AND rowid >= (SELECT rowid FROM messages WHERE id = 'm7')`,
|
||||||
|
)
|
||||||
|
.run(),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
|
// s3 全删后行数一致,其他会话索引不受影响
|
||||||
|
const fts = (db.prepare('SELECT COUNT(*) AS c FROM messages_fts').get() as { c: number }).c;
|
||||||
|
const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c;
|
||||||
|
expect(fts).toBe(msg);
|
||||||
|
const s3Hit = sessionService.searchMessages('ftsdelete-test');
|
||||||
|
expect(s3Hit.every((r: any) => r.sessionId !== 's3')).toBe(true);
|
||||||
|
// 其他会话仍可搜索
|
||||||
|
expect(sessionService.searchMessages('uniquekeyword').length).toBeGreaterThanOrEqual(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0)', () => {
|
describe.skipIf(!dbAvailable)('审计日志导出(v0.5.0)', () => {
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.4.1",
|
"version": "0.5.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.4.1",
|
"version": "0.5.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "metona-ai-desktop",
|
"name": "metona-ai-desktop",
|
||||||
"version": "0.5.0",
|
"version": "0.5.1",
|
||||||
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
"description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用",
|
||||||
"main": "dist-electron/main/main.js",
|
"main": "dist-electron/main/main.js",
|
||||||
"author": "Metona Team",
|
"author": "Metona Team",
|
||||||
|
|||||||
@@ -50,6 +50,12 @@ interface ConfirmationRequest {
|
|||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
riskLevel: string;
|
riskLevel: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
/**
|
||||||
|
* 发起确认的会话 ID(v0.5.1)
|
||||||
|
* 主会话为 sessionId;SubAgent 委派的工具确认为 taskId。
|
||||||
|
* 用于会话 TERMINATED 时只清除该会话的请求(并发会话互不干扰)。
|
||||||
|
*/
|
||||||
|
sessionId?: string;
|
||||||
/** 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
|
/** 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
}
|
}
|
||||||
@@ -164,19 +170,29 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
return cleanup;
|
return cleanup;
|
||||||
}, [refreshPending]);
|
}, [refreshPending]);
|
||||||
|
|
||||||
// ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATED(run 结束/abort)时清空前端 state =====
|
// ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATED(run 结束/abort)时清空该会话的前端 state =====
|
||||||
// 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步,
|
// 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步,
|
||||||
// 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。
|
// 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。
|
||||||
// 新会话 INIT 时也清空,防止上一会话的残留请求污染新会话 UI。
|
// 新会话 INIT 时也清空,防止上一会话的残留请求污染新会话 UI。
|
||||||
|
// v0.5.1: 按会话过滤 — 后端 pending 已按 sessionId 隔离(v0.5.0),
|
||||||
|
// 此前端点也只清除该会话的请求,并发会话等待中的确认不再被误清。
|
||||||
|
// selectedIds 中残留的已移除 id 无害(后端 resolveConfirmationsBatch 跳过不存在项,
|
||||||
|
// 展示计数已按 requests 收敛)。
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!window.metona?.agent?.onStateChange) return;
|
if (!window.metona?.agent?.onStateChange) return;
|
||||||
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
|
const unsubscribe = window.metona.agent.onStateChange((state: unknown) => {
|
||||||
const data = state as { state?: string; current?: string };
|
const data = state as { state?: string; current?: string; sessionId?: string };
|
||||||
const stateValue = data.state ?? data.current ?? '';
|
const stateValue = data.state ?? data.current ?? '';
|
||||||
// INIT: 新 run 开始(新会话或新消息);TERMINATED: run 结束(正常完成/abort/超时/死循环)
|
// INIT: 新 run 开始(新会话或新消息);TERMINATED: run 结束(正常完成/abort/超时/死循环)
|
||||||
if (stateValue === 'INIT' || stateValue === 'TERMINATED') {
|
if (stateValue === 'INIT' || stateValue === 'TERMINATED') {
|
||||||
setRequests([]);
|
const sid = data.sessionId;
|
||||||
setSelectedIds(new Set());
|
if (sid) {
|
||||||
|
setRequests((prev) => prev.filter((r) => r.sessionId !== sid));
|
||||||
|
} else {
|
||||||
|
// 无 sessionId 的兜底(理论上不出现):全清
|
||||||
|
setRequests([]);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return unsubscribe;
|
return unsubscribe;
|
||||||
@@ -306,9 +322,11 @@ export function ConfirmationDialog(): React.JSX.Element | null {
|
|||||||
.filter((v): v is number => typeof v === 'number')
|
.filter((v): v is number => typeof v === 'number')
|
||||||
.sort((a, b) => a - b)[0];
|
.sort((a, b) => a - b)[0];
|
||||||
|
|
||||||
const selectedCount = selectedIds.size;
|
// v0.5.1: 计数按当前 requests 收敛 — selectedIds 可能残留已被会话过滤移除的 id
|
||||||
|
// (后端 resolveConfirmationsBatch 对不存在项安全跳过,仅影响展示计数)
|
||||||
|
const selectedCount = requests.filter((r) => selectedIds.has(r.toolCallId)).length;
|
||||||
const totalCount = requests.length;
|
const totalCount = requests.length;
|
||||||
const allSelected = selectedCount === totalCount;
|
const allSelected = selectedCount === totalCount && totalCount > 0;
|
||||||
|
|
||||||
// 综合风险等级:取所有请求中最高的
|
// 综合风险等级:取所有请求中最高的
|
||||||
const highestRisk = requests.reduce<string>((highest, r) => {
|
const highestRisk = requests.reduce<string>((highest, r) => {
|
||||||
|
|||||||
Vendored
+6
@@ -448,6 +448,12 @@ interface MetonaConfirmationRequest {
|
|||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
riskLevel: string;
|
riskLevel: string;
|
||||||
reason: string;
|
reason: string;
|
||||||
|
/**
|
||||||
|
* 发起确认的会话 ID(v0.5.1 新增)
|
||||||
|
* 主会话为 sessionId;SubAgent 委派的工具确认为 taskId。
|
||||||
|
* 确认弹框据此在会话 TERMINATED 时只清除该会话的请求。
|
||||||
|
*/
|
||||||
|
sessionId?: string;
|
||||||
/** v0.3.2: 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
|
/** v0.3.2: 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */
|
||||||
expiresAt?: number;
|
expiresAt?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user