/** * Unit Tests — SessionPool 的 key 存取一致性(28 号第三轮补检查) * * 这条测试兜的是一个「不报错的性能塌方」: * `getOrCreate` 用**带 scope** 的 key 去池子里查,`create` 只要漏传 scope 就用 * **不带 scope** 的 key 存 —— 两个 key 永远对不上。查不到就再建一个,于是 * ① 池子里堆的全是查不到的死会话;② 每次调用都新起一个 CLI 子进程。 * 两个后果都不抛异常,只表现为莫名其妙的变慢 + 进程泄漏,靠人眼几乎看不出来。 * (这不是新引入的:原始版本的 getKey 就收 scope、getOrCreate 就传 scope, * 只有 create 一路没接住,属于从第一版起就埋着的裂缝。) * * 另外两组断言守 28 §6 的运行态隔离:key 必须按 (companyId, taskId) 分开, * 否则两家公司调同一个共享本体会复用同一个 CLI 会话、上下文互串且照样成功返回。 * * 全程不碰文件系统、不起子进程:把 protocol-factory 的 'claude-cli' 换成一个 * 纯内存桩(registerProtocol 是它自带的运行时扩展口子),afterAll 原样还回去。 */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { EventEmitter } from 'events'; const { SessionPool } = require('../../src/core/session-pool'); const { registerProtocol } = require('../../src/core/protocol-factory'); /** 只满足 create() 用到的那几个方法,不 spawn 任何东西 */ 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}`; } disconnect() { this.isConnected = false; } } // create() 只有 protocolName === 'claude-cli' 这一支不去建 launcher/transport, // 所以桩必须挂在这个名字上。 beforeAll(() => registerProtocol('claude-cli', () => StubProtocol)); afterAll(() => registerProtocol('claude-cli', () => require('@mas/acp-core').ClaudeCliProtocol)); const CFG = { client: 'claude-local', deploy: 'local', companyId: 'zz-tenant', taskId: 'task-1' }; const WORK_DIR = '/tmp/session-pool-scope-key'; describe('SessionPool — key 存取一致性', () => { it('用 scope 存进去的会话,用同一个 scope 取得回来(透传断链就红)', async () => { const pool = new SessionPool(); const scope = 'conv-42'; const created = await pool.create('zz-agent', CFG, null, 'claude-cli', WORK_DIR, scope); // ① 存的位置 = getKey(…, scope) 算出来的位置 expect(created.key).toBe(pool.getKey('zz-agent', CFG, WORK_DIR, scope)); expect(pool.sessions.get(pool.getKey('zz-agent', CFG, WORK_DIR, scope))).toBe(created); // ② 同 scope 再取,必须命中同一个会话对象,而不是又建一个 const reused = await pool.getOrCreate('zz-agent', CFG, null, 'claude-cli', WORK_DIR, scope); expect(reused).toBe(created); expect(pool.sessions.size).toBe(1); }); it('不同 scope 各占一个槽位,互不覆盖', async () => { const pool = new SessionPool(); const a = await pool.getOrCreate('zz-agent', CFG, null, 'claude-cli', WORK_DIR, 'conv-a'); const b = await pool.getOrCreate('zz-agent', CFG, null, 'claude-cli', WORK_DIR, 'conv-b'); expect(a.key).not.toBe(b.key); expect(pool.sessions.size).toBe(2); // 各自还能按自己的 scope 取回来(互相没把对方挤掉) expect(await pool.getOrCreate('zz-agent', CFG, null, 'claude-cli', WORK_DIR, 'conv-a')).toBe(a); expect(await pool.getOrCreate('zz-agent', CFG, null, 'claude-cli', WORK_DIR, 'conv-b')).toBe(b); }); it('不传 scope 也不崩,且照样存取一致(生产现有三个调用方走的就是这条路)', async () => { const pool = new SessionPool(); const created = await pool.getOrCreate('zz-agent', CFG, null, 'claude-cli', WORK_DIR); expect(created.key).toBe(pool.getKey('zz-agent', CFG, WORK_DIR)); expect(created.key).not.toMatch(/undefined/); expect(await pool.getOrCreate('zz-agent', CFG, null, 'claude-cli', WORK_DIR)).toBe(created); expect(pool.sessions.size).toBe(1); // 不传 scope 的 key 不能和任何带 scope 的 key 撞上 expect(pool.getKey('zz-agent', CFG, WORK_DIR, 'conv-a')).not.toBe(created.key); }); it('key 按 (companyId, taskId) 分开:换公司 / 换任务都换 key,同一对则同 key', () => { const pool = new SessionPool(); const key = (companyId, taskId) => pool.getKey('zz-agent', { ...CFG, companyId, taskId }, WORK_DIR); expect(key('co-a', 't1')).toBe(key('co-a', 't1')); // 同一对 → 同 key(会话可复用) expect(key('co-a', 't1')).not.toBe(key('co-b', 't1')); // 换公司 → 换 key expect(key('co-a', 't1')).not.toBe(key('co-a', 't2')); // 换任务 → 换 key // 两段都缺省时用保留值兜底,不能塌成 undefined 拼出来的同一个 key const bare = pool.getKey('zz-agent', { client: 'c', deploy: 'd' }, WORK_DIR); expect(bare).toContain('_global/_no-task'); expect(bare).not.toBe(key('co-a', 't1')); }); });