/** * 46 号文档 Phase 1:subtask-planning 的产出解析 + 宽松归一化。 * * 这一层是纯函数,不碰 DB/CLI —— 与 `triggerSubtaskPlanning` 写共享 artifacts 区 + * 登记 graph_files 的集成路径分开测(见 subtask-planning-materialize.test.js)。 */ import { describe, it, expect } from 'vitest'; import { extractPlannedSteps, 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: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('调用方没给任务描述'); }); });