agent-manager-runtime-isolation.test.js 8.53 KB
/**
 * Unit Tests — 运行态下沉后的隔离(28 号设计 §6 批次 3,第 10/11/12 条)
 *
 * 这一批的两个失败模式**都不报错**,所以断言必须落在「两次调用算出的东西相不相同」上:
 *   - 本体共享:两家公司拿到的 prompt 必须**完全相同**(改一次 agent.md 全公司生效)
 *   - 运行态隔离:workDir / companyDir / companyContextPath 必须**互不相同**,
 *     且 sessionPool 的 key 必须不同 —— key 撞上就是两家公司复用同一个 CLI 会话、
 *     A 公司的上下文漏进 B 公司,两边照样成功返回。
 *
 * 用真实的 system/agents/ 目录(agent-locator 的根是常量,没有 env 覆盖),所以本体是
 * 真的建在注册表里的:id 固定为 `zz-iso-agent`,**本体不删**(28 号裁决:测试建出来的
 * agent 也是有价值的正式注册表条目,留着才能被复用、攒历史)。afterAll 只清**公司态**
 * ——它是 (agentId, companyId) 的运行痕迹,跟本体不是一层东西。
 * 顺带治了一个 flaky:本体被建了又删时,别的用例正在遍历整棵 `system/agents/`,
 * 撞上「列到了名字、进去时已经没了」→ ENOENT。本体不删之后这条竞态自然消失。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';

const AgentManager = require('../../src/core/agent-manager');
const { SessionPool } = require('../../src/core/session-pool');
const locator = require('../../services/agent-locator');
const { companyRootDir } = require('../../services/company-paths');

describe('AgentManager 运行态下沉 — 本体共享 / 公司与任务隔离', () => {
  const AGENT_ID = 'zz-iso-agent'; // zz- 前缀 = 测试建的注册表条目,一眼可辨
  const BODY_DIR = locator.bodyDir(AGENT_ID);
  const CO_A = 'zz-iso-co-a';
  const CO_B = 'zz-iso-co-b';
  const TASK_1 = 'run-1';
  const TASK_2 = 'run-2';

  let manager;

  beforeAll(() => {
    // 本体每次跑都按同样内容重写一遍:跨次跑复用同一份目录,但内容不随上一次的改动漂。
    // soul.md / config.json 一并写出,好让这份常驻条目在注册表里是**完整**的本体,
    // 而不是一个只有 agent.md 的半成品(missingBodyDefinition 会把它算成「缺定义」)。
    fs.mkdirSync(BODY_DIR, { recursive: true });
    fs.writeFileSync(path.join(BODY_DIR, 'agent.md'), '# 共享本体\n\n只有一份。\n', 'utf-8');
    fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# zz-iso-agent 的人格\n\n单测用的常驻本体。\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');
    manager = new AgentManager({}, locator.REPO_ROOT);
  });

  afterAll(() => {
    // 只清公司态(本体下的 {cid}/ 子树),本体目录本身留在注册表里
    for (const cid of [CO_A, CO_B]) {
      fs.rmSync(locator.companyDir(AGENT_ID, cid), { recursive: true, force: true });
    }
  });

  const configFor = (companyId, taskId) =>
    manager.getAgentConfig(`${companyId}:${AGENT_ID}`, { metadata: { taskId } }).agent;

  it('两家公司调同一个本体:prompt 同一份,运行态目录互不相同', () => {
    const a = configFor(CO_A, TASK_1);
    const b = configFor(CO_B, TASK_1);

    // 本体共享的证明
    expect(a.prompt).toBe(path.join(BODY_DIR, 'agent.md'));
    expect(b.prompt).toBe(a.prompt);

    // 运行态隔离
    expect(a.workDir).not.toBe(b.workDir);
    expect(a.companyDir).not.toBe(b.companyDir);
    expect(a.companyContextPath).not.toBe(b.companyContextPath);
    expect(a.companyDir).toBe(path.join(BODY_DIR, CO_A));
    expect(b.companyContextPath).toBe(path.join(BODY_DIR, CO_B, 'company-context.md'));
  });

  it('cwd 落在 system/agents/{aid}/{cid}/{taskId}/ 且已建出来(不存在则 CLI 起不来)', () => {
    const a = configFor(CO_A, TASK_1);

    expect(a.workDir).toBe(path.join(BODY_DIR, CO_A, TASK_1));
    expect(fs.existsSync(a.workDir)).toBe(true);
    // 本体在 cwd 的祖先路径上,恒隔两级:{aid}/{cid}/{taskId} → {aid}。
    // ⚠️ 设计稿 §4.1/§5.2 写的是 `../../../`,那是把结尾的斜杠也数成了一级 ——
    // 真值是 `../..`,坐标声明里的相对提示因此按 path.relative 实算,不照抄文档。
    expect(path.relative(a.workDir, a.bodyDir)).toBe(path.join('..', '..'));
  });

  it('同一个 agent 换一个 taskId:cwd 跟着换(缓存住第一个 taskId 是本批最容易漏的静默 bug)', () => {
    // 取字符串而不是持有对象:缓存命中返回的是**同一个**被就地改写的 config
    const firstWorkDir = configFor(CO_A, TASK_1).workDir;
    expect(firstWorkDir).toBe(path.join(BODY_DIR, CO_A, TASK_1));

    // 第二次走的是 this.config.agents 缓存命中那条路
    const secondWorkDir = configFor(CO_A, TASK_2).workDir;
    expect(secondWorkDir).toBe(path.join(BODY_DIR, CO_A, TASK_2));
    expect(fs.existsSync(secondWorkDir)).toBe(true);
  });

  it('缺 taskId 时落 _no-task,不拼出 undefined 段', () => {
    const a = manager.getAgentConfig(`${CO_A}:${AGENT_ID}`).agent;
    expect(a.workDir).toBe(path.join(BODY_DIR, CO_A, '_no-task'));
    expect(a.workDir).not.toContain('undefined');
  });

  it('sessionPool 的 key 按 (companyId, taskId) 分开', () => {
    const pool = new SessionPool();
    const a = { ...configFor(CO_A, TASK_1) };
    const b = { ...configFor(CO_B, TASK_1) };
    const a2 = { ...configFor(CO_A, TASK_2) };

    const keyA = pool.getKey(`${CO_A}:${AGENT_ID}`, a, a.workDir);
    const keyB = pool.getKey(`${CO_B}:${AGENT_ID}`, b, b.workDir);
    const keyA2 = pool.getKey(`${CO_A}:${AGENT_ID}`, a2, a2.workDir);

    expect(keyA).not.toBe(keyB);
    expect(keyA).not.toBe(keyA2);

    // key 不能只靠 workDir 恰好不同:legacy / scoped skill 模式的 cwd 里没有这两段,
    // 所以把 workDir 和 agentName 都固定住,仅靠 (companyId, taskId) 也必须分得开。
    const sameDirA = pool.getKey('x', { ...a, workDir: '/same' }, '/same');
    const sameDirB = pool.getKey('x', { ...b, workDir: '/same' }, '/same');
    expect(sameDirA).not.toBe(sameDirB);
  });

  it('scoped skill 的 cwd 是拥有它的**本体目录**,不是它下面的 .claude', () => {
    // `{body}/.claude/skills/{skill}/SKILL.md` 要剥 4 层才回到 body。剥 3 层会停在
    // `.claude` 上 —— CLI 于是在 `.claude/.claude/skills/` 里找 skill,永远找不到,
    // 但进程照起、输出照有,只是静默降级成没有 skill 的会话。
    const SKILL = 'zz-iso-skill';
    const skillDir = path.join(BODY_DIR, '.claude', 'skills', SKILL);
    fs.mkdirSync(skillDir, { recursive: true });
    fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# skill\n', 'utf-8');

    const s = manager.getAgentConfig(SKILL, { metadata: { taskId: TASK_1 } }).agent;
    expect(s.runtimeMode).toBe('skill');
    expect(s.workDir).toBe(BODY_DIR);
    expect(s.bodyDir).toBe(BODY_DIR);
    expect(s.agentId).toBe(AGENT_ID);
    expect(fs.existsSync(path.join(s.workDir, '.claude', 'skills', SKILL))).toBe(true);
  });

  it('提示词带三行运行时坐标,且交付物指向 company-paths 算出的公司 workspace', () => {
    const a = configFor(CO_A, TASK_1);
    const prompt = manager.buildFullPrompt(`${CO_A}:${AGENT_ID}`, '写点东西');

    expect(prompt).toContain('# 共享本体');
    expect(prompt).toContain(`你的工作目录(cwd):${a.workDir}`);
    expect(prompt).toContain(`你的本体文件:${a.bodyDir}`);
    expect(prompt).toContain(path.join(companyRootDir(CO_A), 'workspace'));
    // 交付物不能落在 cwd 里 —— 那是「会被清理」的草稿区
    expect(prompt).not.toContain(path.join(a.workDir, 'workspace'));
  });

  it('公司态的 user.md / memory 进提示词,且只进自己那家公司的', () => {
    const a = configFor(CO_A, TASK_1);
    fs.writeFileSync(path.join(a.companyDir, 'user.md'), '# 汇报给 A 公司的 CEO\n', 'utf-8');
    fs.mkdirSync(path.join(a.companyDir, 'memory'), { recursive: true });
    fs.writeFileSync(path.join(a.companyDir, 'memory', 'memory.md'), '记得 A 公司的事\n', 'utf-8');

    const promptA = manager.buildFullPrompt(`${CO_A}:${AGENT_ID}`, 'go');
    expect(promptA).toContain('汇报给 A 公司的 CEO');
    expect(promptA).toContain('记得 A 公司的事');

    configFor(CO_B, TASK_1);
    const promptB = manager.buildFullPrompt(`${CO_B}:${AGENT_ID}`, 'go');
    expect(promptB).not.toContain('记得 A 公司的事');
  });
});