/**
* 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, legacy_path, status, created_at, updated_at)
VALUES (?, ?, 'public', NULL, ?, '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: '\n x\n\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: 'only' }]),
]);
// 旧布局的历史留档,必须照样读得到
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);
});
});