subtask-planning.test.js
6.93 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
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
/**
* 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');
});
});