fix: v0.5.1 工具调用链路复检修复 — SubAgent 孤儿工具拦截 + 确认弹框会话隔离
CI / 产物编译验证 (push) Successful in 10m9s
CI / 类型检查 + Lint + 单元测试 (push) Failing after 6m4s
CI / 全量测试 (Electron ABI) (push) Failing after 5m24s

背景: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:
2026-08-21 21:47:03 +08:00
parent 7e8b4882a0
commit c727ad075c
12 changed files with 488 additions and 19 deletions
@@ -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', () => {