/** * 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: '\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' }]), ]); 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); }); });