Files
metona-ai-desktop/electron/harness/hooks/__tests__/confirmation-hook.test.ts
T
thzxx 9b45c445bf
CI / 类型检查 + Lint + 单元测试 (push) Failing after 9m8s
CI / 全量测试 (Electron ABI) (push) Failing after 6m0s
CI / 产物编译验证 (push) Successful in 10m58s
feat: v0.8.1 记忆深化 · 观测闭环 · 体验收口 — 窗口/输出上限全局单一配置 · 2478 用例全量回归 + E2E 冒烟
硬性契约:删除代码中一切写死的上下文窗口与最大输出上限(含六家模型元信息
钳制与全部兜底值)——唯一合法来源是设置面板「上下文长度」(llm.contextWindow)
与「最大输出上限」(llm.maxTokens),跨 Provider/模型原样透传。

P0 正确性收口:
- 迁移 11/12(SCHEMA_VERSION 5):记忆表 embedding 列 + 分 Provider 窗口键清理
- 记忆生命周期接线:会话终态清理 working memory / episodic 90 天 TTL / access_count 回写
- 回放缓冲模块化 + 会话终态清理(杜绝 4MB/会话内存滞留)
- i18n 收口:主进程 main-locale(zh/en,ui.locale 热切换)+ 渲染层 17 处出层

P1 能力演进:
- 本地向量混合检索:0.6×向量余弦 + 0.4×TF-IDF,Ollama embeddings 首次投产,
  存量记忆惰性回填,嵌入不可用自动回退 TF-IDF
- MEMORY.md 维护闭环:固化去重消除截断盲区;两阶段维护(AI 建议 → 用户确认 →
  原子改写 + 语义记忆双轨同步 + 审计);>50KB 告警
- 可观测闭环:cacheTokens 引擎→前端透传(Token 面板命中率/成本行)+ 输入框
  上下文占用指示条
- MCP Prompts/Resources 对话可用:/mcp:{server}:{prompt} 与 @mcp:{server}:{uri}

P2 体验补全:
- 工具自定义策略(正则白/黑名单 + 频率 + 强制确认,热生效)
- 连续 ≥3 同类工具确认聚合为单弹框
- 会话消息游标分页(首屏 200 条向上翻页)
- 开机自启;Playwright + Electron E2E 冒烟(本地 mock LLM 零外联)

Review 回归修复:MCP 大小写失配 / 分页状态复位 / 清空=未配置语义(Number(null)=0
隐患)/ MEMORY.md 告警位置 / working_memories FK(迁移 13)/ 全局配置层废键清理;
附带根治权限加固启动时序、代理回环放行、safeStorage 降级、悬空 symlink 逃逸。

验证:typecheck/lint 0 问题;test:electron 2478/2478(0 跳过);E2E 2/2;
docs/v0.8.1-迭代实施清单.md 全项留档。
2026-09-08 09:35:58 +08:00

