subtask-planning.test.js 6.93 KB
/**
 * 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');
  });
});