agent-transcript.test.js 11.9 KB
/**
 * 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);
  });
});