/** * Unit Tests — `options.sessionScope` 真的从 `AgentManager.callAgent` 一路传到 * `SessionPool.getKey`(27-P4 §4.2 补的那一维) * * ── 这条测试还的是哪笔债 ────────────────────────────────────────────── * [27-P4](../../../docs/todo/system-design/27-p4-parallel-and-artifacts.md) §4.2 * 设计了「图引擎并发调用同一 system agent 时按 `gr:{nodeRunId}` 传 scope,避免撞会话键」, * `SessionPool.getKey()` 的 `scope` 参数也确实早就在(`session-pool-scope-key.test.js` 锁的 * 正是 `SessionPool` 自己的存取一致性),但**从 `AgentManager._runViaProtocol` 到 * `SessionPool.getOrCreate` 这一段接线此前从未写过**——`callAgent` 的 options 解构里 * `sessionScope` 只是摆设,`_runViaProtocol` 两处 `getOrCreate` 调用都固定只传 5 个参数。 * * 后果:`executor-fanout.json` 这类骨架里 `{{orgNode}}-impl-a` / `{{orgNode}}-impl-b` * 引用同一个 `systemAgentId`,若真的被 `advanceBatch` 并发拾取(两者同一个 run, * `taskId` 都等于 `ctx.runId`),`sessionPool.getKey` 算出来的键完全相同——第二次调用会 * 复用第一次那个还在跑的 CLI 会话,两个"并行"分支实际上在同一个会话队列里排队执行, * 且不报错、不留痕迹。 * * 本测试只钉「接线通不通」,不追真实 CLI(用注册制协议工厂换一个内存桩,同 * `session-pool-scope-key.test.js` 的手法):真实 agent 目录 + 真实 `AgentManager.callAgent` * 全量跑一遍,`_runViaProtocol` 不做任何桩替换。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { EventEmitter } from 'events'; import fs from 'fs'; import path from 'path'; const AgentManager = require('../../src/core/agent-manager'); const { registerProtocol } = require('../../src/core/protocol-factory'); const locator = require('../../services/agent-locator'); /** 只满足 SessionPool.create() 用到的方法 + runPromptInSession 要的 sendPrompt。 */ let stubCounter = 0; class StubProtocol extends EventEmitter { constructor(agentInfo) { super(); this.agentInfo = agentInfo; this.isConnected = false; this.sessionId = null; } async connect() { this.isConnected = true; } async initialize() {} async newSession() { this.sessionId = `stub-session-${++stubCounter}`; } async sendPrompt() { return undefined; } // 立即"跑完",不 emit 任何流式事件 disconnect() { this.isConnected = false; } } beforeAll(() => registerProtocol('claude-cli', () => StubProtocol)); afterAll(() => registerProtocol('claude-cli', () => require('@mas/acp-core').ClaudeCliProtocol)); describe('AgentManager.callAgent — sessionScope 接线(27-P4 §4.2)', () => { const AGENT_ID = 'zz-scope-agent'; // zz- 前缀 = 测试建的注册表条目 const BODY_DIR = locator.bodyDir(AGENT_ID); const CO = 'zz-scope-co'; const ADDR = `${CO}:${AGENT_ID}`; const newManager = () => { const m = new AgentManager({}, locator.REPO_ROOT, {}); m._resolveLogSession = async () => null; m.hookRunner = { run: async () => {} }; return m; }; beforeAll(() => { fs.mkdirSync(BODY_DIR, { recursive: true }); fs.writeFileSync(path.join(BODY_DIR, 'agent.md'), '# 会话作用域测试本体\n', 'utf-8'); fs.writeFileSync(path.join(BODY_DIR, 'config.json'), JSON.stringify({ id: AGENT_ID, name: AGENT_ID, type: 'skill', status: 'active' }, null, 2), 'utf-8'); }); afterAll(() => { fs.rmSync(locator.companyDir(AGENT_ID, CO), { recursive: true, force: true }); }); it('不传 sessionScope:同一 run(同 taskId)两次调用撞上同一个会话(这是被 27-P4 §4.2 指出的问题本身)', async () => { const manager = newManager(); await manager.callAgent(ADDR, 'T1', { metadata: { taskId: 'run-1' } }); await manager.callAgent(ADDR, 'T2', { metadata: { taskId: 'run-1' } }); // 两次都落在同一个 taskId 下、没有 scope 区分 —— 会话池里只会有 1 个会话 expect(manager.sessionPool.sessions.size).toBe(1); }); it('传 sessionScope=gr:{nodeRunId}:同一 run 内两个并发节点各自拿到独立会话,互不覆盖', async () => { const manager = newManager(); const [a, b] = await Promise.all([ manager.callAgent(ADDR, 'impl-a', { metadata: { taskId: 'run-2' }, sessionScope: 'gr:node-a', }), manager.callAgent(ADDR, 'impl-b', { metadata: { taskId: 'run-2' }, sessionScope: 'gr:node-b', }), ]); expect(a.status).toBe('success'); expect(b.status).toBe('success'); // 同一个 taskId、不同 scope —— 两个会话都要留在池子里,不能有一个把另一个顶掉 expect(manager.sessionPool.sessions.size).toBe(2); const keys = [...manager.sessionPool.sessions.keys()]; expect(keys.some((k) => k.endsWith(':gr:node-a'))).toBe(true); expect(keys.some((k) => k.endsWith(':gr:node-b'))).toBe(true); }); it('断链复现:把 sessionScope 从 options 里去掉,上一条用例会退回到 1 个会话(反向验证这条测试真的在盯接线)', async () => { const manager = newManager(); await Promise.all([ manager.callAgent(ADDR, 'impl-a', { metadata: { taskId: 'run-3' } }), manager.callAgent(ADDR, 'impl-b', { metadata: { taskId: 'run-3' } }), ]); // 没有 scope 区分:并发的两次调用会挤在同一个会话键上 expect(manager.sessionPool.sessions.size).toBe(1); }); });