/** * 测试:钉钉机器人未连接时,工具调用应该被阻止 * * Bug 复现: * - 当机器人未连接时,AI 仍能调用 dingtalkbot-* 工具 * - 工具调用会出现在 pi 会话日志中 * * 期望行为: * - 当机器人未连接时,dingtalkbot-* 工具调用被阻止 * - 不会产生正常的工具执行日志 */ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; // ============================================================================ // Mock 模块 // ============================================================================ // 保存所有注册的处理器 const handlers = new Map>(); const registeredTools = new Map(); const registeredCommands = new Map(); const allEventHandlers: { event: string; handler: Function }[] = []; function makeMockPi() { const on = vi.fn((event: string, handler: any) => { if (!handlers.has(event)) handlers.set(event, new Set()); handlers.get(event)!.add(handler); allEventHandlers.push({ event, handler }); }); return { on, registerTool: vi.fn((tool: any) => { registeredTools.set(tool.name, tool); }), registerCommand: vi.fn((name: string, opts: any) => { registeredCommands.set(name, opts); }), registerShortcut: vi.fn(), registerFlag: vi.fn(), getFlag: vi.fn(() => undefined), getActiveTools: vi.fn(() => Array.from(registeredTools.keys())), setActiveTools: vi.fn(), getAllTools: vi.fn(() => Array.from(registeredTools.values()).map(t => ({ name: t.name, label: t.label, description: t.description, })) ), sendUserMessage: vi.fn(), sendMessage: vi.fn(), exec: vi.fn(), }; } vi.mock('dingtalk-stream', () => { return { DWClient: class MockDWClient { private _connected = false; constructor(public config: any) {} registerCallbackListener(_topic: string, _handler: any) {} async connect() { this._connected = true; } disconnect() { this._connected = false; } }, TOPIC_ROBOT: '/v1.0/im/bot/messages/get', EventAck: { SUCCESS: { status: 'SUCCESS', message: 'OK' }, }, }; }); vi.mock('@mariozechner/pi-ai', () => ({ Type: new Proxy({}, { get: (_, prop: string) => (...args: any[]) => ({ type: String(prop).toLowerCase(), args, _mockType: prop, }), }), })); vi.mock('@mariozechner/pi-coding-agent', () => ({ Type: new Proxy({}, { get: (_, prop: string) => (...args: any[]) => ({ type: String(prop).toLowerCase(), args, _mockType: prop, }), }), })); // ============================================================================ // 辅助函数:触发工具调用事件 // ============================================================================ async function emitToolCall(event: any): Promise { const toolCallHandlers = handlers.get('tool_call'); if (!toolCallHandlers || toolCallHandlers.size === 0) return undefined; // 调用所有 tool_call 处理器,按顺序返回第一个非 undefined 的结果 for (const h of toolCallHandlers) { const result = await h(event); if (result !== undefined) return result; } return undefined; } // ============================================================================ // 测试 // ============================================================================ describe('钉钉机器人未连接时的工具调用行为', () => { let mockPi: ReturnType; beforeEach(async () => { // 重置状态 handlers.clear(); registeredTools.clear(); registeredCommands.clear(); allEventHandlers.length = 0; // 设置 session id process.env.PI_SESSION_ID = 'test-session-disconnected'; // 创建 mock mockPi = makeMockPi(); // 重置模块缓存 vi.resetModules(); // 动态加载扩展 const extModule = await import('../index.js'); await extModule.default(mockPi as any); }); afterEach(() => { delete process.env.PI_SESSION_ID; }); it('所有 4 个钉钉工具都已注册', () => { expect(registeredTools.has('dingtalkbot-attach')).toBe(true); expect(registeredTools.has('dingtalkbot-send')).toBe(true); expect(registeredTools.has('dingtalkbot-send-and-wait')).toBe(true); expect(registeredTools.has('dingtalkbot-cancel-wait')).toBe(true); }); it('已注册 tool_call 事件处理器(用于阻止工具调用)', () => { const toolCallHandlers = handlers.get('tool_call'); expect(toolCallHandlers).toBeDefined(); expect(toolCallHandlers!.size).toBeGreaterThan(0); }); describe('当机器人未连接时(默认状态)', () => { it('应该阻止 dingtalkbot-send-and-wait 工具调用', async () => { const result = await emitToolCall({ type: 'tool_call', toolName: 'dingtalkbot-send-and-wait', toolCallId: 'call-1', input: { message: '你好' }, }); expect(result).toBeDefined(); expect(result?.block).toBe(true); expect(result?.reason).toMatch(/未连接|连接/); }); it('应该阻止 dingtalkbot-send 工具调用', async () => { const result = await emitToolCall({ type: 'tool_call', toolName: 'dingtalkbot-send', toolCallId: 'call-2', input: { message: '你好' }, }); expect(result?.block).toBe(true); }); it('应该阻止 dingtalkbot-attach 工具调用', async () => { const result = await emitToolCall({ type: 'tool_call', toolName: 'dingtalkbot-attach', toolCallId: 'call-3', input: { paths: ['/tmp/test.txt'] }, }); expect(result?.block).toBe(true); }); it('应该阻止 dingtalkbot-cancel-wait 工具调用', async () => { const result = await emitToolCall({ type: 'tool_call', toolName: 'dingtalkbot-cancel-wait', toolCallId: 'call-4', input: {}, }); expect(result?.block).toBe(true); }); it('不应该影响非钉钉工具(如 bash)', async () => { const result = await emitToolCall({ type: 'tool_call', toolName: 'bash', toolCallId: 'call-5', input: { command: 'ls' }, }); // 不应该阻止非钉钉工具 expect(result?.block).toBeFalsy(); }); }); });