prompt-write-boundary.test.js 11 KB
/**
 * 28 §4.4(二轮修正)—— 交付物归宿按链路分叉,同一段提示词里只准有一条「你必须写到」。
 *
 * ── 锁的是哪个静默失败 ──────────────────────────────────────────────────
 * 一轮把 §4.4 的「交付物必须写到 {companyRoot}/workspace/」无差别塞进了所有链路的坐标声明,
 * 于是 graph 节点的提示词里同时出现两条互相排斥的指令:
 *   ① 坐标声明:交付物写 data/companies/{cid}/workspace/
 *   ② graph-engine.describeWriteBoundary:只准写项目根 / 节点私有目录,
 *      「禁止写这两处之外的任何路径,尤其是 REPO_ROOT」——而 workspace 正在 REPO_ROOT 下
 * agent 听①:产物进不了 graph_files(indexNodeOutputs 只扫 runs/{rootRunId}/nodes/{nodeRunId}/),
 * 还可能被 applyBoundaryGuard 判越界;听②:①那行是废话。**两种都不报错**,
 * 所以断言只能落在「提示词里这类指令出现了几次」上。
 *
 * ── 为什么用真跑一遍 graph 拿任务描述 ───────────────────────────────────
 * 分叉判据是「任务描述里已经带了 graph 的写入边界段」(prompt-builder.taskCarriesGraphWriteBoundary)。
 * 手写一段假的任务描述只能证明判据函数自己自洽,证明不了 graph-engine 真的产出那个段头 ——
 * 而这两侧一旦漂移,症状恰好就是分叉失效。describeWriteBoundary 按本仓约定不导出
 * (见 graph-engine module.exports 那段注释),所以走真入口 advanceOne 把 ctx.task 抓回来。
 *
 * 隔离手法同 graph-boundary.test.js:GRAPH_DB_PATH / GRAPH_RUNS_DIR 先设再 require,
 * 仓库 data/ 全程不被碰;agent 调用走注入的假 executor,不起 Claude CLI。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const AgentManager = require('../../src/core/agent-manager');
const locator = require('../../services/agent-locator');
const { companyWorkspaceDir, GRAPH_WRITE_BOUNDARY_HEADER } = require('../../src/core/prompt-builder');

/**
 * 「你必须把交付物写到 X」这类**归宿指令**的计数器。
 *
 * 两条链路各有各的措辞,都收进来:前者是坐标声明(agent-chat 链路)的,
 * 后者是 describeWriteBoundary(graph 链路)的。同一段提示词里命中两次 = 给了 agent
 * 两个家,就是本文件要挡的那个 bug。
 */
const WRITE_MANDATE = /(你的交付物必须写到|一切代码 \/ 测试 \/ 配置 \/ 交付物都写在)/g;
const countMandates = (s) => (s.match(WRITE_MANDATE) || []).length;

