work-layer-body-paths.test.js 7.23 KB
/**
 * Unit Tests —— work 层三份本体(org-coder / org-planner / code-planner)的路径口径
 * 以及**写出它们的那条流水线**(28 号改造二轮,blocker 4)。
 *
 * 这三份本体是 §2.4 的「公司自己养出来的」那一类:没有仓库自带模板,内容由 agent-creator
 * (一个 LLM)照着仓库里已有的 agent.md 依样画葫芦写出来的。它们原先写的是旧布局的
 * `../../workspace/org-planner/prd.md` —— cwd = `data/companies/{cid}/agents/{role}/` 时才成立。
 * 28 号把 cwd 换成 `system/agents/{aid}/{cid}/{taskId}/` 之后同一串路径指到
 * `system/agents/{aid}/workspace/`:读上游读到空目录、写产物写进 agent 私有目录
 * (`indexNodeOutputs` 扫不到),**两头都不报错**。
 *
 * 每条 `../` 引用算得对不对,由 `agent-md-refs.test.js` 对全部本体统一把关,这里不重复。
 * 本文件只补它盖不到的三件事:
 *   ① 旧布局那个具体形态不许复活(它恰好也是「上跳两级」,深度对、目标错);
 *   ② 要 agent 去**跑**的仓库内命令(`bin/lemon.js`)必须带上到仓库根的前缀 ——
 *      不带的话 shell 在任务态目录里找 `bin/lemon.js`,四段式流转整条哑掉;
 *   ③ 这三份本体不写死产物路径、改成去任务描述的三个段头里取绝对路径,
 *      那三个段头就成了硬依赖:引擎那边一改字面量,它们就去取一个不存在的段。
 *
 * 最后一组(「装配来源」)才是防回归的那条:只把盘上文件改对,下一个 agent-creator
 * 写出来的照样是旧形态。派单文本必须自己交代运行期布局。
 */
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';

const { buildAgentDefinitionBrief } = require('../../services/company-service');
const { GRAPH_WRITE_BOUNDARY_HEADER } = require('../../src/core/prompt-builder');

const ROOT = path.resolve(__dirname, '../../..');
const WORK_LAYER_AGENTS = ['org-coder', 'org-planner', 'code-planner'];

/** 到仓库根的上跳数:cwd 是 {aid}/{cid}/{taskId},往上 taskId→cid→aid→agents→system→根 */
const TO_REPO_ROOT = '../../../../../';

/** 旧布局形态:上跳两级(= 本体目录)再进 workspace —— 深度对,目标是个空目录 */
const LEGACY_WORKSPACE_RE = /\.\.\/\.\.\/workspace\//;

const bodyText = (aid) => fs.readFileSync(path.join(ROOT, 'system', 'agents', aid, 'agent.md'), 'utf-8');

/**
 * 引擎拼进任务描述的三个段头。前两个直接从 graph-engine 源码里取字面量、
 * 第三个取 prompt-builder 导出的常量 —— 这里再抄一份就成了第四份逐字同构的约定。
 */
function engineSectionHeaders() {
  const engine = fs.readFileSync(path.join(ROOT, 'server', 'services', 'graph-engine.js'), 'utf-8');
  const upstream = engine.match(/'(\[上游输入[^']*)'/);
  const artifactDir = engine.match(/'(\[节点产物目录\])'/);
  expect(upstream, 'graph-engine 里找不到「上游输入」段头').not.toBeNull();
  expect(artifactDir, 'graph-engine 里找不到「节点产物目录」段头').not.toBeNull();
  return [upstream[1], artifactDir[1], GRAPH_WRITE_BOUNDARY_HEADER];
}

