master-session.test.js 18.5 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';

/**
 * Master-Innovator 集成测试
 *
 * 验证 Master Session 的完整工具调用链路:
 *   1. Master 收到用户消息 → 解析为 orchestrate 工具调用
 *   2. orchestrate 返回编排计划
 *   3. Master 按计划调用 call_sub_agent
 *   4. 最终汇总结果回复用户
 *
 * 通过 mock AgentManager.callAgent 模拟 Master 的 Claude CLI 输出。
 */

const { MasterSession } = require('../../core/master-session');
const { ToolRegistry } = require('../../tools/tool-registry');
const { createOrchestrateTool } = require('../../tools/orchestrate-tool');
const { createCallAgentTool, createListAgentsTool } = require('../../tools/call-agent-tool');

// Mock logging
vi.mock('../../core/logging', () => ({
    getGlobalLogManager: () => ({
        initialized: false,
        getOrCreateSession: () => null,
    }),
    waitForGlobalLogManager: () => Promise.resolve(),
}));

// ─── 测试配置 ───

const testConfig = {
    agents: {
        master: { type: 'master-cli', client: 'claude-local', description: 'Master Agent' },
        coder: { type: 'executor', client: 'claude-local', description: '编写代码' },
        writer: { type: 'executor', client: 'claude-local', description: '创意写作' },
        verifier: { type: 'validator', client: 'claude-local', description: '代码验证' },
        'frontend-tester': { type: 'validator', client: 'claude-local', description: '前端测试' },
    },
    agentGroups: {
        development: {
            name: '软件开发',
            description: '编写和验证代码',
            default: true,
            agents: ['coder', 'verifier', 'frontend-tester'],
        },
        writing: {
            name: '创意写作',
            description: '写作和编辑',
            agents: ['writer'],
        },
    },
    clients: {
        'claude-local': { command: 'claude', args: [] },
    },
};

// ─── Helper ───

function createMockSession(callResponses) {
    let callIndex = 0;

    const session = new MasterSession(testConfig, '/tmp/test-master');

    // 直接 mock init 跳过真实 AgentManager 创建
    session._initialized = true;

    // 手动注册工具
    session.toolRegistry = new ToolRegistry();
    session.toolRegistry.registerAll([
        createOrchestrateTool(),
        createCallAgentTool(),
        createListAgentsTool(),
    ]);

    // Mock AgentManager
    const mockCallAgent = vi.fn().mockImplementation(async (agentName, prompt) => {
        if (agentName === 'master') {
            // Master 的模拟回复 — 按序返回预设响应
            const response = callResponses[callIndex++] || { output: '任务完成!' };
            return response;
        }
        // Sub Agent 的模拟回复
        return { output: `[${agentName}] 执行完成: ${prompt.substring(0, 50)}...` };
    });

    session.manager = {
        callAgent: mockCallAgent,
        closeAllSessions: vi.fn(),
        getAgentConfig: vi.fn().mockImplementation((name) => {
            const cfg = testConfig.agents[name];
            if (!cfg) throw new Error(`Agent "${name}" not found`);
            return cfg;
        }),
        listAgents: vi.fn().mockReturnValue(
            Object.entries(testConfig.agents).map(([name, cfg]) => ({ name, ...cfg }))
        ),
    };

    return { session, mockCallAgent };
}

// ─── 测试 ───

