subtask-planning-materialize.test.js 16.8 KB
/**
 * 46 号文档 Phase 1:`triggerSubtaskPlanning` 写共享 artifacts 区 + 登记 `graph_files`
 * 索引之后,任务级共享机制(R-19.3,`graph-engine.describeInputs`)要能把它自动递给
 * 这个 task 下任意 agent_call 节点的提示词——这是本文档设计的「与 org-planner 的衔接点」
 * 真正落地的地方(复用已验证在跑的 R-9.3 任务级共享机制,不是新写一条注入逻辑)。
 *
 * 隔离手法同 `graph-boundary.test.js`/`task-decomposition-materialize.test.js`:
 * 先设 GRAPH_DB_PATH / GRAPH_RUNS_DIR 再 require,agent 调用全走注入,不起 Claude CLI。
 */
import {
  describe, it, expect, beforeAll, afterAll, afterEach,
} from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const ROOT = path.resolve(__dirname, '../../..');
const TEST_DB_PATH = '/tmp/test-subtask-planning-materialize.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/graph-run-sse',
  '../../services/graph-engine',
  '../../services/subtask-planning',
];

describe('46 号文档 Phase 1:subtask-plan.json 共享区落地 + 任务级共享注入', () => {
  let wdb;
  let engine;
  let subtaskPlanning;
  let runsDir;

  beforeAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    runsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subtask-planning-runs-'));
    process.env.GRAPH_RUNS_DIR = runsDir;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
    wdb = require('../../services/work-graph-db');
    engine = require('../../services/graph-engine');
    subtaskPlanning = require('../../services/subtask-planning');
  });

  afterAll(() => {
    for (const f of DB_FILES) { 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;
    for (const m of MODULES) delete require.cache[require.resolve(m)];
  });

  function ensureAgent(id) {
    const db = wdb.getDb();
    const now = 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(id, id, `system/agents/${id}`, now, now);
  }

  /** wdb.createRun 只回 {ok, id, nodeRunIds},不是完整行 —— 服务层要的 root_run_id /
   *  company_id 得回读一次(company.js 的 enqueueTaskRun 也是这么用的,同一处坑)。 */
  function createRunRow(opts) {
    const created = wdb.createRun(opts);
    return wdb.getRun(created.id);
  }

  it('triggerSubtaskPlanning 写共享 artifacts/subtask-plan.json + 登记 graph_files', async () => {
    // setPlanSubtasks 注入替换的是整条 planSubtasks(含 normalizeSteps 那一步),
    // 单条不合法数据被丢弃/重新编号是 normalizeSteps 自己的职责,已在
    // subtask-planning.test.js 单独钉住;这里注入的是"已经规整过"的 steps,
    // 测的是 triggerSubtaskPlanning 往后那一段(写文件 + 登记索引)。
    const companyId = 'co-subplan-1';
    const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' });
    const task = wdb.createTask({ goalId: goal.id, title: '实现贪吃蛇', description: 'd' });
    const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });

    subtaskPlanning.setPlanSubtasks(async () => ({
      steps: [
        { seq: 1, title: '定义游戏状态与棋盘', why: '', depends_on_hint: [] },
        { seq: 2, title: '实现移动与碰撞检测', why: '', depends_on_hint: [1] },
      ],
      tokens: 42,
    }));
    try {
      await subtaskPlanning.triggerSubtaskPlanning({ run, task });
    } finally {
      subtaskPlanning.setPlanSubtasks(null);
    }

    const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
    expect(fs.existsSync(file)).toBe(true);
    const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
    expect(content.task_id).toBe(task.id);
    expect(content.steps).toHaveLength(2);
    expect(content.steps[0].title).toBe('定义游戏状态与棋盘');
    expect(content.steps[1].depends_on_hint).toEqual([1]);

    const rows = wdb.findFiles({ companyId, taskId: task.id, kinds: ['subtask_plan'] });
    expect(rows).toHaveLength(1);
    expect(rows[0].rel_path).toContain('subtask-plan.json');
  });

  it('这个 task 下任意 agent_call 节点的提示词都能自动看到 subtask_plan(任务级共享,不受相邻性限制)', async () => {
    const companyId = 'co-subplan-2';
    ensureAgent('impl');
    const goal = wdb.createGoal({ companyId, title: 'g', description: '贪吃蛇游戏' });
    const task = wdb.createTask({ goalId: goal.id, title: '实现贪吃蛇', description: 'd' });

    // 先起一条"入队时的" run 触发 subtask-planning(模拟 enqueueTaskRun 的时序)
    const enqueueRun = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });
    subtaskPlanning.setPlanSubtasks(async () => ({
      steps: [{ title: '定义游戏状态' }],
      tokens: 10,
    }));
    try {
      await subtaskPlanning.triggerSubtaskPlanning({ run: enqueueRun, task });
    } finally {
      subtaskPlanning.setPlanSubtasks(null);
    }

    // 真正跑图的是另一条 run(同一个 task,不同 run —— 与真实场景一致:enqueueTaskRun 建的
    // 那条 run 才是真正被执行的图,这里只是拆开两步验证互不依赖时序)。
    const g = wdb.createWorkGraph({
      companyId,
      nodes: [{ nodeName: 'impl', systemAgentId: 'impl' }],
      edges: [],
    });
    expect(g.errors || []).toEqual([]);
    const execRun = createRunRow({ graphKind: 'work', graphId: g.id, companyId, taskId: task.id });
    engine.markRunReady(execRun.id);

    let captured = null;
    engine.setAgentExecutor(async (ctx) => {
      captured = ctx.task;
      return { status: 'success' };
    });
    try {
      engine.startRun(execRun.id, { mode: 'manual' });
      await engine.advanceOne(execRun.id);
    } finally {
      engine.setAgentExecutor(null);
    }

    expect(captured).toContain('[上游输入');
    expect(captured).toContain('subtask_plan(任务级共享)');
    expect(captured).toContain(path.join(runsDir, enqueueRun.root_run_id, 'artifacts', 'subtask-plan.json'));
  });

  it('planSubtasks 产出 0 条步骤时不写文件、不报错(软失败,不影响调用方)', async () => {
    const companyId = 'co-subplan-3';
    const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
    const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
    const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });

    subtaskPlanning.setPlanSubtasks(async () => ({ steps: [], tokens: 5 }));
    try {
      await expect(subtaskPlanning.triggerSubtaskPlanning({ run, task })).resolves.toBeUndefined();
    } finally {
      subtaskPlanning.setPlanSubtasks(null);
    }
    const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
    expect(fs.existsSync(file)).toBe(false);
  });

  it('planSubtasks 本身抛错时 triggerSubtaskPlanning 吞掉,不向调用方传播(46 号文档:绝不能拖累入队本身)', async () => {
    const companyId = 'co-subplan-4';
    const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
    const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
    const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });

    subtaskPlanning.setPlanSubtasks(async () => { throw new Error('CLI 起不来'); });
    try {
      await expect(subtaskPlanning.triggerSubtaskPlanning({ run, task })).resolves.toBeUndefined();
    } finally {
      subtaskPlanning.setPlanSubtasks(null);
    }
  });

  it('reasoning_source / experience_refs / research_used 原样落盘(Phase 2 新增字段,向后兼容)', async () => {
    const companyId = 'co-subplan-5';
    const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
    const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
    const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });

    subtaskPlanning.setPlanSubtasks(async () => ({
      steps: [{ seq: 1, title: '结合调研后的分解', why: '', depends_on_hint: [] }],
      tokens: 88,
      reasoningSource: 'deep_research',
      experienceRefs: ['impl: 贪吃蛇碰撞检测踩过的坑'],
      researchUsed: true,
    }));
    try {
      await subtaskPlanning.triggerSubtaskPlanning({ run, task });
    } finally {
      subtaskPlanning.setPlanSubtasks(null);
    }

    const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
    const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
    expect(content.reasoning_source).toBe('deep_research');
    expect(content.experience_refs).toEqual(['impl: 贪吃蛇碰撞检测踩过的坑']);
    expect(content.research_used).toBe(true);
  });

  it('注入的 planSubtasks 没带 Phase 2 字段时兜底为 model_only/空数组/false(向后兼容旧注入桩)', async () => {
    const companyId = 'co-subplan-6';
    const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
    const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
    const run = createRunRow({ graphKind: 'work', graphId: 'placeholder', companyId, taskId: task.id });

    subtaskPlanning.setPlanSubtasks(async () => ({ steps: [{ seq: 1, title: '一步' }], tokens: 1 }));
    try {
      await subtaskPlanning.triggerSubtaskPlanning({ run, task });
    } finally {
      subtaskPlanning.setPlanSubtasks(null);
    }

    const file = path.join(runsDir, run.root_run_id, 'artifacts', 'subtask-plan.json');
    const content = JSON.parse(fs.readFileSync(file, 'utf-8'));
    expect(content.reasoning_source).toBe('model_only');
    expect(content.experience_refs).toEqual([]);
    expect(content.research_used).toBe(false);
  });
});

