/** * 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 公司的事'); }); });