describe('Master Session — 工具调用集成测试', () => {
    afterEach(() => {
        vi.restoreAllMocks();
    });

    // ── 1. 直接回复(无工具调用) ──

    describe('直接回复', () => {
        it('should reply directly for simple questions without tool calls', async () => {
            const { session } = createMockSession([
                { output: 'Docker 是一个容器化平台,用于打包和运行应用。' },
            ]);

            const result = await session.handleMessage('Docker 是什么?');

            expect(result.type).toBe('reply');
            expect(result.output).toContain('Docker');
            expect(result.toolCalls).toHaveLength(0);
            expect(result.rounds).toBe(1);
        });
    });

    // ── 2. 单 Agent 调用(call_sub_agent) ──

    describe('单 Agent 调用', () => {
        it('should call sub agent directly and return result', async () => {
            const { session, mockCallAgent } = createMockSession([
                // Round 1: Master 调用 call_sub_agent
                {
                    output: `好的,我来帮你写一首诗。

[TOOL_CALL: call_sub_agent]
{
  "agent": "writer",
  "task": "写一首关于秋天的五言绝句"
}
[/TOOL_CALL]`,
                },
                // Round 2: Master 收到结果后汇总回复
                {
                    output: '这是 writer 创作的秋天诗:\n\n秋风起天末,万叶落纷纷。\n孤雁向南去,寒山映夕云。',
                },
            ]);

            const result = await session.handleMessage('帮我写一首关于秋天的诗');

            expect(result.type).toBe('reply');
            expect(result.rounds).toBe(2);
            expect(result.toolCalls).toHaveLength(1);
            expect(result.toolCalls[0].tool).toBe('call_sub_agent');
            expect(result.toolCalls[0].params.agent).toBe('writer');

            // 验证 Master 被调了 2 次(初始 + 工具结果反馈)
            expect(mockCallAgent).toHaveBeenCalledTimes(3); // 2 master + 1 writer

            // 验证 writer 确实被调用了
            const writerCall = mockCallAgent.mock.calls.find(c => c[0] === 'writer');
            expect(writerCall).toBeTruthy();
            expect(writerCall[1]).toContain('秋天');
        });
    });

    // ── 3. orchestrate + 多步骤 call_sub_agent ──

    describe('多 Agent 编排', () => {
        it('should orchestrate plan then call sub agents step by step', async () => {
            const { session, mockCallAgent } = createMockSession([
                // Round 1: Master 调用 orchestrate 获取编排计划
                {
                    output: `这是一个需要多 Agent 协作的任务,让我先获取编排计划。

[TOOL_CALL: orchestrate]
{
  "task": "开发一个 Python 命令行计算器",
  "agentGroup": "development"
}
[/TOOL_CALL]`,
                },
                // Round 2: Master 收到 plan,调用 coder
                {
                    output: `收到编排计划。按步骤执行,先调用 coder。

[TOOL_CALL: call_sub_agent]
{
  "agent": "coder",
  "task": "开发一个 Python 命令行计算器,支持加减乘除,处理除零和非法输入"
}
[/TOOL_CALL]`,
                },
                // Round 3: coder 完成,调用 verifier
                {
                    output: `coder 已完成代码编写。现在调用 verifier 验证。

[TOOL_CALL: call_sub_agent]
{
  "agent": "verifier",
  "task": "验证 Python 计算器:运行测试,检查除零处理和错误输入处理"
}
[/TOOL_CALL]`,
                },
                // Round 4: 全部通过,汇总回复
                {
                    output: `✅ 任务完成!

**计算器已开发并验证通过:**
- coder 创建了 calc.py,支持加减乘除
- verifier 验证通过,除零和非法输入都有正确处理

所有步骤已完成。`,
                },
            ]);

            const toolCalls = [];
            const result = await session.handleMessage('开发一个 Python 命令行计算器', {
                onToolCall: (tc) => toolCalls.push(tc),
            });

            // 验证最终结果
            expect(result.type).toBe('reply');
            expect(result.rounds).toBe(4);
            expect(result.output).toContain('任务完成');

            // 验证工具调用历史
            expect(result.toolCalls).toHaveLength(3); // orchestrate + coder + verifier
            expect(result.toolCalls[0].tool).toBe('orchestrate');
            expect(result.toolCalls[1].tool).toBe('call_sub_agent');
            expect(result.toolCalls[1].params.agent).toBe('coder');
            expect(result.toolCalls[2].tool).toBe('call_sub_agent');
            expect(result.toolCalls[2].params.agent).toBe('verifier');

            // 验证 orchestrate 返回了有效 plan
            const orchResult = result.toolCalls[0].result;
            expect(orchResult.status).toBe('success');
            expect(orchResult.result.status).toBe('success');
            expect(orchResult.result.plan.agentGroup).toBe('development');
            expect(orchResult.result.plan.steps.length).toBeGreaterThan(0);

            // 验证 onToolCall 回调被触发
            expect(toolCalls).toHaveLength(3);

            // 验证 Sub Agent 被实际调用
            const agentCalls = mockCallAgent.mock.calls.filter(c => c[0] !== 'master');
            expect(agentCalls.map(c => c[0])).toEqual(['coder', 'verifier']);
        });
    });

    // ── 4. 修复循环 ──

    describe('修复循环', () => {
        it('should handle validator failure → executor fix → re-validate', async () => {
            const { session, mockCallAgent } = createMockSession([
                // Round 1: 直接调 coder
                {
                    output: `[TOOL_CALL: call_sub_agent]
{
  "agent": "coder",
  "task": "写一个 sort 函数"
}
[/TOOL_CALL]`,
                },
                // Round 2: 调 verifier
                {
                    output: `[TOOL_CALL: call_sub_agent]
{
  "agent": "verifier",
  "task": "验证 sort 函数"
}
[/TOOL_CALL]`,
                },
                // Round 3: verifier "失败"(通过模拟 Sub Agent 返回失败结果),Master 让 coder 修复
                {
                    output: `verifier 报告了问题,让 coder 修复。

[TOOL_CALL: call_sub_agent]
{
  "agent": "coder",
  "task": "修复 sort 函数:空数组未处理"
}
[/TOOL_CALL]`,
                },
                // Round 4: 再次验证
                {
                    output: `[TOOL_CALL: call_sub_agent]
{
  "agent": "verifier",
  "task": "重新验证 sort 函数"
}
[/TOOL_CALL]`,
                },
                // Round 5: 完成
                {
                    output: '✅ sort 函数已修复并验证通过。',
                },
            ]);

            const result = await session.handleMessage('写一个排序函数');

            expect(result.type).toBe('reply');
            expect(result.rounds).toBe(5);

            // 验证工具调用:coder → verifier → coder(修复) → verifier(重验)
            const agents = result.toolCalls.map(tc => tc.params.agent);
            expect(agents).toEqual(['coder', 'verifier', 'coder', 'verifier']);
        });
    });

    // ── 5. list_agents 工具 ──

    describe('list_agents 工具', () => {
        it('should return available agents when Master calls list_agents', async () => {
            const { session } = createMockSession([
                {
                    output: `[TOOL_CALL: list_agents]
{}
[/TOOL_CALL]`,
                },
                {
                    output: '目前可用的 Agent 有:coder(编写代码)、writer(创意写作)、verifier(代码验证)。',
                },
            ]);

            const result = await session.handleMessage('有哪些 Agent 可用?');

            expect(result.type).toBe('reply');
            expect(result.toolCalls).toHaveLength(1);
            expect(result.toolCalls[0].tool).toBe('list_agents');

            const listResult = result.toolCalls[0].result.result;
            expect(listResult.agents.length).toBeGreaterThan(0);
            expect(listResult.agents.find(a => a.name === 'coder')).toBeTruthy();
            expect(listResult.groups.length).toBeGreaterThan(0);
        });
    });

    // ── 6. 未知工具处理 ──

    describe('错误处理', () => {
        it('should handle unknown tool gracefully', async () => {
            const { session } = createMockSession([
                {
                    output: `[TOOL_CALL: nonexistent_tool]
{"task": "test"}
[/TOOL_CALL]`,
                },
                {
                    output: '工具不存在,我直接回答你的问题。',
                },
            ]);

            const result = await session.handleMessage('测试');

            expect(result.type).toBe('reply');
            expect(result.toolCalls).toHaveLength(1);
            expect(result.toolCalls[0].result.status).toBe('error');
            expect(result.toolCalls[0].result.error).toContain('Unknown tool');
        });

        it('should handle nonexistent agent in call_sub_agent', async () => {
            const { session } = createMockSession([
                {
                    output: `[TOOL_CALL: call_sub_agent]
{
  "agent": "nonexistent_agent",
  "task": "test"
}
[/TOOL_CALL]`,
                },
                {
                    output: '该 Agent 不存在。',
                },
            ]);

            const result = await session.handleMessage('调用不存在的 agent');

            expect(result.type).toBe('reply');
            expect(result.toolCalls).toHaveLength(1);
            expect(result.toolCalls[0].result.status).toBe('error');
            expect(result.toolCalls[0].result.error).toContain('不存在');
        });

        it('should stop at max tool rounds', async () => {
            // 无限循环调用工具
            const infiniteResponses = Array(15).fill({
                output: `[TOOL_CALL: list_agents]
{}
[/TOOL_CALL]`,
            });

            const { session } = createMockSession(infiniteResponses);

            const result = await session.handleMessage('无限循环', {
                maxToolRounds: 3,
            });

            expect(result.type).toBe('max_rounds');
            expect(result.rounds).toBe(3);
        });
    });

    // ── 7. ToolRegistry 单元测试 ──

    describe('ToolRegistry', () => {
        it('should parse multiple tool calls from output', () => {
            const output = `
先做第一步:
[TOOL_CALL: orchestrate]
{"task": "test task"}
[/TOOL_CALL]

还有第二步:
[TOOL_CALL: call_sub_agent]
{"agent": "coder", "task": "write code"}
[/TOOL_CALL]
`;
            const calls = ToolRegistry.parseToolCalls(output);
            expect(calls).toHaveLength(2);
            expect(calls[0].tool).toBe('orchestrate');
            expect(calls[0].params.task).toBe('test task');
            expect(calls[1].tool).toBe('call_sub_agent');
            expect(calls[1].params.agent).toBe('coder');
        });

        it('should handle non-JSON params as raw task', () => {
            const output = `[TOOL_CALL: call_sub_agent]
just a plain text task
[/TOOL_CALL]`;
            const calls = ToolRegistry.parseToolCalls(output);
            expect(calls).toHaveLength(1);
            expect(calls[0].params.task).toBe('just a plain text task');
        });

        it('should return empty array for no tool calls', () => {
            const calls = ToolRegistry.parseToolCalls('Just a normal reply with no tools.');
            expect(calls).toHaveLength(0);
        });

        it('should generate tool prompt with all registered tools', () => {
            const registry = new ToolRegistry();
            registry.registerAll([
                createOrchestrateTool(),
                createCallAgentTool(),
                createListAgentsTool(),
            ]);

            const prompt = registry.generateToolPrompt();
            expect(prompt).toContain('orchestrate');
            expect(prompt).toContain('call_sub_agent');
            expect(prompt).toContain('list_agents');
            expect(prompt).toContain('[TOOL_CALL:');
        });
    });

    // ── 8. orchestrate planning 验证 ──

    describe('orchestrate planning', () => {
        it('should generate valid plan for development group', async () => {
            const tool = createOrchestrateTool();
            const result = await tool.execute(
                { task: '开发计算器', agentGroup: 'development' },
                { config: testConfig }
            );

            expect(result.status).toBe('success');
            expect(result.plan.agentGroup).toBe('development');
            expect(result.plan.steps.length).toBeGreaterThan(0);

            // executor 步骤
            const executorSteps = result.plan.steps.filter(s => s.type === 'executor');
            expect(executorSteps.length).toBe(1); // coder
            expect(executorSteps[0].agent).toBe('coder');

            // validator 步骤
            const validatorSteps = result.plan.steps.filter(s => s.type === 'validator');
            expect(validatorSteps.length).toBe(2); // verifier + frontend-tester

            // conditional 修复步骤
            const conditionalSteps = result.plan.steps.filter(s => s.action === 'conditional');
            expect(conditionalSteps.length).toBe(1);
        });

        it('should return error for nonexistent group', async () => {
            const tool = createOrchestrateTool();
            const result = await tool.execute(
                { task: 'test', agentGroup: 'nonexistent' },
                { config: testConfig }
            );

            expect(result.status).toBe('error');
            expect(result.message).toContain('不存在');
        });

        it('should use default group when not specified', async () => {
            const tool = createOrchestrateTool();
            const result = await tool.execute(
                { task: '开发功能' },
                { config: testConfig }
            );

            expect(result.status).toBe('success');
            expect(result.plan.agentGroup).toBe('development'); // default: true
        });
    });

    // ── 9. 事件发射验证 ──

    describe('事件发射', () => {
        it('should emit round and tool_call events', async () => {
            const { session } = createMockSession([
                {
                    output: `[TOOL_CALL: list_agents]
{}
[/TOOL_CALL]`,
                },
                { output: '完成' },
            ]);

            const events = { round: [], tool_call: [], tool_result: [] };
            session.on('round', (e) => events.round.push(e));
            session.on('tool_call', (e) => events.tool_call.push(e));
            session.on('tool_result', (e) => events.tool_result.push(e));

            await session.handleMessage('test');

            expect(events.round.length).toBe(2); // 2 rounds
            expect(events.tool_call.length).toBe(1);
            expect(events.tool_call[0].tool).toBe('list_agents');
            expect(events.tool_result.length).toBe(1);
        });
    });
});