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