624 lines
24 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.
/**
* ConfirmationHook 单元测试(v0.4.1 测试补齐)
* 覆盖:自动执行放行、会话内记忆(批准/拒绝)、拒绝记忆 TTL 过期、
* 超时拒绝、用户批准、批量审批、pending 管理、恢复询问接口
*/
import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest';
import type { BrowserWindow } from 'electron';
import { ConfirmationHook } from '../confirmation-hook';
import type { MetonaToolCall, MetonaToolDef } from '../../types';
import { MetonaToolCategory, MetonaRiskLevel } from '../../types';
// v0.7.2 P2-7: ConfirmationHook 的请求分发升级为 BrowserWindow.getAllWindows()
// 全窗口广播 —— 测试环境(node vitest)下 electron 的 BrowserWindow 为 undefined
// 统一 mock 为可控的窗口数组(默认空数组 → 广播回退到注入的 mainWindow,与旧行为一致)。
const getAllWindowsMock = vi.fn((): BrowserWindow[] => []);
vi.mock('electron', () => ({
BrowserWindow: {
getAllWindows: () => getAllWindowsMock(),
},
}));
/** 需要确认的高风险工具定义 */
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: 1_000,
};
/** 低风险工具定义(无需确认) */
const SAFE_DEF: MetonaToolDef = {
name: 'read_file',
description: 'Read file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.SAFE,
requiresPermission: false,
timeoutMs: 1_000,
};
/** 第二个需确认的高风险工具(用于记忆互不干扰的测试) */
const HIGH_RISK_DEF_2: MetonaToolDef = {
name: 'delete_file',
description: 'Delete file (test fixture)',
parameters: { type: 'object', properties: {}, required: [] },
category: MetonaToolCategory.FILESYSTEM,
riskLevel: MetonaRiskLevel.HIGH,
requiresPermission: true,
timeoutMs: 1_000,
};
let idCounter = 0;
function makeToolCall(name = 'run_command'): MetonaToolCall {
return {
id: `tc_${++idCounter}`,
name,
args: { command: 'ls' },
iteration: 1,
timestamp: Date.now(),
};
}
function makeMockWindow(): BrowserWindow {
return {
isDestroyed: () => false,
webContents: { send: vi.fn() },
} as unknown as BrowserWindow;
}
beforeEach(() => {
// v0.7.2 P2-7: 每个用例前重置窗口数组 mock(默认无窗口 → 广播回退 mainWindow
getAllWindowsMock.mockReturnValue([]);
});
describe('ConfirmationHook — 多窗口广播(v0.7.2 P2-7', () => {
/** 带send 追踪的窗口(新测试用;makeMockWindow 保持原签名兼容既有用例) */
function makeTrackedWindow(destroyed = false): { win: BrowserWindow; send: Mock } {
const send = vi.fn();
const win = {
isDestroyed: () => destroyed,
webContents: { send },
} as unknown as BrowserWindow;
return { win, send };
}
it('确认请求广播到所有存活窗口(而非仅 mainWindow', async () => {
vi.useFakeTimers();
const a = makeTrackedWindow();
const b = makeTrackedWindow();
const destroyed = makeTrackedWindow(true);
getAllWindowsMock.mockReturnValue([a.win, destroyed.win, b.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
// v0.8.1 P2-2: 聚合窗口(800ms)结束才广播 —— 推进 fake timers
await vi.advanceTimersByTimeAsync(1000);
// 所有存活窗口均收到确认请求(携带 expiresAt 倒计时契约)
expect(a.send).toHaveBeenCalledWith(
'tool:confirmationRequest',
expect.objectContaining({ toolName: 'run_command', expiresAt: expect.any(Number) }),
);
expect(b.send).toHaveBeenCalledWith(
'tool:confirmationRequest',
expect.objectContaining({ toolName: 'run_command' }),
);
// 已销毁窗口不接收(防 send on destroyed webContents 抛错)
expect(destroyed.send).not.toHaveBeenCalled();
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p).blocked).toBe(false);
vi.useRealTimers();
});
it('getAllWindows 为空时回退到注入的 mainWindow(向后兼容)', async () => {
vi.useFakeTimers();
const mainWin = makeMockWindow();
getAllWindowsMock.mockReturnValue([]);
const hook = new ConfirmationHook(mainWin, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
// v0.8.1 P2-2: 推进聚合窗口后断言广播
await vi.advanceTimersByTimeAsync(1000);
expect((mainWin.webContents as unknown as { send: Mock }).send).toHaveBeenCalledWith(
'tool:confirmationRequest',
expect.objectContaining({ toolName: 'run_command' }),
);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p).blocked).toBe(false);
vi.useRealTimers();
});
it('全部窗口不可达时 fail-closed 阻断(no main window available', async () => {
const destroyed = makeTrackedWindow(true);
getAllWindowsMock.mockReturnValue([destroyed.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
});
it('超时提示同样广播到所有窗口(与确认请求同通道语义)', async () => {
vi.useFakeTimers();
try {
const a = makeTrackedWindow();
getAllWindowsMock.mockReturnValue([a.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000);
const p = hook.beforeExecute(makeToolCall(), 'sess');
vi.advanceTimersByTime(31_000);
await p;
expect(a.send).toHaveBeenCalledWith(
'toast:show',
expect.objectContaining({
type: 'warning',
message: expect.stringContaining('工具确认超时'),
}),
);
} finally {
vi.useRealTimers();
}
});
});
describe('ConfirmationHook — 免确认路径', () => {
it('未注册工具定义时放行(由 ToolRegistry 处理未知工具错误)', async () => {
const hook = new ConfirmationHook(null, null);
const result = await hook.beforeExecute(makeToolCall('unknown_tool'), 'sess');
expect(result.blocked).toBe(false);
});
it('无需确认的工具直接放行', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([SAFE_DEF]);
const result = await hook.beforeExecute(makeToolCall('read_file'), 'sess');
expect(result.blocked).toBe(false);
});
it('持久化自动执行(autoExecute)的工具放行', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setAutoExecute('run_command', true);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(false);
expect(hook.getAutoExecuteList()).toContain('run_command');
});
it('记住批准(remember approved)后同会话放行', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 第一次调用 → 等待确认 → 用户批准并记住
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
expect(pending).toHaveLength(1);
hook.resolveConfirmation(pending[0].toolCallId, true, true, false);
expect((await p1).blocked).toBe(false);
// 第二次调用 — 记住的批准直接放行
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(false);
});
});
describe('ConfirmationHook — 拒绝与阻断', () => {
it('记住拒绝后同会话阻断(reason 含 previously denied', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
hook.resolveConfirmation(pending[0].toolCallId, false, true, false);
expect((await p1).blocked).toBe(true);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('previously denied');
});
it('拒绝记忆 TTL 过期后恢复询问(v0.4.1', async () => {
vi.useFakeTimers();
try {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 记住拒绝
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const pending1 = hook.getPendingConfirmations();
hook.resolveConfirmation(pending1[0].toolCallId, false, true, false);
await p1;
// 拒绝记忆立即生效
const blockedNow = await hook.beforeExecute(makeToolCall(), 'sess');
expect(blockedNow.blocked).toBe(true);
expect(blockedNow.reason).toContain('previously denied');
// 快进 11 分钟(TTL 10 分钟)→ 拒绝记忆过期,恢复询问流程
vi.setSystemTime(Date.now() + 11 * 60 * 1000);
// 撤掉主窗口,使询问流程以 'no main window' 阻断(证明走到了询问分支而非记忆分支)
hook.setMainWindow(null as unknown as BrowserWindow);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
// 过期记忆已被清理
expect(hook.getRememberedDenials()).toHaveLength(0);
} finally {
vi.useRealTimers();
}
});
it('无主窗口时安全阻断(fail-closed', async () => {
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([HIGH_RISK_DEF]);
const result = await hook.beforeExecute(makeToolCall(), 'sess');
expect(result.blocked).toBe(true);
expect(result.reason).toContain('no main window available');
});
it('用户拒绝单次调用 → blocked 且 reason 含 User denied', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
const pending = hook.getPendingConfirmations();
hook.resolveConfirmation(pending[0].toolCallId, false, false, false);
const result = await p;
expect(result.blocked).toBe(true);
expect(result.reason).toContain('User denied');
});
});
describe('ConfirmationHook — 超时行为', () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
it('确认超时视为拒绝(blocked', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000); // 最小值 30s
const p = hook.beforeExecute(makeToolCall(), 'sess');
// 快进超过超时时间
vi.advanceTimersByTime(31_000);
const result = await p;
expect(result.blocked).toBe(true);
expect(result.reason).toContain('User denied');
// pending 已被超时清理
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
it('确认超时与用户点击的竞态:先到者赢(settled 标志)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
hook.setConfirmationTimeout(30_000);
const p = hook.beforeExecute(makeToolCall(), 'sess');
// timer 回调已入队但未执行时,用户点击批准
vi.advanceTimersByTime(30_000);
// 超时已 resolve(false) — 后续 resolveConfirmation 无效果
const pending = hook.getPendingConfirmations();
expect(pending).toHaveLength(0);
const result = await p;
expect(result.blocked).toBe(true);
});
});
describe('ConfirmationHook — 批量审批(v0.3.2', () => {
it('批量批准并行工具调用', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
expect(hook.getPendingConfirmations()).toHaveLength(2);
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
const resolved = hook.resolveConfirmationsBatch(ids, true, false, false);
expect(resolved).toHaveLength(2);
expect((await p1).blocked).toBe(false);
expect((await p2).blocked).toBe(false);
});
it('批量拒绝 + 记住 → 同工具后续调用被记忆阻断', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
hook.resolveConfirmationsBatch(ids, false, true, false);
expect((await p1).blocked).toBe(true);
expect((await p2).blocked).toBe(true);
const after = await hook.beforeExecute(makeToolCall(), 'sess');
expect(after.blocked).toBe(true);
expect(after.reason).toContain('previously denied');
});
it('批量批准 + autoExecute → 写入持久化自动执行列表', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
const ids = hook.getPendingConfirmations().map((r) => r.toolCallId);
hook.resolveConfirmationsBatch(ids, true, false, true);
expect((await p).blocked).toBe(false);
expect(hook.getAutoExecuteList()).toContain('run_command');
});
});
describe('ConfirmationHook — 拒绝记忆管理接口(v0.4.1', () => {
it('getRememberedDenials 只返回拒绝记忆(含剩余时间)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF, HIGH_RISK_DEF_2]);
// 记住一个批准(run_command)、一个拒绝(delete_file
const pApprove = hook.beforeExecute(makeToolCall('run_command'), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
await pApprove;
const pDeny = hook.beforeExecute(makeToolCall('delete_file'), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pDeny;
const denials = hook.getRememberedDenials();
expect(denials).toHaveLength(1);
expect(denials[0].toolName).toBe('delete_file');
expect(denials[0].expiresInSeconds).toBeGreaterThan(0);
expect(denials[0].expiresInSeconds).toBeLessThanOrEqual(600);
});
it('resetRememberedDenial 重置后恢复询问', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p = hook.beforeExecute(makeToolCall(), 'sess');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await p;
expect(hook.getRememberedDenials()).toHaveLength(1);
// 重置 → 拒绝记忆清空
expect(hook.resetRememberedDenial('run_command')).toBe(true);
expect(hook.getRememberedDenials()).toHaveLength(0);
// 后续调用恢复询问(有窗口 → 产生新 pending)
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
expect(hook.getPendingConfirmations()).toHaveLength(1);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await p2).blocked).toBe(false);
});
it('resetRememberedDenial 对无拒绝记忆的工具返回 false', () => {
const hook = new ConfirmationHook(null, null);
expect(hook.resetRememberedDenial('run_command')).toBe(false);
});
});
describe('ConfirmationHook — clearPending', () => {
it('清空所有等待中的确认(全部视为拒绝)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
const p1 = hook.beforeExecute(makeToolCall(), 'sess');
const p2 = hook.beforeExecute(makeToolCall(), 'sess');
hook.clearPending();
expect((await p1).blocked).toBe(true);
expect((await p2).blocked).toBe(true);
expect(hook.getPendingConfirmations()).toHaveLength(0);
});
});
describe('ConfirmationHook — 跨会话隔离(v0.5.0', () => {
it('A 会话记住拒绝,B 会话同工具仍正常询问(产生新 pending)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A 会话记住拒绝
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
expect((await pA).blocked).toBe(true);
// B 会话同工具 — 不受 A 会话拒绝记忆影响,进入询问流程(产生新 pending)
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
const pendingB = hook.getPendingConfirmations();
expect(pendingB).toHaveLength(1);
hook.resolveConfirmation(pendingB[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
// A 会话的拒绝记忆仍在(getRememberedDenials 限定 A 会话可见)
expect(hook.getRememberedDenials('sess-a')).toHaveLength(1);
expect(hook.getRememberedDenials('sess-b')).toHaveLength(0);
});
it('A 会话记住批准,B 会话同工具仍需确认', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A 会话记住批准
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, true, false);
expect((await pA).blocked).toBe(false);
// A 会话后续调用直接放行(无新 pending)
const again = await hook.beforeExecute(makeToolCall(), 'sess-a');
expect(again.blocked).toBe(false);
expect(hook.getPendingConfirmations()).toHaveLength(0);
// B 会话同工具 — 进入询问流程(批准记忆不跨会话共享)
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
expect(hook.getPendingConfirmations()).toHaveLength(1);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
});
it('中断 A 会话(clearPending 按会话)不影响 B 会话等待中的确认', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 两个会话各有一个等待中的确认
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
expect(hook.getPendingConfirmations()).toHaveLength(2);
// 中断 A 会话 — 只拒绝 A 的 pending
hook.clearPending('sess-a');
expect((await pA).blocked).toBe(true);
// B 会话的 pending 仍在等待,用户批准后正常放行
const remaining = hook.getPendingConfirmations();
expect(remaining).toHaveLength(1);
expect(remaining[0].toolCallId).toBeDefined();
hook.resolveConfirmation(remaining[0].toolCallId, true, false, false);
expect((await pB).blocked).toBe(false);
});
it('resetRememberedDenial 指定会话时只重置该会话的拒绝记忆', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// A、B 两个会话都记住拒绝
const pA = hook.beforeExecute(makeToolCall(), 'sess-a');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pA;
const pB = hook.beforeExecute(makeToolCall(), 'sess-b');
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await pB;
// 重置 A 会话的拒绝记忆 — B 会话的记忆保留
expect(hook.resetRememberedDenial('run_command', 'sess-a')).toBe(true);
expect(hook.getRememberedDenials('sess-a')).toHaveLength(0);
expect(hook.getRememberedDenials('sess-b')).toHaveLength(1);
// 缺省 sessionId — 重置所有会话
expect(hook.resetRememberedDenial('run_command')).toBe(true);
expect(hook.getRememberedDenials()).toHaveLength(0);
});
it('setAutoExecute 启用时清除所有会话的拒绝记忆(全局设置优先)', async () => {
const hook = new ConfirmationHook(makeMockWindow(), null);
hook.setToolDefs([HIGH_RISK_DEF]);
// 两个会话都记住拒绝
for (const sid of ['sess-a', 'sess-b']) {
const p = hook.beforeExecute(makeToolCall(), sid);
hook.resolveConfirmation(hook.getPendingConfirmations()[0].toolCallId, false, true, false);
await p;
}
expect(hook.getRememberedDenials()).toHaveLength(1); // 聚合去重后同一工具 1 条
// 启用自动执行 — 所有会话的拒绝记忆被清除
hook.setAutoExecute('run_command', true);
expect(hook.getRememberedDenials()).toHaveLength(0);
// 自动执行放行(任意会话)
expect((await hook.beforeExecute(makeToolCall(), 'sess-a')).blocked).toBe(false);
expect((await hook.beforeExecute(makeToolCall(), 'sess-b')).blocked).toBe(false);
});
});
// ===== v0.8.1 P2-2: 连续同类工具批量确认聚合 =====
describe('ConfirmationHook — 同类工具批量确认聚合(v0.8.1 P2-2', () => {
/** 单次 promptbeforeExecute 阻塞等待确认,不消费 promise) */
function startPrompt(hook: ConfirmationHook, id: string): Promise<unknown> {
return hook.beforeExecute(
{ id, name: 'run_command', args: {}, iteration: 1, timestamp: Date.now() },
'sess',
);
}
/** 本 describe 专用的 send 追踪窗口(makeTrackedWindow 定义在上一 describe 作用域) */
function makeWindow(): { win: BrowserWindow; send: Mock } {
const send = vi.fn();
const win = {
isDestroyed: () => false,
webContents: { send },
} as unknown as BrowserWindow;
return { win, send };
}
function makeRunCommandDef(): MetonaToolDef {
return {
...HIGH_RISK_DEF,
name: 'run_command',
requiresPermission: true,
};
}
it('3 个同类并行请求 → 聚合为单条 batch 事件(无逐条事件)', async () => {
vi.useFakeTimers();
const w = makeWindow();
getAllWindowsMock.mockReturnValue([w.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([makeRunCommandDef()]);
const prompts = [
startPrompt(hook, 'tc_1'),
startPrompt(hook, 'tc_2'),
startPrompt(hook, 'tc_3'),
];
// 达到阈值立即 flush
await vi.advanceTimersByTimeAsync(0);
const batchCalls = (w.send as Mock).mock.calls.filter(
(c) => c[0] === 'tool:confirmationRequestBatch',
);
expect(batchCalls).toHaveLength(1);
expect(
(batchCalls[0][1] as unknown[]).map((r) => (r as { toolCallId: string }).toolCallId),
).toEqual(['tc_1', 'tc_2', 'tc_3']);
// 不应再发送逐条事件
const individual = (w.send as Mock).mock.calls.filter(
(c) => c[0] === 'tool:confirmationRequest',
);
expect(individual).toHaveLength(0);
for (const id of ['tc_1', 'tc_2', 'tc_3']) {
hook.resolveConfirmation(id, true, false, false);
}
await Promise.all(prompts);
vi.useRealTimers();
});
it('2 个同类请求(低于阈值)→ 窗口结束逐条广播(原行为)', async () => {
vi.useFakeTimers();
const w = makeWindow();
getAllWindowsMock.mockReturnValue([w.win]);
const hook = new ConfirmationHook(null, null);
hook.setToolDefs([makeRunCommandDef()]);
const prompts = [startPrompt(hook, 'tc_a'), startPrompt(hook, 'tc_b')];
await vi.advanceTimersByTimeAsync(1000);
expect(
(w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequestBatch'),
).toHaveLength(0);
expect(
(w.send as Mock).mock.calls.filter((c) => c[0] === 'tool:confirmationRequest'),
).toHaveLength(2);
for (const id of ['tc_a', 'tc_b']) {
hook.resolveConfirmation(id, true, false, false);
}
await Promise.all(prompts);
vi.useRealTimers();
});
});