subtask-planning-materialize.test.js 23.1 KB
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506
/**
 * 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);
  });

  // ── 46 号文档 Phase 4:拆分成功后接管父任务的执行权 ───────────────────

  describe('Phase 4:拆分成功后取消父 run + markTaskDecomposed', () => {
    const MIN_ACCEPTANCE = { dimensions: [{ name: 'done', weight: 1, pass_threshold: 80 }] };

    it('materialize 出 ≥1 条子任务后,父 run 被 stopRun 取消,父任务转 decomposed', async () => {
      const companyId = 'co-subplan-p4-1';
      const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
      const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
      const run = createRunRow({
        graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
      });
      wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id });

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

      expect(wdb.getRun(run.id).run_status).toBe('stopped');
      expect(wdb.getTask(task.id).status).toBe('decomposed');

      const children = wdb.getDb()
        .prepare(`SELECT id FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`)
        .all(task.id);
      expect(children).toHaveLength(2);
    });

    // 真实回归当场撞见的场景:task-scheduler 抢坑几乎是瞬间的,subtask-planning 是真实
    // LLM 调用(数十秒),等它拆完回来时父任务/父 run 几乎必然已经是 running 了,不是
    // queued——这是最常见的路径,不是边缘情形,第一版实现(只认 queued)在这里当场翻车:
    // markTaskDecomposed 拿不到锁,但 stopRun 依然把真实在跑的 run 取消掉,父任务卡成
    // cancelled、5 个子任务变成没人认领的孤儿。修复后必须在这个场景下正确接管。
    it('父 run 已经真实跑到 running(最常见的时序):依然能正确接管,不会卡成 cancelled', async () => {
      const companyId = 'co-subplan-p4-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: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
      });
      // 模拟 task-scheduler 已经把它推进了活跃集(queued → ready → running)
      wdb.updateRunStatus(run.id, 'queued', 'ready');
      wdb.updateRunStatus(run.id, 'ready', 'running');
      wdb.updateTask(task.id, { status: 'running', currentRunId: run.id, startedAt: new Date().toISOString() });

      subtaskPlanning.setPlanSubtasks(async () => ({
        steps: [
          { seq: 1, title: '第一步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
          { seq: 2, title: '第二步', depends_on_hint: [], acceptance: MIN_ACCEPTANCE },
        ],
        tokens: 10,
      }));
      try {
        await subtaskPlanning.triggerSubtaskPlanning({ run: wdb.getRun(run.id), task });
      } finally {
        subtaskPlanning.setPlanSubtasks(null);
      }

      // 关键断言:不是 cancelled(旧 bug 的症状),是 decomposed
      expect(wdb.getTask(task.id).status).toBe('decomposed');
      expect(wdb.getRun(run.id).run_status).toBe('stopped');

      const children = wdb.getDb()
        .prepare(`SELECT id FROM tasks WHERE parent_task_id = ? AND subtask_kind = 'decomposed_work'`)
        .all(task.id);
      expect(children).toHaveLength(2);
    });

    it('拆分产出 0 条有效子任务时(全部缺 acceptance)不接管:父 run/父任务原样不动', async () => {
      const companyId = 'co-subplan-p4-2';
      const goal = wdb.createGoal({ companyId, title: 'g', description: 'd' });
      const task = wdb.createTask({ goalId: goal.id, title: 't', description: 'd' });
      const run = createRunRow({
        graphKind: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
      });
      wdb.updateTask(task.id, { status: 'queued', currentRunId: run.id });

      // 缺 acceptance → materializeDecomposedSubtasks 里 createTask 会拒,单步跳过,整批为空
      subtaskPlanning.setPlanSubtasks(async () => ({
        steps: [{ seq: 1, title: '没有 acceptance 的一步', depends_on_hint: [] }],
        tokens: 5,
      }));
      try {
        await subtaskPlanning.triggerSubtaskPlanning({ run, task });
      } finally {
        subtaskPlanning.setPlanSubtasks(null);
      }

      expect(wdb.getRun(run.id).run_status).toBe('queued'); // 没被取消
      expect(wdb.getTask(task.id).status).toBe('queued'); // 没被接管
    });

    it('父任务在拆分成功之前就已经赢了竞态跑完:不覆盖已有的终态', async () => {
      const companyId = 'co-subplan-p4-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: 'org', graphId: companyId, companyId, taskId: task.id, runStatus: 'queued',
      });
      // 模拟「父 run 抢跑赢了竞态」:run 与 task 都已经落到终态
      wdb.updateRunStatus(run.id, 'queued', 'ready');
      wdb.updateRunStatus(run.id, 'ready', 'running');
      wdb.updateRunStatus(run.id, 'running', 'completed');
      wdb.updateTask(task.id, { status: 'completed', currentRunId: run.id });

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

      // stopRun 对已终态 run 是 no-op;markTaskDecomposed 的 CAS(WHERE status='queued')
      // 拿不到锁——父任务保持它已经赢下的 completed,不会被拆分结果覆盖
      expect(wdb.getRun(run.id).run_status).toBe('completed');
      expect(wdb.getTask(task.id).status).toBe('completed');
    });
  });
});

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('没有调研也照样给一版');
  });
});