session-pool-scope-key.test.js 5.18 KB
/**
 * 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'));
  });
});