agent-transcript.test.js
11.9 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
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
/**
* Unit Tests — agent-transcript.js + GET /api/graph-runs/:id/nodes/:nodeRunId/transcript
*
* 这套逻辑里三处「看着对、实际错」的地方都用真实踩过的形态钉住:
*
* ① listRoles 的角色清单来自 `company_agents`,**不从目录名反推**(28 批次 4-20):
* cwd 下沉后目录名是 `-repo-system-agents-{role}-{cid}-{taskId}` 的扁平串,
* role / cid / taskId 三段全都可能自带 '-'(本用例的 brd-planning / co-tx / T-tx
* 三个都带),从中切角色名没有唯一解。旧实现靠「前缀切一刀」,那是靠
* 「角色名是最后一段」成立的,本次改造把这个前提推翻了。
* ② 路由取本体取错层:本体绑在**定义层**节点上,graph_run_nodes 里没有 system_agent_id
* 这一列(只有 def_node_id)。第一版直接读运行时行 → SqliteError: no such column。
* ③ 找不到文件时必须回 ok:false + 期望目录,**不能回空列表** ——
* 空列表会被读成「这个 agent 没说过话」,而真相是「我们没找到文件」。
*
* 外加一条:批次 4 之前落盘的 transcript(旧 cwd `data/companies/{cid}/agents/{role}`)
* 仍要能读到 —— 那些 jsonl 改不了,不读等于改造当天历史原文全部消失。
*
* 隔离:CLAUDE_PROJECTS_DIR 指向临时夹具(真实位置在用户 home 下,单测不可能在那造数据);
* 库走 GRAPH_DB_PATH。全程不读用户真实的 ~/.claude/projects。
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import express from 'express';
const TEST_DB_PATH = '/tmp/test-agent-transcript.sqlite';
const DB_FILES = [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`];
const MODULES = [
'../../services/graph-db',
'../../services/graph-service',
'../../services/org-graph-service',
'../../services/work-graph-db',
'../../services/company-paths',
// listRoles 走 company-service.listCompanyAgents(),它在模块加载时抓住 graph-db 的句柄 ——
// 不一起重载的话它会读到上一个用例留下的那个库
'../../services/company-service',
'../../services/agent-transcript',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../routes/graph-run',
];
const COMPANY = 'co-tx';
const ROLE = 'brd-planning';
const TASK_ID = 'T-tx';
// 旧布局那份历史 transcript 挂在这个角色上(它没被雇,所以只验 readEntries,不进 listRoles)
const LEGACY_ROLE = 'sdd-planning';
describe('agent-transcript:会话原文读取', () => {
let tx;
let wdb;
let engine;
let projectsRoot;
let runsDir;
let srv;
let base;
let runId;
let nodeRunId;
let win;
/** 往夹具里写一份 transcript(cwd 已下沉:system/agents/{role}/{cid}/{taskId}/) */
function writeTranscript(company, role, lines, taskId = TASK_ID) {
const locator = require('../../services/agent-locator');
return writeAt(locator.taskDir(role, company, taskId), lines);
}
/** 旧布局(批次 4 之前的 cwd)—— 那批 jsonl 改不了,只能照读 */
function writeLegacyTranscript(company, role, lines) {
const locator = require('../../services/agent-locator');
return writeAt(locator.legacyCompanyAgentDir(company, role), lines);
}
function writeAt(cwd, lines) {
const dir = path.join(projectsRoot, cwd.replace(/\//g, '-'));
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'sess-1.jsonl'), lines.map((l) => JSON.stringify(l)).join('\n'));
return dir;
}
const msg = (ts, role, content) => ({ timestamp: ts, type: role, message: { role, content } });
beforeAll(async () => {
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
projectsRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-projects-'));
runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tx-runs-'));
process.env.CLAUDE_PROJECTS_DIR = projectsRoot;
process.env.GRAPH_DB_PATH = TEST_DB_PATH;
process.env.GRAPH_RUNS_DIR = runsDir;
for (const m of MODULES) delete require.cache[require.resolve(m)];
tx = require('../../services/agent-transcript');
wdb = require('../../services/work-graph-db');
engine = require('../../services/graph-engine');
// 本体行(work_graph_nodes.system_agent_id 有外键)
const db = wdb.getDb();
const now = new Date().toISOString();
db.prepare(`
INSERT OR IGNORE INTO system_agents
(id, name, scope, owner_company_id, source_kind, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, 'global_agent', ?, 'active', ?, ?)
`).run(ROLE, ROLE, `system/agents/${ROLE}`, now, now);
// 雇佣关系:listRoles 的角色清单来自这张表(28 批次 4-19),没有这行就一个角色都列不出来
db.prepare(`
INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
VALUES (?, ?, ?, 'active', ?)
`).run(COMPANY, ROLE, ROLE, now);
const g = wdb.createWorkGraph({
companyId: COMPANY, taskId: 'T-tx',
nodes: [{ nodeName: 'n-brd', systemAgentId: ROLE }],
});
expect(g.ok).toBe(true);
const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: COMPANY, taskId: 'T-tx' });
runId = run.id;
engine.markRunReady(run.id);
engine.startRun(run.id, { mode: 'manual' });
engine.setAgentExecutor(async () => ({ status: 'success' }));
await engine.advanceOne(run.id);
engine.setAgentExecutor(null);
const node = wdb.getRunState(run.id).nodes[0];
nodeRunId = node.id;
win = { since: node.startedAt, until: node.completedAt };
expect(win.since).toBeTruthy();
expect(win.until).toBeTruthy();
// 三条落在窗口内、一条早于窗口(上一次调用的残留)
const inWin = win.since; // 边界值也要算在窗口内(>= / <=)
writeTranscript(COMPANY, ROLE, [
msg('2000-01-01T00:00:00.000Z', 'assistant', [{ type: 'text', text: '上一次调用说的话' }]),
msg(inWin, 'user', [{ type: 'text', text: '<env_status>\n <t>x</t>\n</env_status>\n真正的提示词' }]),
msg(inWin, 'assistant', [
{ type: 'thinking', thinking: '先读上游' },
{ type: 'text', text: '我先读 brd' },
{ type: 'tool_use', name: 'Read', input: { file_path: '/tmp/brd.md' } },
]),
msg(win.until, 'user', [{ type: 'tool_result', content: '文件内容…' }]),
// 整条就是一个 env_status → 这条不该留下任何片段
msg(inWin, 'user', [{ type: 'text', text: '<env_status>only</env_status>' }]),
]);
// 旧布局的历史留档,必须照样读得到
writeLegacyTranscript(COMPANY, LEGACY_ROLE, [
msg(inWin, 'assistant', [{ type: 'text', text: '批次 4 之前落的盘' }]),
]);
const app = express();
app.use(express.json());
app.use('/api/graph-runs', require('../../routes/graph-run'));
await new Promise((resolve) => {
srv = app.listen(0, '127.0.0.1', () => {
base = `http://127.0.0.1:${srv.address().port}`;
resolve();
});
});
});
afterAll(async () => {
if (srv) await new Promise((r) => srv.close(r));
for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
for (const d of [projectsRoot, runsDir]) { try { fs.rmSync(d, { recursive: true, force: true }); } catch {} }
delete process.env.CLAUDE_PROJECTS_DIR;
delete process.env.GRAPH_DB_PATH;
delete process.env.GRAPH_RUNS_DIR;
for (const m of MODULES) delete require.cache[require.resolve(m)];
});
it('① listRoles 的角色名来自 company_agents,不从扁平目录名反推', () => {
const roles = tx.listRoles(COMPANY);
// 角色名自带 '-':靠目录名切一刀的老写法在这里必然切歪
expect(roles.map((r) => r.role)).toEqual([ROLE]);
expect(roles[0].sessions).toBe(1);
});
it('目录名按「cwd 的 / 换成 -」推导,且落在 CLAUDE_PROJECTS_DIR 下', () => {
const dir = tx.transcriptDirFor(COMPANY, ROLE);
expect(dir.startsWith(projectsRoot)).toBe(true);
// 公司态目录(= 任务态目录的匹配前缀):…-system-agents-{role}-{cid}
expect(path.basename(dir).endsWith(`-agents-${ROLE}-${COMPANY}`)).toBe(true);
expect(path.basename(dir)).not.toContain('/');
// 真正装 jsonl 的是它下面按 taskId 分的那一个
expect(tx.transcriptDirsFor(COMPANY, ROLE).map((d) => path.basename(d)))
.toEqual([`${path.basename(dir)}-${TASK_ID}`]);
});
it('旧布局(批次 4 之前的 cwd)落的 transcript 仍然读得到', () => {
const out = tx.readEntries({ companyId: COMPANY, role: LEGACY_ROLE });
expect(out.ok).toBe(true);
expect(out.entries.flatMap((e) => e.parts).some((p) => p.text.includes('批次 4 之前落的盘'))).toBe(true);
});
it('③ 找不到目录时回 ok:false + 期望路径,不是空列表', () => {
const out = tx.readEntries({ companyId: COMPANY, role: 'never-ran' });
expect(out.ok).toBe(false);
expect(out.reason).toBe('no-transcript-dir');
expect(out.dir).toContain('never-ran'); // 用户能拿这个路径自己去看存不存在
expect(out.entries).toEqual([]);
});
it('窗口过滤:窗口外的条目不出现(含边界值算在内)', () => {
const all = tx.readEntries({ companyId: COMPANY, role: ROLE });
const windowed = tx.readEntries({ companyId: COMPANY, role: ROLE, ...win });
expect(all.windowed).toBe(false);
expect(windowed.windowed).toBe(true);
// 全量里有那条 2000 年的;窗口内没有
expect(all.entries.some((e) => e.ts === '2000-01-01T00:00:00.000Z')).toBe(true);
expect(windowed.entries.some((e) => e.ts === '2000-01-01T00:00:00.000Z')).toBe(false);
expect(windowed.entries.length).toBeGreaterThan(0);
});
it('env_status 被剥掉、剥了几处如实回报;整条都是 env_status 的不留', () => {
const out = tx.readEntries({ companyId: COMPANY, role: ROLE, ...win });
const texts = out.entries.flatMap((e) => e.parts.filter((p) => p.kind === 'text').map((p) => p.text));
expect(texts.join('\n')).not.toContain('env_status');
expect(texts.some((t) => t.includes('真正的提示词'))).toBe(true); // 同一条里的正文要留下
expect(texts.some((t) => t.includes('only'))).toBe(false); // 整条是 env_status 的不留
expect(out.strippedEnvStatus).toBe(2);
});
it('片段类型齐全:text / thinking / tool_use / tool_result 都能规范化', () => {
const out = tx.readEntries({ companyId: COMPANY, role: ROLE, ...win });
const kinds = new Set(out.entries.flatMap((e) => e.parts.map((p) => p.kind)));
expect([...kinds].sort()).toEqual(['text', 'thinking', 'tool_result', 'tool_use']);
const tool = out.entries.flatMap((e) => e.parts).find((p) => p.kind === 'tool_use');
expect(tool.name).toBe('Read');
expect(tool.text).toBe('/tmp/brd.md');
});
// ── 路由 ────────────────────────────────────────────────────────
it('② 路由能取到本体(它在定义层,不在 graph_run_nodes 上)', async () => {
const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/${nodeRunId}/transcript`);
expect(r.status).toBe(200);
const d = await r.json();
expect(d.ok).toBe(true);
expect(d.role).toBe(ROLE); // 取错层时这里会是 undefined
expect(d.windowed).toBe(true);
expect(d.window.since).toBe(win.since);
expect(d.entries.length).toBeGreaterThan(0);
});
it('节点不属于这个 run → 404(不要跨 run 读别人的原文)', async () => {
const r = await fetch(`${base}/api/graph-runs/${runId}/nodes/rn_not_mine/transcript`);
expect(r.status).toBe(404);
});
it('run 不存在 → 404', async () => {
const r = await fetch(`${base}/api/graph-runs/run_nope/nodes/${nodeRunId}/transcript`);
expect(r.status).toBe(404);
});
});