describe('46 号文档 Phase 2:defaultPlanSubtasks 编排(经验检索 + 按需 deep research)', () => {
  let wdb;
  let subtaskPlanning;
  let deepResearch;
  const ORIGINAL_ENABLE = process.env.ENABLE_DEEP_RESEARCH;
  const EXP_AGENT = 'zz-subplan-p2-impl';
  const EXP_AGENT_DIR = path.join(ROOT, 'system', 'agents', EXP_AGENT);

  beforeAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    process.env.GRAPH_DB_PATH = TEST_DB_PATH;
    for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) {
      try { delete require.cache[require.resolve(m)]; } catch {}
    }
    wdb = require('../../services/work-graph-db');
    subtaskPlanning = require('../../services/subtask-planning');
    deepResearch = require('../../services/deep-research');
  });

  afterAll(() => {
    for (const f of DB_FILES) { try { fs.unlinkSync(f); } catch {} }
    delete process.env.GRAPH_DB_PATH;
    fs.rmSync(EXP_AGENT_DIR, { recursive: true, force: true });
    if (ORIGINAL_ENABLE === undefined) delete process.env.ENABLE_DEEP_RESEARCH;
    else process.env.ENABLE_DEEP_RESEARCH = ORIGINAL_ENABLE;
    for (const m of [...MODULES, '../../services/experience-lookup', '../../services/deep-research']) {
      try { delete require.cache[require.resolve(m)]; } catch {}
    }
  });

  afterEach(() => {
    subtaskPlanning.setSubtaskAgentCaller(null);
    deepResearch.setTriggerDeepResearch(null);
  });

  function hireInto(companyId, agentId) {
    const db = wdb.getDb();
    const now = 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(agentId, agentId, `system/agents/${agentId}`, now, now);
    db.prepare(`INSERT OR IGNORE INTO company_agents (company_id, system_agent_id, role_name, status, hired_at)
        VALUES (?, ?, ?, 'active', ?)`).run(companyId, agentId, agentId, now);
  }

  it('经验库命中 + agent 给 high confidence → reasoning_source=memory,只调一轮,不触发调研', async () => {
    const companyId = 'co-subplan-p2-memory';
    hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);
    hireInto(companyId, EXP_AGENT);
    const expDir = path.join(EXP_AGENT_DIR, 'memory');
    fs.mkdirSync(expDir, { recursive: true });
    fs.writeFileSync(
      path.join(expDir, 'experience.md'),
      '# 跨公司经验(dream 提炼)\n\n### 贪吃蛇碰撞检测踩过的坑\n\n棋盘边界判断要在移动前做,不然会穿墙\n\n> 提炼于 2026-09-01;原料:x\n',
      'utf-8'
    );

    let calls = 0;
    subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => {
      calls += 1;
      expect(prompt).toContain('已知相关经验');
      return { output: '```json\n{"steps":[{"seq":1,"title":"定义棋盘"}],"confidence":"high"}\n```' };
    });

    const task = { id: 'T-p2-memory', title: '实现贪吃蛇', description: '贪吃蛇游戏的碰撞检测和棋盘边界' };
    const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });

    expect(calls).toBe(1);
    expect(result.reasoningSource).toBe('memory');
    expect(result.researchUsed).toBe(false);
    expect(result.experienceRefs.length).toBeGreaterThan(0);
    expect(result.steps[0].title).toBe('定义棋盘');
  });

  it('经验库无命中 + deep research 关闭 → reasoning_source=model_only,只调一轮', async () => {
    delete process.env.ENABLE_DEEP_RESEARCH;
    const companyId = 'co-subplan-p2-modelonly';
    hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);

    let calls = 0;
    subtaskPlanning.setSubtaskAgentCaller(async () => {
      calls += 1;
      return { output: '```json\n{"steps":[{"seq":1,"title":"随便一步"}],"confidence":"high"}\n```' };
    });

    const task = { id: 'T-p2-modelonly', title: '发一封周报', description: '把周报发给团队' };
    const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });

    expect(calls).toBe(1);
    expect(result.reasoningSource).toBe('model_only');
    expect(result.researchUsed).toBe(false);
    expect(result.experienceRefs).toEqual([]);
  });

  it('经验库无命中 + deep research 开启且返回结果 → reasoning_source=deep_research,两轮调用', async () => {
    process.env.ENABLE_DEEP_RESEARCH = '1';
    const companyId = 'co-subplan-p2-research';
    hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);

    deepResearch.setTriggerDeepResearch(async () => ({
      findings: '业界通常先定义棋盘再做碰撞检测', sources: ['https://example.com'], tokens: 50,
    }));

    let calls = 0;
    subtaskPlanning.setSubtaskAgentCaller(async (agentName, prompt) => {
      calls += 1;
      if (calls === 2) expect(prompt).toContain('调研参考');
      return {
        output: calls === 1
          ? '```json\n{"steps":[{"seq":1,"title":"第一轮草稿"}],"confidence":"low"}\n```'
          : '```json\n{"steps":[{"seq":1,"title":"结合调研后的分解"}],"confidence":"high"}\n```',
      };
    });

    const task = { id: 'T-p2-research', title: '实现贪吃蛇', description: '一个全新玩法,系统里从没做过' };
    const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });

    expect(calls).toBe(2);
    expect(result.reasoningSource).toBe('deep_research');
    expect(result.researchUsed).toBe(true);
    expect(result.steps[0].title).toBe('结合调研后的分解');
    expect(result.tokens).toBeGreaterThanOrEqual(50);
  });

  it('deep research 开启但没查到有用结果(返回 null)→ 仍是 model_only,不算失败', async () => {
    process.env.ENABLE_DEEP_RESEARCH = '1';
    const companyId = 'co-subplan-p2-research-empty';
    hireInto(companyId, subtaskPlanning.PLANNING_AGENT_ID);

    deepResearch.setTriggerDeepResearch(async () => null);

    let calls = 0;
    subtaskPlanning.setSubtaskAgentCaller(async () => {
      calls += 1;
      return { output: '```json\n{"steps":[{"seq":1,"title":"没有调研也照样给一版"}],"confidence":"low"}\n```' };
    });

    const task = { id: 'T-p2-research-empty', title: '一个冷门任务', description: '系统里第一次遇到' };
    const result = await subtaskPlanning.defaultPlanSubtasks({ companyId, task });

    expect(calls).toBe(1); // 调研没结果,不会有第二轮
    expect(result.reasoningSource).toBe('model_only');
    expect(result.researchUsed).toBe(false);
    expect(result.steps[0].title).toBe('没有调研也照样给一版');
  });
});