scaffold-body-readonly.test.js 4.67 KB
/**
 * 28 号改造 —— 脚手架不许覆写现役本体。
 *
 * 批次 5-21 把四个 `new AgentScaffold(...)` 的 baseDir 统一到 `system/agents` 之后,
 * `AgentScaffold.create()` 那句「无条件写 Bootstrap Files」的语义变了:
 * 它覆写的不再是某家公司的私有副本,而是**全系统唯一那一份本体** ——
 * agent.md / soul.md / identity.md / tools.md / user.md / heartbeat.md / config.json /
 * todo.md / plan.md 一律换成通用默认模板。
 *
 * 这条失败路径全程不报错:端点返回 200 + `success:true`,日志写「✅ 已创建」,
 * 目录还在、文件也都在,只是内容被抹平成了「(待上层 Agent 定义)」。
 * 更糟的是它和转正闸门互相抵消 —— 覆写后的 agent.md / soul.md 非空、也不带
 * `_base` 哨兵,`missingBodyDefinition` 当场判「齐全」,本体就这么带着一身通用
 * 骨架转正、被别家公司当成可复用本体雇走(§2.4 org-coder 的失败形态)。
 *
 * `company.js` 的 `POST /templates` 早就有这道 409 判重;缺的是 agent-chat 这两处。
 */
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'fs';
import path from 'path';
import express from 'express';
import { createRequire } from 'node:module';

const require_ = createRequire(import.meta.url);
const ROOT = path.resolve(__dirname, '../../..');
const AGENTS_ROOT = path.join(ROOT, 'system', 'agents');

// 端点的本体根是硬编码的 `<repo>/system/agents`(无 env 覆盖口),所以这两份本体是
// 真的建在注册表里的常驻条目(28 号裁决:测试建的 agent 不删)。id 固定、内容每次重写。
// 不用 `_` 前缀:那是 `_global` / `_base` 的保留段。
const AID = 'zz-scaffold-readonly';
const BODY = path.join(AGENTS_ROOT, AID);
/** 第二条用例的载体:本体存不存在决定端点是「建」还是「判重」,两条分支都锁 */
const FRESH_AID = `${AID}-fresh`;
const FRESH_BODY = path.join(AGENTS_ROOT, FRESH_AID);

const REAL_AGENT_MD = '# 现役本体 —— 角色特化过的真定义\n\n读 `../../soul.md`。\n';
const REAL_SOUL_MD = '# 现役人格\n';

describe('AgentScaffold 端点 · 本体只读', () => {
  let base;
  let srv;

  beforeAll(async () => {
    fs.mkdirSync(BODY, { recursive: true });
    fs.writeFileSync(path.join(BODY, 'agent.md'), REAL_AGENT_MD);
    fs.writeFileSync(path.join(BODY, 'soul.md'), REAL_SOUL_MD);

    const app = express();
    app.use(express.json());
    app.use('/api/agent', require_('../../routes/agent-chat'));
    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));
    // 本体不删:它们是测试建出来的正式注册表条目(28 号裁决)。
    // 这里也没有公司态可清 —— 本文件只调 /scaffold,不雇人、不起会话。
  });

  it('POST /scaffold 撞上已存在的本体 → 409,且盘上一个字都没被改', async () => {
    const res = await fetch(`${base}/api/agent/scaffold`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id: AID, type: 'skill', name: '冒名顶替者' }),
    });

    expect(res.status).toBe(409);
    // 断言落盘而不只是响应码:这个 bug 的整个危害都在磁盘上,响应码曾经是 200。
    expect(fs.readFileSync(path.join(BODY, 'agent.md'), 'utf-8')).toBe(REAL_AGENT_MD);
    expect(fs.readFileSync(path.join(BODY, 'soul.md'), 'utf-8')).toBe(REAL_SOUL_MD);
  });

  it('POST /scaffold 的判重只认「本体在不在」:不在就建(200),在就 409,两条都不覆写', async () => {
    // 本体跑完不删,所以第一次跑走「建」、以后每次走「判重」——**存在性决定分支**正是
    // 端点的契约,于是两条分支都能被钉死,而不是二选一。
    const existed = fs.existsSync(FRESH_BODY);
    const before = existed ? fs.readFileSync(path.join(FRESH_BODY, 'agent.md'), 'utf-8') : null;

    const res = await fetch(`${base}/api/agent/scaffold`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ id: FRESH_AID, type: 'skill', name: '新同事' }),
    });

    expect(res.status).toBe(existed ? 409 : 200);
    // 不管走哪条分支,落盘结果都必须是「本体在,且没被这次请求改写」
    expect(fs.existsSync(path.join(FRESH_BODY, 'agent.md'))).toBe(true);
    if (existed) {
      expect(fs.readFileSync(path.join(FRESH_BODY, 'agent.md'), 'utf-8')).toBe(before);
    }
  });
});