agent-manager-runtime-isolation.test.js
8.53 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
/**
* 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 公司的事');
});
});