subtask-planning.test.js
4.79 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
/**
* 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('调用方没给任务描述');
});
});