describe('work 层本体的路径口径(cwd 深度 3)', () => {
  it('三份本体都在,且不是空文件(改名/被清掉时别静默空跑)', () => {
    for (const aid of WORK_LAYER_AGENTS) {
      expect(bodyText(aid).length, `${aid}/agent.md`).toBeGreaterThan(500);
    }
  });

  it('旧布局的 `../../workspace/…` 一处都不许剩', () => {
    const hits = WORK_LAYER_AGENTS.flatMap((aid) =>
      bodyText(aid)
        .split('\n')
        .map((line, i) => ({ aid, i, line }))
        .filter((r) => LEGACY_WORKSPACE_RE.test(r.line))
        .map((r) => `system/agents/${r.aid}/agent.md:${r.i + 1}  ${r.line.trim()}`),
    );
    expect(hits).toEqual([]);
  });

  it('`bin/lemon.js` 每一次出现都带到仓库根的前缀(四段式流转全靠这条命令)', () => {
    const bad = [];
    for (const aid of WORK_LAYER_AGENTS) {
      bodyText(aid).split('\n').forEach((line, i) => {
        for (const m of line.matchAll(/(\S*)bin\/lemon\.js/g)) {
          if (m[1] !== TO_REPO_ROOT) bad.push(`system/agents/${aid}/agent.md:${i + 1}  ${m[0]}`);
        }
      });
    }
    expect(bad).toEqual([]);
  });

  it('本体里引用的引擎段头与引擎那边逐字一致(漂了就是去取一个不存在的段)', () => {
    const headers = engineSectionHeaders();
    // 三份本体都把「路径去任务描述里取」当成硬约束,所以至少各命中一个段头;
    // 命中了就必须逐字对得上——这里查的是「引用了但对不上」。
    const drifted = [];
    for (const aid of WORK_LAYER_AGENTS) {
      const text = bodyText(aid);
      const cited = headers.filter((h) => text.includes(h));
      if (cited.length === 0) drifted.push(`${aid}/agent.md 一个引擎段头都没引,产物落点无处可取`);
    }
    expect(drifted).toEqual([]);
  });
});

/**
 * 装配来源(第二步,也是「去掉修复就红」的那条)。
 *
 * 这三份 agent.md 是 untracked 的运行期产物 —— 只改盘上文件,下一次 agent-creator
 * 被触发时照样按它看到的老样板重写一遍。唯一能持久的修法是把运行期布局写进**派单文本**,
 * 而派单文本有两处(`POST /:companyId/hire` 与 `POST /templates/:templateId/generate`),
 * 所以它必须单点定义、两处共用。
 */
describe('装配来源:agent-creator 的产出契约必须交代运行期布局', () => {
  const brief = buildAgentDefinitionBrief({
    agentId: 'org-coder',
    bodyPath: '/abs/system/agents/org-coder',
    description: '实现节点',
  });

  it('三个必填文件仍然逐字点名(本体填不满就停在 draft)', () => {
    for (const f of ['agent.md', 'soul.md', '.claude/skills/main_skill/SKILL.md']) {
      expect(brief).toContain(`/abs/system/agents/org-coder/${f}`);
    }
  });

  it('派单里写明 cwd 是深度 3 的任务态目录,不是本体目录', () => {
    expect(brief).toContain('system/agents/org-coder/{公司ID}/{任务ID}/');
  });

  it('派单里给出本体 / 公司态 / 仓库根三档的相对深度', () => {
    // 本体是 `../../`(不是 28 §4.1 手写的 `../../../`,那一级把结尾斜杠数进去了)
    expect(brief).toMatch(/本体文件[^\n]*`\.\.\/\.\.\/`/);
    expect(brief).toContain(TO_REPO_ROOT);
  });

  it('派单里点名禁止旧布局的 `../../workspace/…`(这正是三份本体跑偏的形态)', () => {
    expect(brief).toMatch(LEGACY_WORKSPACE_RE);
    expect(brief).toContain('禁止');
  });

  it('派单引用的是写入边界段头常量本身,不是又抄一份字面量', () => {
    expect(brief).toContain(GRAPH_WRITE_BOUNDARY_HEADER);
  });

  it('两处派单都走这一份契约:路由里不再留第二份内联清单', () => {
    // 内联那份只补进一处,就等于另一处没补 —— 而两处写出来的都是同一批本体。
    const routes = fs.readFileSync(path.join(ROOT, 'server', 'routes', 'company.js'), 'utf-8');
    expect(routes).not.toContain('(人格与判断准则)');
    expect(routes.match(/buildAgentDefinitionBrief\(/g) || []).toHaveLength(2);
  });
});