describe('提示词的交付物归宿:graph 节点 vs agent-chat 两条链路', () => {
  const TEST_DB_PATH = '/tmp/test-prompt-write-boundary.sqlite';
  // 常驻注册表条目(28 号裁决:测试建的本体不删),id 固定;内容每次重写不漂
  const AGENT_ID = 'zz-wb-agent';
  const BODY_DIR = locator.bodyDir(AGENT_ID);
  const COMPANY_ID = 'zz-wb-co';

  let wdb;
  let engine;
  let manager;
  let runsDir;

  /** 真跑一遍 graph 抓回来的节点任务描述(含 describeWriteBoundary 那段) */
  let graphTask;
  /** graph 链路 / agent-chat 链路各自的完整提示词 */
  let graphPrompt;
  let chatPrompt;
  /** 本次调用的 cwd(= system/agents/{aid}/{cid}/{taskId}/) */
  let graphCwd;
  let chatCwd;

  beforeAll(async () => {
    for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
      try { fs.unlinkSync(f); } catch {}
    }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'prompt-wb-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of [
      '../../services/graph-db',
      '../../services/graph-service',
      '../../services/org-graph-service',
      '../../services/work-graph-db',
      '../../services/graph-run-sse',
      '../../services/graph-engine',
    ]) {
      delete require.cache[require.resolve(m)];
    }
    wdb = require('../../services/work-graph-db');
    engine = require('../../services/graph-engine');

    // 本体(agent-locator 的根是常量、没有 env 覆盖,所以建在真的注册表里,跑完不删)
    fs.mkdirSync(BODY_DIR, { recursive: true });
    fs.writeFileSync(path.join(BODY_DIR, 'agent.md'), '# zz-wb-agent\n\n单测用的常驻本体。\n', 'utf-8');
    fs.writeFileSync(path.join(BODY_DIR, 'soul.md'), '# zz-wb-agent 的人格\n\n只写该写的地方。\n', 'utf-8');
    fs.writeFileSync(path.join(BODY_DIR, 'config.json'),
      JSON.stringify({ id: AGENT_ID, name: AGENT_ID, type: 'skill', status: 'active' }, null, 2), 'utf-8');
    manager = new AgentManager({}, locator.REPO_ROOT);

    // ① 真跑一个单节点 run,把节点收到的任务描述抓回来
    const db = wdb.getDb();
    const ts = 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(AGENT_ID, AGENT_ID, `system/agents/${AGENT_ID}`, ts, ts);

    const g = wdb.createWorkGraph({
      companyId: COMPANY_ID,
      nodes: [{ nodeName: 'impl', systemAgentId: AGENT_ID }],
      edges: [],
    });
    expect(g.errors || []).toEqual([]);
    const run = wdb.createRun({ graphKind: 'work', graphId: g.id, companyId: COMPANY_ID });
    engine.markRunReady(run.id);
    engine.setAgentExecutor(async (ctx) => {
      graphTask = ctx.task;
      return { status: 'success' };
    });
    engine.startRun(run.id, { mode: 'manual' });
    await engine.advanceOne(run.id);
    engine.setAgentExecutor(null);

    // ② 用同一份任务描述过一遍真正的提示词装配(graph-engine → agent-manager → PromptBuilder)
    const addr = `${COMPANY_ID}:${AGENT_ID}`;
    graphCwd = manager.getAgentConfig(addr, { metadata: { taskId: run.id } }).agent.workDir;
    graphPrompt = manager.buildFullPrompt(addr, graphTask);

    // ③ agent-chat 链路:同一个本体、同一家公司,只是任务描述里没有写入边界段
    //    (裁决 A 之后 convId 充当 taskId,这里用一个 conv-* 形态的值)
    chatCwd = manager.getAgentConfig(addr, { metadata: { taskId: 'conv-wb-1' } }).agent.workDir;
    chatPrompt = manager.buildFullPrompt(addr, '帮我写一份本周复盘');
  });

  afterAll(() => {
    for (const f of [TEST_DB_PATH, `${TEST_DB_PATH}-shm`, `${TEST_DB_PATH}-wal`]) {
      try { fs.unlinkSync(f); } catch {}
    }
    delete process.env.GRAPH_DB_PATH;
    try { fs.rmSync(runsDir, { recursive: true, force: true }); } catch {}
    delete process.env.GRAPH_RUNS_DIR;
    // 只清公司态(本次跑的 cwd 都在它下面),本体留在注册表里
    fs.rmSync(locator.companyDir(AGENT_ID, COMPANY_ID), { recursive: true, force: true });
    // 同一个进程里后续文件再 require 时要拿回没有 override 的那份
    for (const m of ['../../services/graph-run-sse', '../../services/graph-engine']) {
      delete require.cache[require.resolve(m)];
    }
  });

  it('graph-engine 真的在任务描述里发出了那个段头(分叉判据的另一半)', () => {
    expect(graphTask).toContain(GRAPH_WRITE_BOUNDARY_HEADER);
  });

  it('graph 节点链路:没有 workspace 那条交付指令,归宿只有一套', () => {
    // 公司 workspace 在 REPO_ROOT 下、既非项目根也非节点私有目录,写进去等于让 agent 越界
    expect(graphPrompt).not.toContain(companyWorkspaceDir(COMPANY_ID));
    expect(graphPrompt).not.toContain('你的交付物必须写到');
    // 唯一那条来自 describeWriteBoundary(项目根),坐标声明只指路、不另给归宿
    expect(countMandates(graphPrompt)).toBe(1);
    expect(graphPrompt).toContain(`按下方任务描述里的「${GRAPH_WRITE_BOUNDARY_HEADER}」写`);
  });

  it('agent-chat 链路:交付物仍写公司共享 workspace,同样只有一套', () => {
    expect(chatPrompt).toContain(`你的交付物必须写到:${companyWorkspaceDir(COMPANY_ID)}/`);
    expect(countMandates(chatPrompt)).toBe(1);
    // 反向:chat 链路不该凭空冒出 graph 的写入边界段
    expect(chatPrompt).not.toContain(GRAPH_WRITE_BOUNDARY_HEADER);
  });

  /**
   * subgraph 节点的规划调用(defaultPlanWorkGraph → buildPlanningPrompt)是**第三条**
   * 进 PromptBuilder 的路,而它不走 describeWriteBoundary、自己拼写入边界那一段。
   *
   * 上面两个用例只覆盖 agent_call 节点,于是二轮修复漏了这一条:buildPlanningPrompt
   * 原来写的段头是手写的 `## 写入边界(硬约束)`,taskCarriesGraphWriteBoundary 判不出来
   * → 坐标声明补上「交付物必须写到 {companyRoot}/workspace/」,而同一段提示词里
   * 规划提示词自己那句是「禁止写 REPO_ROOT 下的任何路径」,companyRoot 正在 REPO_ROOT 下。
   * 规划本体听哪条都跑得完、都不报错。
   */
  it('subgraph 规划链路:buildPlanningPrompt 用的是同一个段头常量,分叉不落空', async () => {
    const AM = require('../../src/core/agent-manager');
    const orig = AM.prototype.callAgent;
    let planTask = null;
    AM.prototype.callAgent = async (name, task) => {
      planTask = task;
      return { output: '```json\n{"nodes":[],"edges":[]}\n```' };
    };
    try {
      await engine.defaultPlanWorkGraph({
        companyId: COMPANY_ID, runId: 'wb-plan-run', rootRunId: 'wb-plan-run',
        nodeRunId: 'wb-plan-nr', defNodeId: 'wb-plan-def', nodeName: 'planner',
        systemAgentId: AGENT_ID, attempt: 1, projectRoot: null, task: '规划一张子图',
        remediationNote: null,
        run: { id: 'wb-plan-run', company_id: COMPANY_ID, goal_id: null, task_id: null, okr_node_id: null },
      });
    } catch { /* 图为空会被校验打回,本用例只要那份任务描述 */ }
    finally { AM.prototype.callAgent = orig; }

    expect(planTask).toContain(GRAPH_WRITE_BOUNDARY_HEADER);
    // 拿同一份任务描述过一遍真装配(用带公司段的地址,否则 chat 分支本来就不会触发,测了个寂寞)
    const planPrompt = manager.buildFullPrompt(`${COMPANY_ID}:${AGENT_ID}`, planTask);
    expect(planPrompt).not.toContain('你的交付物必须写到');
    expect(planPrompt).not.toContain(companyWorkspaceDir(COMPANY_ID));
  });

  it('两条链路都给出 cwd 与本体目录的绝对路径(相对引用漏改时的唯一兜底)', () => {
    for (const [prompt, cwd] of [[graphPrompt, graphCwd], [chatPrompt, chatCwd]]) {
      expect(path.isAbsolute(cwd)).toBe(true);
      expect(prompt).toContain(`你的工作目录(cwd):${cwd}`);
      expect(prompt).toContain(`你的本体文件:${BODY_DIR}`);
    }
    // cwd 恒为深度 3(裁决 A):{aid}/{cid}/{taskId},两次调用只有第三段不同
    expect(path.dirname(graphCwd)).toBe(path.join(BODY_DIR, COMPANY_ID));
    expect(chatCwd).toBe(path.join(BODY_DIR, COMPANY_ID, 'conv-wb-1'));
  });
});