/** * 46 号文档 Phase 1:subtask-planning 的产出解析 + 宽松归一化。 * * 这一层是纯函数,不碰 DB/CLI —— 与 `triggerSubtaskPlanning` 写共享 artifacts 区 + * 登记 graph_files 的集成路径分开测(见 subtask-planning-materialize.test.js)。 */ import { describe, it, expect } from 'vitest'; import { extractPlannedSteps, extractConfidence, normalizeSteps, buildSubtaskPlanningPrompt, MAX_STEPS, } from '../../services/subtask-planning.js'; describe('subtask-planning:extractPlannedSteps', () => { it('从围栏 json 代码块里取 {steps:[...]}', () => { const output = [ '我是这么想的……', '```json', '{"steps":[{"seq":1,"title":"定义游戏状态","why":"后面都要用","depends_on_hint":[]}]}', '```', ].join('\n'); expect(extractPlannedSteps(output)).toEqual([ { seq: 1, title: '定义游戏状态', why: '后面都要用', depends_on_hint: [] }, ]); }); it('容错:顶层直接是裸数组也认', () => { const output = '```json\n[{"title":"a"},{"title":"b"}]\n```'; expect(extractPlannedSteps(output)).toEqual([{ title: 'a' }, { title: 'b' }]); }); it('多个代码块时取最后一个(agent.md 硬约束 6:JSON 块必须是回复最后一段)', () => { const output = [ '```json', '{"steps":[{"seq":1,"title":"上一轮草稿,应被忽略"}]}', '```', '重新想了一下:', '```json', '{"steps":[{"seq":1,"title":"最终版"}]}', '```', ].join('\n'); expect(extractPlannedSteps(output)).toEqual([{ seq: 1, title: '最终版' }]); }); it('回复里没有可解析 JSON 时返回空数组(不抛错)', () => { expect(extractPlannedSteps('我不知道该怎么拆')).toEqual([]); expect(extractPlannedSteps('')).toEqual([]); expect(extractPlannedSteps(null)).toEqual([]); }); }); describe('subtask-planning:extractConfidence(Phase 2 新增字段)', () => { it('取到 "high"/"low" 就原样返回', () => { expect(extractConfidence('```json\n{"steps":[],"confidence":"low"}\n```')).toBe('low'); expect(extractConfidence('```json\n{"steps":[],"confidence":"high"}\n```')).toBe('high'); }); it('取不到 / 值不合法 / 字段缺失时返回 null,不强行猜', () => { expect(extractConfidence('```json\n{"steps":[]}\n```')).toBeNull(); expect(extractConfidence('```json\n{"steps":[],"confidence":"maybe"}\n```')).toBeNull(); expect(extractConfidence('我不知道')).toBeNull(); expect(extractConfidence('')).toBeNull(); expect(extractConfidence(null)).toBeNull(); }); }); describe('subtask-planning:normalizeSteps(宽松归一化,单条不合法就丢那一条)', () => { it('正常路径:重新编号为 1..N,depends_on_hint 保留合法引用', () => { const raw = [ { title: '第一步', why: 'w1', depends_on_hint: [] }, { title: '第二步', why: 'w2', depends_on_hint: [1] }, ]; expect(normalizeSteps(raw)).toEqual([ { seq: 1, title: '第一步', why: 'w1', depends_on_hint: [] }, { seq: 2, title: '第二步', why: 'w2', depends_on_hint: [1] }, ]); }); it('缺 title 的条目被丢弃,其余重新编号、不留空洞', () => { const raw = [ { title: '第一步' }, { title: '' }, // 空 title,丢 { why: '没有 title 字段' }, // 丢 { title: '第二步' }, ]; const out = normalizeSteps(raw); expect(out.map((s) => s.seq)).toEqual([1, 2]); expect(out.map((s) => s.title)).toEqual(['第一步', '第二步']); }); it('depends_on_hint 指向被丢弃条目 / 越界 / 自引用:过滤掉,不报错', () => { const raw = [ { title: '' }, // 原 seq 1,会被丢 { title: '第一步', depends_on_hint: [1, 2, 99] }, // 原 seq 2 → 新 seq 1;1 已被丢,2 是自己,99 越界 ]; const out = normalizeSteps(raw); expect(out).toEqual([{ seq: 1, title: '第一步', why: '', depends_on_hint: [] }]); }); it('非对象 / 数组类型的条目直接跳过', () => { const raw = [null, 'not an object', ['x'], { title: '合法的一条' }]; expect(normalizeSteps(raw)).toEqual([ { seq: 1, title: '合法的一条', why: '', depends_on_hint: [] }, ]); }); it(`条数超过 MAX_STEPS(${MAX_STEPS}) 时截断,不报错`, () => { const raw = Array.from({ length: MAX_STEPS + 5 }, (_, i) => ({ title: `步骤${i + 1}` })); const out = normalizeSteps(raw); expect(out).toHaveLength(MAX_STEPS); expect(out[0].title).toBe('步骤1'); }); it('空输入 / 非数组输入返回空数组', () => { expect(normalizeSteps([])).toEqual([]); expect(normalizeSteps(null)).toEqual([]); expect(normalizeSteps(undefined)).toEqual([]); }); }); describe('subtask-planning:buildSubtaskPlanningPrompt', () => { it('提示词里包含 task 描述、标题、交回格式,不包含创建 task/run 的诱导', () => { const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc', title: '实现贪吃蛇', description: '一个可以在浏览器玩的贪吃蛇游戏' }, }); expect(prompt).toContain('一个可以在浏览器玩的贪吃蛇游戏'); expect(prompt).toContain('实现贪吃蛇'); expect(prompt).toContain('"steps"'); expect(prompt).toContain('depends_on_hint'); }); it('task 描述缺失时给出明确占位,不假装有内容', () => { const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc' } }); expect(prompt).toContain('调用方没给任务描述'); }); it('不传 extra 时不出现"已知相关经验"/"调研参考"小节标题(Phase 1 行为不变)', () => { const prompt = buildSubtaskPlanningPrompt({ companyId: 'co-x', task: { id: 'T-abc', title: 't' } }); expect(prompt).not.toContain('## 已知相关经验'); expect(prompt).not.toContain('## 调研参考'); expect(prompt).toContain('confidence'); }); it('传 experience 命中时拼进"已知相关经验"小节,带来源 agentId', () => { const prompt = buildSubtaskPlanningPrompt( { companyId: 'co-x', task: { id: 'T-abc', title: 't' } }, { experience: [{ agentId: 'impl', title: '踩过的坑', body: '别忘了边界检测' }] } ); expect(prompt).toContain('## 已知相关经验'); expect(prompt).toContain('[来自 impl]'); expect(prompt).toContain('踩过的坑'); expect(prompt).toContain('别忘了边界检测'); expect(prompt).not.toContain('## 调研参考'); }); it('传 research 时拼进"调研参考"小节,带 findings 和 sources', () => { const prompt = buildSubtaskPlanningPrompt( { companyId: 'co-x', task: { id: 'T-abc', title: 't' } }, { research: { findings: '业界通常先定义棋盘', sources: ['https://example.com'] } } ); expect(prompt).toContain('## 调研参考'); expect(prompt).toContain('业界通常先定义棋盘'); expect(prompt).toContain('https://example.com'); }); });