From c727ad075cefabf2a9424dca57b0398bb3af10e9 Mon Sep 17 00:00:00 2001 From: thzxx <1440196015@qq.com> Date: Fri, 21 Aug 2026 21:47:03 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20v0.5.1=20=E5=B7=A5=E5=85=B7=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E9=93=BE=E8=B7=AF=E5=A4=8D=E6=A3=80=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=20=E2=80=94=20SubAgent=20=E5=AD=A4=E5=84=BF=E5=B7=A5=E5=85=B7?= =?UTF-8?q?=E6=8B=A6=E6=88=AA=20+=20=E7=A1=AE=E8=AE=A4=E5=BC=B9=E6=A1=86?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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 成功 --- README.md | 4 +- .../__tests__/engine-toolchain.test.ts | 366 ++++++++++++++++++ electron/harness/hooks/confirmation-hook.ts | 9 + .../harness/orchestration/orchestrator.ts | 19 +- electron/ipc/__tests__/agent.test.ts | 3 +- electron/ipc/agent.ts | 8 +- electron/preload.ts | 1 + .../services/__tests__/session-search.test.ts | 55 +++ package-lock.json | 4 +- package.json | 2 +- src/components/ConfirmationDialog.tsx | 30 +- src/types/global.d.ts | 6 + 12 files changed, 488 insertions(+), 19 deletions(-) create mode 100644 electron/harness/agent-loop/__tests__/engine-toolchain.test.ts diff --git a/README.md b/README.md index 27309da..80ac60a 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@

