agent-transcript.test.js
9.49 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
/**
* Unit Tests — agent-transcript.js + GET /api/graph-runs/:id/nodes/:nodeRunId/transcript
*
* 这套逻辑里三处「看着对、实际错」的地方都用真实踩过的形态钉住:
*
* ① listRoles 的目录前缀差一个 '-':agentCwd(cid, '') 经 path.join 会丢掉空的最后一段,
* 得到 `.../agents`(无尾斜杠),不补分隔符时切出来的角色名全带前导 '-'
* (实测 '-sdd-planning')。这条是回归测试。
* ② 路由取本体取错层:本体绑在**定义层**节点上,graph_run_nodes 里没有 system_agent_id
* 这一列(只有 def_node_id)。第一版直接读运行时行 → SqliteError: no such column。
* ③ 找不到文件时必须回 ok:false + 期望目录,**不能回空列表** ——
* 空列表会被读成「这个 agent 没说过话」,而真相是「我们没找到文件」。
*
* 隔离: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',
'../../services/agent-transcript',
'../../services/graph-run-sse',
'../../services/graph-engine',
'../../routes/graph-run',
];
const COMPANY = 'co-tx';
const ROLE = 'brd-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 */
function writeTranscript(company, role, lines) {
const repoRoot = path.resolve(__dirname, '../../..');
const cwd = path.join(repoRoot, 'data', 'companies', company, 'agents', role);
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);
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>' }]),
]);
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 切出干净的角色名(不带前导 - )', () => {
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);
expect(path.basename(dir).endsWith(`-agents-${ROLE}`)).toBe(true);
expect(path.basename(dir)).not.toContain('/');
});
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);
});
});