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