- Version + Version License Electron React @@ -865,7 +865,7 @@ npm run format # Prettier 格式化 # ─── 测试 ───────────────────────────────── 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 # 测试监听模式 # ─── 构建 ───────────────────────────────── diff --git a/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts b/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts new file mode 100644 index 0000000..c6d6028 --- /dev/null +++ b/electron/harness/agent-loop/__tests__/engine-toolchain.test.ts @@ -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 => ({ + 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 { + 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): 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; 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'); + }); +}); diff --git a/electron/harness/hooks/confirmation-hook.ts b/electron/harness/hooks/confirmation-hook.ts index 7971cdf..cd18525 100644 --- a/electron/harness/hooks/confirmation-hook.ts +++ b/electron/harness/hooks/confirmation-hook.ts @@ -22,6 +22,13 @@ export interface ConfirmationRequest { args: Record; riskLevel: string; reason: string; + /** + * 发起确认的会话 ID(v0.5.1 新增) + * 主会话为 sessionId;SubAgent 委派的工具确认为 taskId。 + * 前端确认弹框据此在会话 TERMINATED 时只清除该会话的请求, + * 避免并发会话场景下误清其他会话等待中的确认。 + */ + sessionId?: string; /** * 过期时间戳(ms),由 waitForConfirmation 注入,用于前端倒计时 UI。 * 注意:beforeExecute 构造 request 时不带此字段,仅在 waitForConfirmation 中追加。 @@ -276,6 +283,7 @@ export class ConfirmationHook implements PreToolHook { args: pending.args ?? {}, riskLevel: pending.riskLevel ?? 'medium', reason: pending.reason ?? `Tool "${pending.toolName}" requires confirmation`, + sessionId: pending.sessionId, expiresAt: pending.expiresAt, }); } @@ -385,6 +393,7 @@ export class ConfirmationHook implements PreToolHook { reason: def.requiresPermission ? `Tool "${toolCall.name}" requires permission (risk: ${def.riskLevel})` : `Tool "${toolCall.name}" has high risk level: ${def.riskLevel}`, + sessionId, }; // 等待用户响应(带超时) diff --git a/electron/harness/orchestration/orchestrator.ts b/electron/harness/orchestration/orchestrator.ts index 44af6b1..b6ae8dc 100644 --- a/electron/harness/orchestration/orchestrator.ts +++ b/electron/harness/orchestration/orchestrator.ts @@ -356,19 +356,26 @@ export class TaskOrchestrator extends EventEmitter { /** * P2-10: 中断指定父会话派生的所有 SubAgent * (用户中断会话时由 IPC abort handler 联动调用,消除"会话停了子任务还在跑") + * + * v0.5.1: 返回值从数量改为被中止的 taskId 列表 — 调用方需据此清理这些 + * SubAgent 的 pending 工具确认(SubEngine 以 taskId 为 sessionId 写入 + * ConfirmationHook,父会话的 clearPending(sessionId) 清不到它们;不清理 + * 会导致中止后孤儿工具在用户补批时执行副作用) */ - abortByParent(parentSessionId: string): number { - let aborted = 0; + abortByParent(parentSessionId: string): string[] { + const abortedTaskIds: string[] = []; for (const handle of this.activeSubAgents.values()) { if (handle.parentSessionId === parentSessionId && handle.status === 'running') { handle.abort(); - aborted++; + abortedTaskIds.push(handle.taskId); } } - if (aborted > 0) { - log.info(`[Orchestrator] Aborted ${aborted} SubAgent(s) of session ${parentSessionId}`); + if (abortedTaskIds.length > 0) { + log.info( + `[Orchestrator] Aborted ${abortedTaskIds.length} SubAgent(s) of session ${parentSessionId}`, + ); } - return aborted; + return abortedTaskIds; } /** diff --git a/electron/ipc/__tests__/agent.test.ts b/electron/ipc/__tests__/agent.test.ts index 93a2bf5..07c7004 100644 --- a/electron/ipc/__tests__/agent.test.ts +++ b/electron/ipc/__tests__/agent.test.ts @@ -137,7 +137,8 @@ function makeCtx(overrides: Record = {}) { }, // v0.5.0: orchestrator 增加事件监听(SubAgent 可观测性广播/录制), // 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() }, reloadAdapter: vi.fn(() => true), ...overrides, diff --git a/electron/ipc/agent.ts b/electron/ipc/agent.ts index d39adb6..596fe93 100644 --- a/electron/ipc/agent.ts +++ b/electron/ipc/agent.ts @@ -714,13 +714,19 @@ export function registerAgentHandlers(ctx: IPCContext): void { ipcMain.handle('agent:abortSession', async (_event, sessionId) => { log.info('[AGENT] Abort:', sessionId); // P2-10: 联动中断该会话派生的所有 SubAgent(消除"会话停了子任务还在跑") - orchestrator.abortByParent(sessionId); + // v0.5.1: 记录被中止的 taskId — SubAgent 的 pending 确认以 taskId 为 sessionId, + // 需一并清理,否则中止后孤儿工具在用户补批残留弹框时会真实执行副作用 + const abortedTaskIds = orchestrator.abortByParent(sessionId); agentEngineManager.abort(sessionId); // MT-1 修复: 等待当前 run 完全结束再返回,防止用户立即重发时新消息卡在等待中 await agentEngineManager.waitForAbort(sessionId); // v0.3.0 修复: 清理所有等待中的工具确认,避免定时器泄漏和超时 toast 在新会话中弹出 // v0.5.0: 按会话清理 — 只拒绝被中断会话的 pending,不影响其他并发会话等待中的确认 confirmationHook.clearPending(sessionId); + // v0.5.1: 被中止 SubAgent 的 pending 确认一并拒绝(含 SubAgent 递归派生的孙任务) + for (const taskId of abortedTaskIds) { + confirmationHook.clearPending(taskId); + } // TOOL 层:记录中断 auditService.log({ diff --git a/electron/preload.ts b/electron/preload.ts index 1285683..77cd178 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -176,6 +176,7 @@ const metonaAPI = { args: Record; riskLevel: string; reason: string; + sessionId?: string; expiresAt?: number; }>; }>, diff --git a/electron/services/__tests__/session-search.test.ts b/electron/services/__tests__/session-search.test.ts index 38e897e..5dff880 100644 --- a/electron/services/__tests__/session-search.test.ts +++ b/electron/services/__tests__/session-search.test.ts @@ -166,6 +166,61 @@ describe.skipIf(!dbAvailable)('FTS5 会话内容搜索', () => { const msg = (db.prepare('SELECT COUNT(*) AS c FROM messages').get() as { c: number }).c; 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)', () => { diff --git a/package-lock.json b/package-lock.json index b403124..289d481 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "metona-ai-desktop", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "metona-ai-desktop", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "dependencies": { "@emotion/react": "^11.14.0", diff --git a/package.json b/package.json index a482eba..2662d1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "metona-ai-desktop", - "version": "0.5.0", + "version": "0.5.1", "description": "MetonaAI Desktop — 生产级通用 AI Agent 智能体桌面应用", "main": "dist-electron/main/main.js", "author": "Metona Team", diff --git a/src/components/ConfirmationDialog.tsx b/src/components/ConfirmationDialog.tsx index 16187e7..c8adc09 100644 --- a/src/components/ConfirmationDialog.tsx +++ b/src/components/ConfirmationDialog.tsx @@ -50,6 +50,12 @@ interface ConfirmationRequest { args: Record; riskLevel: string; reason: string; + /** + * 发起确认的会话 ID(v0.5.1) + * 主会话为 sessionId;SubAgent 委派的工具确认为 taskId。 + * 用于会话 TERMINATED 时只清除该会话的请求(并发会话互不干扰)。 + */ + sessionId?: string; /** 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */ expiresAt?: number; } @@ -164,19 +170,29 @@ export function ConfirmationDialog(): React.JSX.Element | null { return cleanup; }, [refreshPending]); - // ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATED(run 结束/abort)时清空前端 state ===== + // ===== 监听 Agent 状态变化:INIT(新 run 开始)或 TERMINATED(run 结束/abort)时清空该会话的前端 state ===== // 解决:abort 场景下后端 clearPending() 清空了 Map,但前端 requests state 不会自动同步, // 弹框会停留在已失效的请求上。用户操作后批量 IPC 返回 0 resolved,逻辑无害但 UX 差。 // 新会话 INIT 时也清空,防止上一会话的残留请求污染新会话 UI。 + // v0.5.1: 按会话过滤 — 后端 pending 已按 sessionId 隔离(v0.5.0), + // 此前端点也只清除该会话的请求,并发会话等待中的确认不再被误清。 + // selectedIds 中残留的已移除 id 无害(后端 resolveConfirmationsBatch 跳过不存在项, + // 展示计数已按 requests 收敛)。 useEffect(() => { if (!window.metona?.agent?.onStateChange) return; 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 ?? ''; // INIT: 新 run 开始(新会话或新消息);TERMINATED: run 结束(正常完成/abort/超时/死循环) if (stateValue === 'INIT' || stateValue === 'TERMINATED') { - setRequests([]); - setSelectedIds(new Set()); + const sid = data.sessionId; + if (sid) { + setRequests((prev) => prev.filter((r) => r.sessionId !== sid)); + } else { + // 无 sessionId 的兜底(理论上不出现):全清 + setRequests([]); + setSelectedIds(new Set()); + } } }); return unsubscribe; @@ -306,9 +322,11 @@ export function ConfirmationDialog(): React.JSX.Element | null { .filter((v): v is number => typeof v === 'number') .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 allSelected = selectedCount === totalCount; + const allSelected = selectedCount === totalCount && totalCount > 0; // 综合风险等级:取所有请求中最高的 const highestRisk = requests.reduce((highest, r) => { diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 8ad7947..703d76d 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -448,6 +448,12 @@ interface MetonaConfirmationRequest { args: Record; riskLevel: string; reason: string; + /** + * 发起确认的会话 ID(v0.5.1 新增) + * 主会话为 sessionId;SubAgent 委派的工具确认为 taskId。 + * 确认弹框据此在会话 TERMINATED 时只清除该会话的请求。 + */ + sessionId?: string; /** v0.3.2: 过期时间戳(ms),由后端 ConfirmationHook 注入,用于倒计时 */ expiresAt?: number; }