magic-agent 1.0.46 → 1.0.108
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLIENT-CONTRACT.md +514 -7
- package/README.md +31 -2
- package/client-ui/pc/app.css +3 -41
- package/client-ui/pc/js/04-task-list.js +6 -2
- package/client-ui/pc/js/90-glue.js +23 -4
- package/client-ui/pc/js/92-composer.js +11 -4
- package/client-ui/pc/js/93-rail.js +178 -19
- package/client-ui/shared/conv/03-chat-message.js +145 -25
- package/client-ui/shared/conv/09-stream-apply.js +55 -6
- package/client-ui/shared/conv/19-attachments.js +70 -6
- package/client-ui/shared/conv/27-conv-ledger.js +18 -0
- package/client-ui/shared/conv/29-msg-variants.js +10 -0
- package/client-ui/shared/conv/README.md +6 -0
- package/client-ui/shared/conv/conv.css +77 -0
- package/client-ui/shared/conversation.css +229 -43
- package/client-ui/shared/conversation.js +1316 -131
- package/client-ui/shared/transport-http.js +164 -12
- package/client-ui-mobile/index.html +2168 -143
- package/internal/agent/capability.go +16 -2
- package/internal/agent/claude.go +3 -2
- package/internal/agent/codebuddy.go +7 -1
- package/internal/agent/codex_test.go +3 -0
- package/internal/agent/custom_models_test.go +5 -0
- package/internal/agent/dsh.go +18 -2
- package/internal/agent/dsh_test.go +53 -2
- package/internal/agent/engine_test.go +34 -19
- package/internal/agent/env_test.go +14 -9
- package/internal/agent/models_test.go +9 -0
- package/internal/agent/runcmd_test.go +24 -3
- package/internal/agent/stream.go +33 -0
- package/internal/cli/ask.go +94 -4
- package/internal/cli/contract_test.go +26 -0
- package/internal/cli/events.go +18 -7
- package/internal/cli/keepalive_test.go +69 -0
- package/internal/cli/requirements.go +119 -0
- package/internal/cli/requirements_test.go +36 -0
- package/internal/cli/root.go +56 -4
- package/internal/cli/session.go +64 -25
- package/internal/cli/sessionlog.go +76 -7
- package/internal/cli/sessionlog_test.go +58 -0
- package/internal/cli/team.go +374 -0
- package/internal/cli/team_session.go +156 -0
- package/internal/cli/team_test.go +360 -0
- package/internal/client/plugins.go +7 -4
- package/internal/client/requirements.go +309 -0
- package/internal/client/requirements_test.go +239 -0
- package/internal/client/service.go +545 -19
- package/internal/client/service_test.go +47 -2
- package/internal/client/session_events_test.go +320 -0
- package/internal/client/team_flags.go +204 -0
- package/internal/client/team_flags_test.go +254 -0
- package/internal/requirements/bootstrap.go +423 -0
- package/internal/requirements/consolidate.go +908 -0
- package/internal/requirements/doc.go +473 -0
- package/internal/requirements/memory.go +324 -0
- package/internal/requirements/requirements.go +169 -0
- package/internal/requirements/requirements_test.go +530 -0
- package/internal/requirements/store.go +289 -0
- package/internal/session/delete.go +8 -4
- package/internal/session/session.go +194 -17
- package/internal/session/session_append.go +4 -1
- package/internal/session/session_append_test.go +19 -0
- package/internal/session/session_mark_test.go +42 -3
- package/internal/session/session_test.go +43 -0
- package/internal/session/sessionlog.go +50 -34
- package/internal/session/sessionlog_read_test.go +69 -11
- package/internal/session/status.go +205 -0
- package/internal/session/status_test.go +144 -0
- package/internal/team/adaptive.go +293 -0
- package/internal/team/board.go +216 -0
- package/internal/team/event.go +28 -0
- package/internal/team/execute.go +648 -0
- package/internal/team/plan.go +903 -0
- package/internal/team/planner.go +294 -0
- package/internal/team/reduce.go +192 -0
- package/internal/team/role.go +334 -0
- package/internal/team/route.go +206 -0
- package/internal/team/schedule.go +241 -0
- package/internal/team/schedule_test.go +286 -0
- package/internal/team/subsession.go +27 -0
- package/internal/team/team.go +401 -0
- package/internal/team/team_test.go +1997 -0
- package/npm/bin/magic-agent.js +24 -1
- package/npm/bin/magic-client.js +20 -2
- package/npm/dist/darwin-arm64/magic-agent +0 -0
- package/npm/dist/darwin-arm64/magic-client +0 -0
- package/npm/dist/darwin-x64/magic-agent +0 -0
- package/npm/dist/darwin-x64/magic-client +0 -0
- package/npm/dist/linux-arm64/magic-agent +0 -0
- package/npm/dist/linux-arm64/magic-client +0 -0
- package/npm/dist/linux-x64/magic-agent +0 -0
- package/npm/dist/linux-x64/magic-client +0 -0
- package/npm/dist/win32-x64/magic-agent.exe +0 -0
- package/npm/dist/win32-x64/magic-client.exe +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1997 @@
|
|
|
1
|
+
package team
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"encoding/json"
|
|
6
|
+
"os"
|
|
7
|
+
"path/filepath"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
|
|
13
|
+
"github.com/darren/magic-agent/internal/agent"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
/* 用例的边界:**不碰真引擎**。
|
|
17
|
+
计划解析、路由、分层并发、事件投影、落盘这些都用假引擎钉住 —— 它们才是团队模式
|
|
18
|
+
自己的逻辑。真引擎的可用性由 CLI 层的能力探测负责(另有测试),混在一起只会得到
|
|
19
|
+
一批「本机没装 claude 就红」的假红。 */
|
|
20
|
+
|
|
21
|
+
// taskDocAnchor 任务书首行锚点,用来在假引擎收到的请求里认出「这是子任务」。
|
|
22
|
+
// 不能只认「任务书」三个字:角色提示词里也会提到任务书。
|
|
23
|
+
const taskDocAnchor = "# 任务书 ·"
|
|
24
|
+
|
|
25
|
+
// fakeEngine 假引擎:按 prompt 里的关键词回不同的东西。
|
|
26
|
+
type fakeEngine struct {
|
|
27
|
+
name string
|
|
28
|
+
// plan 计划阶段返回的正文(第一次调用)。
|
|
29
|
+
plan string
|
|
30
|
+
// task 子任务返回的正文(后续调用)。
|
|
31
|
+
task string
|
|
32
|
+
// taskSeq 子任务按调用次数依次返回的正文(用完后重复最后一个)。
|
|
33
|
+
// 用来模拟「第一次没交交接单、重试才补齐」。
|
|
34
|
+
taskSeq []string
|
|
35
|
+
// replan 再规划轮(adaptive)返回的正文;为空时回落到 task。
|
|
36
|
+
replan string
|
|
37
|
+
// failOn 命中该关键词就报错(模拟子任务失败)。
|
|
38
|
+
failOn string
|
|
39
|
+
// sessionID 引擎回报的自己的会话 id(团队模式要把它绑成主会话下的子会话)。
|
|
40
|
+
sessionID string
|
|
41
|
+
// writeFiles 每次子任务调用后写进工作目录的文件(模拟真的交出交付物)。
|
|
42
|
+
writeFiles []string
|
|
43
|
+
// writeFrom 从第几次子任务调用开始写 writeFiles(0/1 = 每次都写)。
|
|
44
|
+
// 用来模拟「第一次没交、重试才交」。
|
|
45
|
+
writeFrom int
|
|
46
|
+
|
|
47
|
+
mu sync.Mutex
|
|
48
|
+
calls []agent.Request
|
|
49
|
+
taskN int
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
func (f *fakeEngine) Name() string { return f.name }
|
|
53
|
+
func (f *fakeEngine) Detect() (bool, string) { return true, f.name }
|
|
54
|
+
func (f *fakeEngine) CapabilityFamily() string { return f.name }
|
|
55
|
+
func (f *fakeEngine) Complete(ctx context.Context, req agent.Request) (agent.Response, error) {
|
|
56
|
+
f.record(req)
|
|
57
|
+
return agent.Response{Engine: f.name, Text: f.reply(req)}, nil
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func (f *fakeEngine) Stream(ctx context.Context, req agent.Request, onEvent func(agent.StreamEvent)) (agent.StreamResult, error) {
|
|
61
|
+
f.record(req)
|
|
62
|
+
text := f.reply(req)
|
|
63
|
+
if f.failOn != "" && strings.Contains(text, f.failOn) {
|
|
64
|
+
return agent.StreamResult{}, errFake
|
|
65
|
+
}
|
|
66
|
+
/* 真引擎的正文事件**不带** session_id(只有 init 行 / turn_end / ask 带),
|
|
67
|
+
会话 id 走 OnSessionID 回调 —— 这里照实模拟,才能钉住「执行期每个子事件
|
|
68
|
+
都带得上 sub_session_id」这条路径。 */
|
|
69
|
+
if f.sessionID != "" && req.OnSessionID != nil {
|
|
70
|
+
req.OnSessionID(f.sessionID)
|
|
71
|
+
}
|
|
72
|
+
onEvent(agent.StreamEvent{Kind: agent.KindText, Text: text})
|
|
73
|
+
return agent.StreamResult{Response: agent.Response{Engine: f.name, Text: text, SessionID: f.sessionID}}, nil
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// record 记账,并在子任务调用后按配置落交付物(计划 / 汇总轮没有工作目录,不落)。
|
|
77
|
+
func (f *fakeEngine) record(req agent.Request) {
|
|
78
|
+
f.mu.Lock()
|
|
79
|
+
f.calls = append(f.calls, req)
|
|
80
|
+
n := 0
|
|
81
|
+
if req.Workspace != "" {
|
|
82
|
+
f.taskN++
|
|
83
|
+
n = f.taskN
|
|
84
|
+
}
|
|
85
|
+
f.mu.Unlock()
|
|
86
|
+
if n == 0 || len(f.writeFiles) == 0 || (f.writeFrom > 1 && n < f.writeFrom) {
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
if err := os.MkdirAll(req.Workspace, 0o755); err != nil {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
for _, name := range f.writeFiles {
|
|
93
|
+
_ = os.WriteFile(filepath.Join(req.Workspace, name), []byte("fake"), 0o644)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func (f *fakeEngine) reply(req agent.Request) string {
|
|
98
|
+
f.mu.Lock()
|
|
99
|
+
defer f.mu.Unlock()
|
|
100
|
+
// 第一次调用是计划阶段,之后都是子任务 / 汇总。
|
|
101
|
+
if len(f.calls) <= 1 {
|
|
102
|
+
return f.plan
|
|
103
|
+
}
|
|
104
|
+
// 再规划轮:没有工作目录、系统提示词带 adaptiveMarker。必须先于 taskSeq 判断,
|
|
105
|
+
// 否则它会按「第 N 个子任务」取到错的内容(多出的调用还会把序列整体错位)。
|
|
106
|
+
if req.Workspace == "" && strings.Contains(req.SystemPrompt, adaptiveMarker) {
|
|
107
|
+
if f.replan != "" {
|
|
108
|
+
return f.replan
|
|
109
|
+
}
|
|
110
|
+
return f.plan
|
|
111
|
+
}
|
|
112
|
+
if len(f.taskSeq) > 0 {
|
|
113
|
+
i := len(f.calls) - 2
|
|
114
|
+
if i >= len(f.taskSeq) {
|
|
115
|
+
i = len(f.taskSeq) - 1
|
|
116
|
+
}
|
|
117
|
+
return f.taskSeq[i]
|
|
118
|
+
}
|
|
119
|
+
return f.task
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func (f *fakeEngine) requests() []agent.Request {
|
|
123
|
+
f.mu.Lock()
|
|
124
|
+
defer f.mu.Unlock()
|
|
125
|
+
out := make([]agent.Request, len(f.calls))
|
|
126
|
+
copy(out, f.calls)
|
|
127
|
+
return out
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type fakeErr string
|
|
131
|
+
|
|
132
|
+
func (e fakeErr) Error() string { return string(e) }
|
|
133
|
+
|
|
134
|
+
const errFake = fakeErr("fake engine failure")
|
|
135
|
+
|
|
136
|
+
// install 把假引擎临时塞进注册表(Lookup 靠它解析路由)。
|
|
137
|
+
func install(t *testing.T, engines ...agent.Engine) {
|
|
138
|
+
t.Helper()
|
|
139
|
+
for _, e := range engines {
|
|
140
|
+
agent.Register(e)
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func newOpts(t *testing.T, eng agent.Engine, plan string) Options {
|
|
145
|
+
t.Helper()
|
|
146
|
+
return Options{
|
|
147
|
+
Engine: eng,
|
|
148
|
+
Prompt: "做一份带封面的介绍",
|
|
149
|
+
RunID: "run-test",
|
|
150
|
+
BaseDir: t.TempDir(),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// taskSnapshots 把 KindTasks 事件按到达顺序解析成一份份任务行快照。
|
|
155
|
+
// 快照语义:每份都是全量,取最后一份即当前状态。
|
|
156
|
+
func taskSnapshots(events []agent.StreamEvent) [][]TaskRow {
|
|
157
|
+
var out [][]TaskRow
|
|
158
|
+
for _, ev := range events {
|
|
159
|
+
if ev.Kind != agent.KindTasks {
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
var snap struct {
|
|
163
|
+
Tasks []TaskRow `json:"tasks"`
|
|
164
|
+
}
|
|
165
|
+
if err := json.Unmarshal([]byte(ev.Text), &snap); err != nil {
|
|
166
|
+
continue
|
|
167
|
+
}
|
|
168
|
+
out = append(out, snap.Tasks)
|
|
169
|
+
}
|
|
170
|
+
return out
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// rowOf 在一份快照里按 id 找一行(找不到返回 nil)。
|
|
174
|
+
func rowOf(rows []TaskRow, id string) *TaskRow {
|
|
175
|
+
for i := range rows {
|
|
176
|
+
if rows[i].ID == id {
|
|
177
|
+
return &rows[i]
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
return nil
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
func TestPlanValidate(t *testing.T) {
|
|
184
|
+
cases := []struct {
|
|
185
|
+
name string
|
|
186
|
+
plan Plan
|
|
187
|
+
ok bool
|
|
188
|
+
}{
|
|
189
|
+
{"正常", Plan{Goal: "g", Tasks: []Task{{ID: "t1", Kind: KindText, Prompt: "p"}}}, true},
|
|
190
|
+
{"空任务", Plan{Goal: "g"}, false},
|
|
191
|
+
{"缺 id", Plan{Tasks: []Task{{Kind: KindText, Prompt: "p"}}}, false},
|
|
192
|
+
{"id 重复", Plan{Tasks: []Task{
|
|
193
|
+
{ID: "t1", Kind: KindText, Prompt: "p"},
|
|
194
|
+
{ID: "t1", Kind: KindText, Prompt: "p"},
|
|
195
|
+
}}, false},
|
|
196
|
+
// 认不出的类别降级为 text(NormalizeKind 既有语义),不整份作废。
|
|
197
|
+
{"类别认不出退 text", Plan{Tasks: []Task{{ID: "t1", Kind: "music", Prompt: "p"}}}, true},
|
|
198
|
+
{"缺 prompt", Plan{Tasks: []Task{{ID: "t1", Kind: KindText}}}, false},
|
|
199
|
+
{"依赖不存在", Plan{Tasks: []Task{
|
|
200
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deps: []string{"t9"}},
|
|
201
|
+
}}, false},
|
|
202
|
+
{"成环", Plan{Tasks: []Task{
|
|
203
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deps: []string{"t2"}},
|
|
204
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}},
|
|
205
|
+
}}, false},
|
|
206
|
+
}
|
|
207
|
+
for _, c := range cases {
|
|
208
|
+
t.Run(c.name, func(t *testing.T) {
|
|
209
|
+
err := c.plan.Validate()
|
|
210
|
+
if c.ok && err != nil {
|
|
211
|
+
t.Fatalf("应当通过,却报错:%v", err)
|
|
212
|
+
}
|
|
213
|
+
if !c.ok && err == nil {
|
|
214
|
+
t.Fatalf("应当报错,却通过了")
|
|
215
|
+
}
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
func TestPlanOrderLayers(t *testing.T) {
|
|
221
|
+
p := Plan{Tasks: []Task{
|
|
222
|
+
{ID: "t3", Kind: KindText, Prompt: "p", Deps: []string{"t1", "t2"}},
|
|
223
|
+
{ID: "t2", Kind: KindText, Prompt: "p"},
|
|
224
|
+
{ID: "t1", Kind: KindText, Prompt: "p"},
|
|
225
|
+
}}
|
|
226
|
+
layers, err := p.Order()
|
|
227
|
+
if err != nil {
|
|
228
|
+
t.Fatal(err)
|
|
229
|
+
}
|
|
230
|
+
if len(layers) != 2 {
|
|
231
|
+
t.Fatalf("应当 2 层,得到 %d 层", len(layers))
|
|
232
|
+
}
|
|
233
|
+
if len(layers[0]) != 2 {
|
|
234
|
+
t.Fatalf("第一层应当 2 个任务(t1/t2),得到 %d", len(layers[0]))
|
|
235
|
+
}
|
|
236
|
+
// 层内按 id 排,输出稳定。
|
|
237
|
+
if layers[0][0].ID != "t1" || layers[0][1].ID != "t2" {
|
|
238
|
+
t.Fatalf("层内顺序应为 t1,t2,得到 %s,%s", layers[0][0].ID, layers[0][1].ID)
|
|
239
|
+
}
|
|
240
|
+
if layers[1][0].ID != "t3" {
|
|
241
|
+
t.Fatalf("第二层应为 t3,得到 %s", layers[1][0].ID)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func TestParsePlanHandlesFenceAndPrefix(t *testing.T) {
|
|
246
|
+
// 模型常见的三种包裹:裸 JSON / ```json 围栏 / 前面带一段话。
|
|
247
|
+
for _, raw := range []string{
|
|
248
|
+
`{"goal":"g","tasks":[{"id":"t1","kind":"text","prompt":"p"}]}`,
|
|
249
|
+
"```json\n{\"goal\":\"g\",\"tasks\":[{\"id\":\"t1\",\"kind\":\"text\",\"prompt\":\"p\"}]}\n```",
|
|
250
|
+
"我打算这么干:\n{\"goal\":\"g\",\"tasks\":[{\"id\":\"t1\",\"kind\":\"text\",\"prompt\":\"p\"}]}\n以上。",
|
|
251
|
+
} {
|
|
252
|
+
p, err := parsePlan(raw)
|
|
253
|
+
if err != nil {
|
|
254
|
+
t.Fatalf("解析失败(%q):%v", raw, err)
|
|
255
|
+
}
|
|
256
|
+
if len(p.Tasks) != 1 || p.Tasks[0].ID != "t1" {
|
|
257
|
+
t.Fatalf("解析结果不对:%+v", p)
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
func TestParsePlanNormalizesKind(t *testing.T) {
|
|
263
|
+
p, err := parsePlan(`{"goal":"g","tasks":[
|
|
264
|
+
{"id":"t1","kind":"Text","prompt":"p"},
|
|
265
|
+
{"id":"t2","kind":"image generation","prompt":"p"},
|
|
266
|
+
{"id":"t3","kind":"视频","prompt":"p"},
|
|
267
|
+
{"id":"t4","kind":"2d game","prompt":"p"},
|
|
268
|
+
{"id":"t5","kind":"代码改动","prompt":"p"},
|
|
269
|
+
{"id":"t6","kind":"认不出来的","prompt":"p"}
|
|
270
|
+
]}`)
|
|
271
|
+
if err != nil {
|
|
272
|
+
t.Fatal(err)
|
|
273
|
+
}
|
|
274
|
+
want := []string{KindText, KindImage, KindVideo, KindGame, KindCode, KindText}
|
|
275
|
+
for i, w := range want {
|
|
276
|
+
if p.Tasks[i].Kind != w {
|
|
277
|
+
t.Fatalf("%s 的类别应为 %s,得到 %s", p.Tasks[i].ID, w, p.Tasks[i].Kind)
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 代码类任务必须能走到 code(早先 "code" 被归进 text 分支,代码引擎永远接不到活)。
|
|
283
|
+
func TestNormalizeKindAcceptsCode(t *testing.T) {
|
|
284
|
+
for _, raw := range []string{"code", "Coding", "代码", "编码", "编程", "程序"} {
|
|
285
|
+
if got := NormalizeKind(raw); got != KindCode {
|
|
286
|
+
t.Fatalf("%q 应归一为 %s,得到 %q", raw, KindCode, got)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
for _, raw := range []string{"text", "doc", "文案"} {
|
|
290
|
+
if got := NormalizeKind(raw); got != KindText {
|
|
291
|
+
t.Fatalf("%q 应归一为 %s,得到 %q", raw, KindText, got)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 计划提示词必须同时表达两件事:code 类存在,且简单代码不拆子任务。
|
|
297
|
+
func TestPlannerPromptGatesCodeKind(t *testing.T) {
|
|
298
|
+
for _, want := range []string{"code:", "不拆子任务", "简单代码"} {
|
|
299
|
+
if !strings.Contains(plannerSystemPrompt, want) {
|
|
300
|
+
t.Fatalf("计划提示词应包含 %q:\n%s", want, plannerSystemPrompt)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
eng := &fakeEngine{name: "main"}
|
|
304
|
+
hint := routeHint(Options{Engine: eng})
|
|
305
|
+
if !strings.Contains(hint, "- code(coder)→ ") {
|
|
306
|
+
t.Fatalf("路由提示应列出 code 类及其角色:\n%s", hint)
|
|
307
|
+
}
|
|
308
|
+
if !strings.Contains(hint, "简单代码由主引擎") {
|
|
309
|
+
t.Fatalf("路由提示应说明简单代码跟随主引擎:\n%s", hint)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// 拆解粒度只改「最多几个」,其余规则不随档位变(字段契约是解析器依赖的硬要求)。
|
|
314
|
+
func TestPlannerPromptSplit(t *testing.T) {
|
|
315
|
+
cases := []struct {
|
|
316
|
+
opts Options
|
|
317
|
+
want []string
|
|
318
|
+
deny []string
|
|
319
|
+
}{
|
|
320
|
+
{Options{}, []string{"不超过 6 个", "不拆子任务", "简单代码"}, []string{"{{MAX_TASKS}}", "{{CODE_GATE}}"}},
|
|
321
|
+
{Options{Split: SplitCoarse}, []string{"不超过 3 个"}, nil},
|
|
322
|
+
{Options{Split: SplitFine}, []string{"不超过 10 个"}, nil},
|
|
323
|
+
{Options{SplitSimpleCode: true}, []string{"代码也要拆", "纯问答"}, []string{"由主引擎在\n 最终回复里直接给出"}},
|
|
324
|
+
}
|
|
325
|
+
for _, c := range cases {
|
|
326
|
+
got := plannerPrompt(c.opts)
|
|
327
|
+
for _, want := range c.want {
|
|
328
|
+
if !strings.Contains(got, want) {
|
|
329
|
+
t.Fatalf("split=%q splitCode=%v 的提示词应包含 %q:\n%s",
|
|
330
|
+
c.opts.Split, c.opts.SplitSimpleCode, want, got)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
for _, deny := range c.deny {
|
|
334
|
+
if strings.Contains(got, deny) {
|
|
335
|
+
t.Fatalf("split=%q splitCode=%v 的提示词不该包含 %q:\n%s",
|
|
336
|
+
c.opts.Split, c.opts.SplitSimpleCode, deny, got)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// 角色规则覆盖:写进角色表,替掉内置 Duty;其余角色的描述不受影响。
|
|
343
|
+
func TestRoleHintRuleOverride(t *testing.T) {
|
|
344
|
+
hint := roleHint(map[string]string{RoleKey("coder"): "只拆多文件、要构建或测试的改动"})
|
|
345
|
+
if !strings.Contains(hint, "coder [code] —— 只拆多文件、要构建或测试的改动") {
|
|
346
|
+
t.Fatalf("自定义规则应替掉内置描述:\n%s", hint)
|
|
347
|
+
}
|
|
348
|
+
if !strings.Contains(hint, "writer [text] —— 把材料写成结构清晰") {
|
|
349
|
+
t.Fatalf("没改的角色应保留内置描述:\n%s", hint)
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
func TestParseRules(t *testing.T) {
|
|
354
|
+
rules, err := ParseRules([]string{"role:coder= 只拆 多文件 ", "writer=写文档"})
|
|
355
|
+
if err != nil {
|
|
356
|
+
t.Fatal(err)
|
|
357
|
+
}
|
|
358
|
+
if got := rules[RoleKey("coder")]; got != "只拆 多文件" {
|
|
359
|
+
t.Fatalf("coder 规则 = %q(应折叠空白并把裸名归一成 role:coder)", got)
|
|
360
|
+
}
|
|
361
|
+
if got := rules[RoleKey("writer")]; got != "写文档" {
|
|
362
|
+
t.Fatalf("writer 规则 = %q", got)
|
|
363
|
+
}
|
|
364
|
+
for _, bad := range []string{"coder", "role:nobody=x", "role:coder="} {
|
|
365
|
+
if _, err := ParseRules([]string{bad}); err == nil {
|
|
366
|
+
t.Fatalf("%q 应报错", bad)
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
long := make([]rune, RulesMaxLen+1)
|
|
370
|
+
for i := range long {
|
|
371
|
+
long[i] = '规'
|
|
372
|
+
}
|
|
373
|
+
if _, err := ParseRules([]string{"role:coder=" + string(long)}); err == nil {
|
|
374
|
+
t.Fatal("超长规则应报错")
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func TestNormalizeSplit(t *testing.T) {
|
|
379
|
+
for raw, want := range map[string]string{
|
|
380
|
+
"": SplitStandard,
|
|
381
|
+
" ": SplitStandard,
|
|
382
|
+
"STANDARD": SplitStandard,
|
|
383
|
+
"Coarse": SplitCoarse,
|
|
384
|
+
"fine": SplitFine,
|
|
385
|
+
} {
|
|
386
|
+
got, err := NormalizeSplit(raw)
|
|
387
|
+
if err != nil || got != want {
|
|
388
|
+
t.Fatalf("NormalizeSplit(%q) = %q, %v;want %q", raw, got, err, want)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if _, err := NormalizeSplit("tiny"); err == nil {
|
|
392
|
+
t.Fatal("非法粒度应报错")
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// 简单代码开关也要在路由提示里生效:否则模型只看到「代码也要拆」,上下文里仍留着
|
|
397
|
+
// 「简单代码由主引擎」这条相反的口径。
|
|
398
|
+
func TestRouteHintSplitSimpleCode(t *testing.T) {
|
|
399
|
+
eng := &fakeEngine{name: "main"}
|
|
400
|
+
strict := routeHint(Options{Engine: eng})
|
|
401
|
+
if !strings.Contains(strict, "简单代码由主引擎") {
|
|
402
|
+
t.Fatalf("默认应保留「简单代码由主引擎」:\n%s", strict)
|
|
403
|
+
}
|
|
404
|
+
loose := routeHint(Options{Engine: eng, SplitSimpleCode: true})
|
|
405
|
+
if strings.Contains(loose, "简单代码由主引擎") {
|
|
406
|
+
t.Fatalf("开了 simple-code 后不该再写「简单代码由主引擎」:\n%s", loose)
|
|
407
|
+
}
|
|
408
|
+
if !strings.Contains(loose, "只要需求里要改代码就拆") {
|
|
409
|
+
t.Fatalf("开了 simple-code 后应改成「要改代码就拆」:\n%s", loose)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
func TestParsePlanNormalizesRole(t *testing.T) {
|
|
414
|
+
p, err := parsePlan(`{"goal":"g","tasks":[
|
|
415
|
+
{"id":"t1","role":"视觉","prompt":"p"},
|
|
416
|
+
{"id":"t2","role":"文字","prompt":"p"},
|
|
417
|
+
{"id":"t3","role":"role:Reviewer","kind":"text","prompt":"p"},
|
|
418
|
+
{"id":"t4","role":"不存在的","prompt":"p"}
|
|
419
|
+
]}`)
|
|
420
|
+
if err != nil {
|
|
421
|
+
t.Fatal(err)
|
|
422
|
+
}
|
|
423
|
+
// role 是唯一作者字段:认不出 / 漏写都补默认角色 writer,而不是留空。
|
|
424
|
+
wantRole := []string{"designer", "writer", "reviewer", "writer"}
|
|
425
|
+
wantKind := []string{KindImage, KindText, KindText, KindText}
|
|
426
|
+
for i := range wantRole {
|
|
427
|
+
if p.Tasks[i].Role != wantRole[i] {
|
|
428
|
+
t.Fatalf("%s 的角色应为 %q,得到 %q", p.Tasks[i].ID, wantRole[i], p.Tasks[i].Role)
|
|
429
|
+
}
|
|
430
|
+
// 类别一律由角色派生,模型写的 kind 不参与(t3 的 text 只是巧合一致)。
|
|
431
|
+
if p.Tasks[i].Kind != wantKind[i] {
|
|
432
|
+
t.Fatalf("%s 的类别应为 %q,得到 %q", p.Tasks[i].ID, wantKind[i], p.Tasks[i].Kind)
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// 角色与类别冲突时以角色为准:否则会出现「派给图片引擎却带着代码人设」。
|
|
438
|
+
func TestParsePlanRoleWinsOverKind(t *testing.T) {
|
|
439
|
+
p, err := parsePlan(`{"goal":"g","tasks":[
|
|
440
|
+
{"id":"t1","role":"coder","kind":"image","prompt":"p"},
|
|
441
|
+
{"id":"t2","role":"designer","kind":"code","prompt":"p"}
|
|
442
|
+
]}`)
|
|
443
|
+
if err != nil {
|
|
444
|
+
t.Fatal(err)
|
|
445
|
+
}
|
|
446
|
+
if p.Tasks[0].Kind != KindCode || p.Tasks[1].Kind != KindImage {
|
|
447
|
+
t.Fatalf("冲突时应以角色为准,得到 %s / %s", p.Tasks[0].Kind, p.Tasks[1].Kind)
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// EffectiveKind 的优先级:角色 > 旧 kind 字段 > text。直接构造 Task 的调用方也走这里。
|
|
452
|
+
func TestTaskEffectiveKind(t *testing.T) {
|
|
453
|
+
if got := (Task{Role: "designer", Kind: KindCode}).EffectiveKind(); got != KindImage {
|
|
454
|
+
t.Fatalf("角色优先,应为 %s,得到 %s", KindImage, got)
|
|
455
|
+
}
|
|
456
|
+
if got := (Task{Kind: KindCode}).EffectiveKind(); got != KindCode {
|
|
457
|
+
t.Fatalf("没有角色时按旧 kind 回退,应为 %s,得到 %s", KindCode, got)
|
|
458
|
+
}
|
|
459
|
+
if got := (Task{}).EffectiveKind(); got != KindText {
|
|
460
|
+
t.Fatalf("都没有时退 text,得到 %s", got)
|
|
461
|
+
}
|
|
462
|
+
// 漏写 role 的旧计划:按 kind 落到该类别的默认角色。
|
|
463
|
+
got := normalizeTask(Task{ID: "t1", Kind: KindCode, Prompt: "p"})
|
|
464
|
+
if got.Role != "coder" || got.Kind != KindCode {
|
|
465
|
+
t.Fatalf("旧计划的 kind 应回退到 coder/code,得到 %q/%q", got.Role, got.Kind)
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
func TestTaskDocRendersRole(t *testing.T) {
|
|
470
|
+
dir := t.TempDir()
|
|
471
|
+
task := normalizeTask(Task{ID: "t1", Title: "写正文", Prompt: "写 200 字"})
|
|
472
|
+
plan := Plan{Goal: "g", Tasks: []Task{task}}
|
|
473
|
+
body := TaskDoc(dir, plan, task, nil)
|
|
474
|
+
if !strings.Contains(body, "## 你的角色") || !strings.Contains(body, "**writer**") {
|
|
475
|
+
t.Fatalf("归一化后的任务一定有角色,任务书应渲染角色段:\n%s", body)
|
|
476
|
+
}
|
|
477
|
+
if !strings.Contains(body, "结构清晰") {
|
|
478
|
+
t.Fatalf("角色段应包含职责描述:\n%s", body)
|
|
479
|
+
}
|
|
480
|
+
// 未知角色(未经归一化直接构造)不凭空多出一段。
|
|
481
|
+
body = TaskDoc(dir, Plan{Goal: "g"}, Task{ID: "t2", Title: "x", Prompt: "p"}, nil)
|
|
482
|
+
if strings.Contains(body, "## 你的角色") {
|
|
483
|
+
t.Fatalf("未知角色时不该渲染角色段:\n%s", body)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
func TestRolePromptToolsAndPermission(t *testing.T) {
|
|
488
|
+
opts := Options{SystemPrompt: "主提示词", Tools: agent.ToolsOn, Permission: agent.PermissionAcceptEdits}
|
|
489
|
+
got := systemPromptFor(opts, Task{Role: "writer"})
|
|
490
|
+
if !strings.HasPrefix(got, "主提示词") || !strings.Contains(got, "写作者") {
|
|
491
|
+
t.Fatalf("角色片段应追加在主提示词之后,得到 %q", got)
|
|
492
|
+
}
|
|
493
|
+
if got := systemPromptFor(opts, Task{}); got != "主提示词" {
|
|
494
|
+
t.Fatalf("未设角色时应保持原样,得到 %q", got)
|
|
495
|
+
}
|
|
496
|
+
// 内置角色没声明工具 / 权限 → 沿用全局,不凭空收窄。
|
|
497
|
+
if !toolsFor(opts, Task{Role: "writer"}).IsOn() {
|
|
498
|
+
t.Fatal("角色没声明工具时应沿用全局")
|
|
499
|
+
}
|
|
500
|
+
if permissionFor(opts, Task{Role: "writer"}) != agent.PermissionAcceptEdits {
|
|
501
|
+
t.Fatal("角色没声明权限时应沿用全局")
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
func TestRouteForPrefersRoleOverKind(t *testing.T) {
|
|
506
|
+
main := &fakeEngine{name: "mainengine"}
|
|
507
|
+
opts := Options{
|
|
508
|
+
Engine: main,
|
|
509
|
+
Model: "m1",
|
|
510
|
+
Routes: map[string]Route{
|
|
511
|
+
KindText: {Engine: "texteng"},
|
|
512
|
+
"role:writer": {Engine: "writereng"},
|
|
513
|
+
"reviewer": {Engine: "revieweng"}, // 裸角色名也认
|
|
514
|
+
},
|
|
515
|
+
}
|
|
516
|
+
if r := RouteFor(Task{Kind: KindText, Role: "writer"}, opts); r.Engine != "writereng" {
|
|
517
|
+
t.Fatalf("角色路由应优先于类别路由,得到 %+v", r)
|
|
518
|
+
}
|
|
519
|
+
if r := RouteFor(Task{Kind: KindText, Role: "reviewer"}, opts); r.Engine != "revieweng" {
|
|
520
|
+
t.Fatalf("裸角色名也应命中,得到 %+v", r)
|
|
521
|
+
}
|
|
522
|
+
// 没配角色 → 落回类别路由。
|
|
523
|
+
if r := RouteFor(Task{Kind: KindText, Role: "researcher"}, opts); r.Engine != "texteng" {
|
|
524
|
+
t.Fatalf("没配角色时应走类别路由,得到 %+v", r)
|
|
525
|
+
}
|
|
526
|
+
// 任务写死的仍然最优先。
|
|
527
|
+
if r := RouteFor(Task{Kind: KindText, Role: "writer", Engine: "x"}, opts); r.Engine != "x" {
|
|
528
|
+
t.Fatalf("任务写死的引擎应最优先,得到 %+v", r)
|
|
529
|
+
}
|
|
530
|
+
// 角色配了引擎但没配模型 → 用角色默认模型(内置角色没配,应为空)。
|
|
531
|
+
if r := RouteFor(Task{Kind: KindText, Role: "writer"}, Options{Routes: map[string]Route{"role:writer": {Engine: "writereng"}}}); r.Model != "" {
|
|
532
|
+
t.Fatalf("角色路由没给模型时应为空,得到 %q", r.Model)
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
func TestParseRoutesAcceptsRole(t *testing.T) {
|
|
537
|
+
got, err := ParseRoutes([]string{"role:reviewer=claude/haiku", "role:writer=weng"})
|
|
538
|
+
if err != nil {
|
|
539
|
+
t.Fatal(err)
|
|
540
|
+
}
|
|
541
|
+
if r := got["role:reviewer"]; r.Engine != "claude" || r.Model != "haiku" {
|
|
542
|
+
t.Fatalf("角色路由不对:%+v", r)
|
|
543
|
+
}
|
|
544
|
+
if r := got["role:writer"]; r.Engine != "weng" {
|
|
545
|
+
t.Fatalf("角色路由不对:%+v", r)
|
|
546
|
+
}
|
|
547
|
+
if _, err := ParseRoutes([]string{"role:nobody=x"}); err == nil {
|
|
548
|
+
t.Fatal("未知角色应当报错")
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// 内置角色必须按五类子任务对齐:每一类至少一个默认角色,且角色不引入第六类。
|
|
553
|
+
func TestBuiltinRolesCoverKinds(t *testing.T) {
|
|
554
|
+
for _, kind := range Kinds {
|
|
555
|
+
if DefaultRoleForKind(kind) == nil {
|
|
556
|
+
t.Fatalf("%s 类没有默认角色 —— 角色表应与五类子任务对齐", kind)
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
for _, r := range builtinRoles {
|
|
560
|
+
found := false
|
|
561
|
+
for _, kind := range Kinds {
|
|
562
|
+
if r.Kind == kind {
|
|
563
|
+
found = true
|
|
564
|
+
break
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
if !found {
|
|
568
|
+
t.Fatalf("角色 %s 的默认类别 %q 不在五类子任务里", r.Name, r.Kind)
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if r := DefaultRoleForKind(KindVideo); r == nil || r.Name != "director" {
|
|
572
|
+
t.Fatalf("video 的默认角色应为 director,得到 %+v", r)
|
|
573
|
+
}
|
|
574
|
+
if r := DefaultRoleForKind(KindCode); r == nil || r.Name != "coder" {
|
|
575
|
+
t.Fatalf("code 的默认角色应为 coder,得到 %+v", r)
|
|
576
|
+
}
|
|
577
|
+
if hint := roleHint(nil); !strings.Contains(hint, "director [video]") || !strings.Contains(hint, "coder [code]") {
|
|
578
|
+
t.Fatalf("角色提示词应带上默认类别:\n%s", hint)
|
|
579
|
+
}
|
|
580
|
+
// 计划漏写 kind 时,各角色落到自己的类别。
|
|
581
|
+
for role, kind := range map[string]string{
|
|
582
|
+
"designer": KindImage, "导演": KindVideo, "builder": KindGame,
|
|
583
|
+
"coder": KindCode, "开发": KindCode, "engineer": KindCode,
|
|
584
|
+
} {
|
|
585
|
+
if got := normalizeTask(Task{Role: role, Prompt: "p"}); got.Kind != kind {
|
|
586
|
+
t.Fatalf("角色 %s 的默认类别应为 %s,得到 %s", role, kind, got.Kind)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// 角色别名不能抢占类别路由键:`--team-route video=…` 必须仍然是 video 类。
|
|
592
|
+
func TestRoleAliasesDoNotShadowKindRoutes(t *testing.T) {
|
|
593
|
+
for _, kind := range RouteKinds {
|
|
594
|
+
if got := NormalizeRole(kind); got != "" {
|
|
595
|
+
t.Fatalf("类别 %q 被角色别名抢成了 %q", kind, got)
|
|
596
|
+
}
|
|
597
|
+
got, err := ParseRoutes([]string{kind + "=eng"})
|
|
598
|
+
if err != nil {
|
|
599
|
+
t.Fatal(err)
|
|
600
|
+
}
|
|
601
|
+
if _, ok := got[kind]; !ok {
|
|
602
|
+
t.Fatalf("%s 路由键被改写:%+v", kind, got)
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// 影像角色的常用中文写法仍要认。
|
|
606
|
+
if got := NormalizeRole("导演"); got != "director" {
|
|
607
|
+
t.Fatalf("「导演」应归一为 director,得到 %q", got)
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
func TestParseRoutes(t *testing.T) {
|
|
612
|
+
got, err := ParseRoutes([]string{"image=llm/hunyuan-image-3.0", "text=claude", "game=codebuddy/x/y"})
|
|
613
|
+
if err != nil {
|
|
614
|
+
t.Fatal(err)
|
|
615
|
+
}
|
|
616
|
+
if got[KindImage].Engine != "llm" || got[KindImage].Model != "hunyuan-image-3.0" {
|
|
617
|
+
t.Fatalf("image 路由不对:%+v", got[KindImage])
|
|
618
|
+
}
|
|
619
|
+
if got[KindText].Engine != "claude" || got[KindText].Model != "" {
|
|
620
|
+
t.Fatalf("text 路由不对:%+v", got[KindText])
|
|
621
|
+
}
|
|
622
|
+
// 只按第一个 / 切:模型名里可能还有 /。
|
|
623
|
+
if got[KindGame].Engine != "codebuddy" || got[KindGame].Model != "x/y" {
|
|
624
|
+
t.Fatalf("game 路由不对:%+v", got[KindGame])
|
|
625
|
+
}
|
|
626
|
+
if _, err := ParseRoutes([]string{"music=x"}); err == nil {
|
|
627
|
+
t.Fatal("非法类别应当报错")
|
|
628
|
+
}
|
|
629
|
+
if _, err := ParseRoutes([]string{"image"}); err == nil {
|
|
630
|
+
t.Fatal("缺 = 应当报错")
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
func TestRouteForPrefersExplicitThenMain(t *testing.T) {
|
|
635
|
+
main := &fakeEngine{name: "mainengine"}
|
|
636
|
+
opts := Options{Engine: main, Model: "m1", Routes: map[string]Route{KindImage: {Engine: "imgen"}}}
|
|
637
|
+
// 任务写死的优先。
|
|
638
|
+
if r := RouteFor(Task{Kind: KindImage, Engine: "x", Model: "y"}, opts); r.Engine != "x" || r.Model != "y" {
|
|
639
|
+
t.Fatalf("任务写死的应当优先,得到 %+v", r)
|
|
640
|
+
}
|
|
641
|
+
// 其次配置。
|
|
642
|
+
if r := RouteFor(Task{Kind: KindImage}, opts); r.Engine != "imgen" {
|
|
643
|
+
t.Fatalf("应当走配置,得到 %+v", r)
|
|
644
|
+
}
|
|
645
|
+
// text 没配 → 主引擎。
|
|
646
|
+
if r := RouteFor(Task{Kind: KindText}, opts); r.Engine != "mainengine" || r.Model != "m1" {
|
|
647
|
+
t.Fatalf("text 应当回落主引擎,得到 %+v", r)
|
|
648
|
+
}
|
|
649
|
+
// video / code 没配 → 同样跟随主引擎(五类一致)。
|
|
650
|
+
if r := RouteFor(Task{Kind: KindVideo}, opts); r.Engine != "mainengine" || r.Model != "m1" {
|
|
651
|
+
t.Fatalf("video 没配应当回落主引擎,得到 %+v", r)
|
|
652
|
+
}
|
|
653
|
+
if r := RouteFor(Task{Kind: KindCode}, opts); r.Engine != "mainengine" || r.Model != "m1" {
|
|
654
|
+
t.Fatalf("code 没配应当回落主引擎,得到 %+v", r)
|
|
655
|
+
}
|
|
656
|
+
// 主引擎也为空 → 空路由(execute 按失败处理)。
|
|
657
|
+
if r := RouteFor(Task{Kind: KindGame}, Options{}); r.Engine != "" {
|
|
658
|
+
t.Fatalf("主引擎为空时应当返回空路由,得到 %+v", r)
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
func TestParseRoutesAcceptsPlan(t *testing.T) {
|
|
663
|
+
// plan 是**阶段**键,不是子任务类别:NormalizeKind 认不出它,所以 ParseRoutes
|
|
664
|
+
// 必须走自己的归一(含中文写法)。
|
|
665
|
+
got, err := ParseRoutes([]string{"计划=claude/haiku"})
|
|
666
|
+
if err != nil {
|
|
667
|
+
t.Fatal(err)
|
|
668
|
+
}
|
|
669
|
+
if r := got[KindPlan]; r.Engine != "claude" || r.Model != "haiku" {
|
|
670
|
+
t.Fatalf("plan 路由不对:%+v", r)
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
func TestPlanRouteFollowsMainUnlessConfigured(t *testing.T) {
|
|
675
|
+
main := &fakeEngine{name: "mainengine"}
|
|
676
|
+
opts := Options{Engine: main, Model: "m1"}
|
|
677
|
+
if r := PlanRoute(opts); r.Engine != "mainengine" || r.Model != "m1" {
|
|
678
|
+
t.Fatalf("没配 plan 应当跟随主引擎,得到 %+v", r)
|
|
679
|
+
}
|
|
680
|
+
opts.Routes = map[string]Route{KindPlan: {Engine: "plannereng", Model: "pm"}}
|
|
681
|
+
if r := PlanRoute(opts); r.Engine != "plannereng" || r.Model != "pm" {
|
|
682
|
+
t.Fatalf("配了 plan 应当照办,得到 %+v", r)
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
func TestPlanOnOtherEngineKeepsSessionApart(t *testing.T) {
|
|
687
|
+
main := &fakeEngine{name: "mainengine", task: "汇总"}
|
|
688
|
+
planner := &fakeEngine{
|
|
689
|
+
name: "plannereng",
|
|
690
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","title":"写","prompt":"写一段"}]}`,
|
|
691
|
+
}
|
|
692
|
+
install(t, main, planner)
|
|
693
|
+
opts := newOpts(t, main, "")
|
|
694
|
+
opts.SessionID = "main-sid"
|
|
695
|
+
opts.Routes = map[string]Route{KindPlan: {Engine: "plannereng"}}
|
|
696
|
+
|
|
697
|
+
_, sid, err := makePlan(context.Background(), opts)
|
|
698
|
+
if err != nil {
|
|
699
|
+
t.Fatal(err)
|
|
700
|
+
}
|
|
701
|
+
// 跨引擎的 session id 不能交给汇总(那是另一个引擎的上下文)。
|
|
702
|
+
if sid != "" {
|
|
703
|
+
t.Fatalf("计划换了引擎时不该返回 session,得到 %q", sid)
|
|
704
|
+
}
|
|
705
|
+
reqs := planner.requests()
|
|
706
|
+
if len(reqs) != 1 || reqs[0].SessionID != "" {
|
|
707
|
+
t.Fatalf("计划引擎不该拿到主引擎的 session:%+v", reqs)
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
func TestRunWritesPlanAndTaskDocs(t *testing.T) {
|
|
712
|
+
eng := &fakeEngine{
|
|
713
|
+
name: "fakeplan",
|
|
714
|
+
plan: `{"goal":"做一份介绍","tasks":[
|
|
715
|
+
{"id":"t1","kind":"text","title":"写正文","prompt":"写 200 字","deliverables":["brief.md"]},
|
|
716
|
+
{"id":"t2","kind":"text","title":"写摘要","prompt":"写 50 字","deps":["t1"]}
|
|
717
|
+
]}`,
|
|
718
|
+
task: "好的",
|
|
719
|
+
// 交付物必须真的落盘:缺交付物会触发重试(那是另一个用例),这里要钉的是
|
|
720
|
+
// 「正常一次通过」的文档与事件形状。
|
|
721
|
+
writeFiles: []string{"brief.md"},
|
|
722
|
+
}
|
|
723
|
+
install(t, eng)
|
|
724
|
+
|
|
725
|
+
var mu sync.Mutex
|
|
726
|
+
var events []agent.StreamEvent
|
|
727
|
+
opts := newOpts(t, eng, "")
|
|
728
|
+
opts.OnEvent = func(ev agent.StreamEvent) {
|
|
729
|
+
mu.Lock()
|
|
730
|
+
events = append(events, ev)
|
|
731
|
+
mu.Unlock()
|
|
732
|
+
}
|
|
733
|
+
res, err := Run(context.Background(), opts)
|
|
734
|
+
if err != nil {
|
|
735
|
+
t.Fatal(err)
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
// 计划文档:存在、只读、含关键锚点。
|
|
739
|
+
st, err := os.Stat(res.PlanPath)
|
|
740
|
+
if err != nil {
|
|
741
|
+
t.Fatalf("PLAN.md 没写出来:%v", err)
|
|
742
|
+
}
|
|
743
|
+
if mode := st.Mode().Perm(); mode != 0o444 {
|
|
744
|
+
t.Fatalf("PLAN.md 应当只读(0444),实际 %o", mode)
|
|
745
|
+
}
|
|
746
|
+
body, _ := os.ReadFile(res.PlanPath)
|
|
747
|
+
for _, want := range []string{"## 任务总览", "T1", "T2", "brief.md"} {
|
|
748
|
+
if !strings.Contains(string(body), want) {
|
|
749
|
+
t.Fatalf("PLAN.md 缺 %q:\n%s", want, body)
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// 任务书:每个任务一份、只读、含工作目录与交付物。
|
|
754
|
+
for _, id := range []string{"t1", "t2"} {
|
|
755
|
+
p := TaskDocPath(res.RunDir, id)
|
|
756
|
+
doc, err := os.ReadFile(p)
|
|
757
|
+
if err != nil {
|
|
758
|
+
t.Fatalf("%s 没写出来:%v", id, err)
|
|
759
|
+
}
|
|
760
|
+
if !strings.Contains(string(doc), TaskDir(res.RunDir, id)) {
|
|
761
|
+
t.Fatalf("%s 任务书里没有工作目录:\n%s", id, doc)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
// 子任务工作目录。
|
|
765
|
+
for _, id := range []string{"t1", "t2"} {
|
|
766
|
+
if st, err := os.Stat(TaskDir(res.RunDir, id)); err != nil || !st.IsDir() {
|
|
767
|
+
t.Fatalf("%s 工作目录没建:%v", id, err)
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// OUT 落盘。
|
|
772
|
+
if _, err := os.Stat(OutputPath(res.RunDir, "t1")); err != nil {
|
|
773
|
+
t.Fatalf("OUT-t1.md 没写:%v", err)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/* 最终答复的产物清单由 core 追加(见 reduce.go 的 artsBlock):短路径 + 列表项,
|
|
777
|
+
不再由模型把长绝对路径一行一个地吐出来(那会被 markdown 折成一大段)。 */
|
|
778
|
+
for _, want := range []string{"**产物**", "- `t1/brief.md` · 写正文", "全部位于:`" + res.RunDir + "`"} {
|
|
779
|
+
if !strings.Contains(res.Text, want) {
|
|
780
|
+
t.Fatalf("最终答复缺 %q:\n%s", want, res.Text)
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
if strings.Contains(res.Text, filepath.Join(res.RunDir, "t1", "brief.md")) {
|
|
784
|
+
t.Fatalf("产物清单不该再出现长绝对路径:\n%s", res.Text)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/* 事件:主会话里只有**一张任务列表卡**(KindTasks 全量快照)。
|
|
788
|
+
计划一确定就把 2 行都登记进去(含等上游的 t2);开跑 / 收尾各广播一份;
|
|
789
|
+
子任务的内部事件**不进主会话**(它们写自己的会话日志)。 */
|
|
790
|
+
mu.Lock()
|
|
791
|
+
snaps := taskSnapshots(events)
|
|
792
|
+
parented, taskCards := 0, 0
|
|
793
|
+
for _, ev := range events {
|
|
794
|
+
if ev.Parent != "" {
|
|
795
|
+
parented++
|
|
796
|
+
}
|
|
797
|
+
if ev.Kind == agent.KindToolUse && ev.Name == "Task" {
|
|
798
|
+
taskCards++
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
mu.Unlock()
|
|
802
|
+
if len(snaps) == 0 {
|
|
803
|
+
t.Fatal("应当至少广播一份任务列表快照")
|
|
804
|
+
}
|
|
805
|
+
if len(snaps[0]) != 2 || rowOf(snaps[0], "t1") == nil || rowOf(snaps[0], "t2") == nil {
|
|
806
|
+
t.Fatalf("第一份快照就该有 2 行(计划确定即全部登记),得到 %+v", snaps[0])
|
|
807
|
+
}
|
|
808
|
+
if r := rowOf(snaps[0], "t2"); r == nil || r.Status != RowPending || len(r.Deps) != 1 || r.Deps[0] != "t1" {
|
|
809
|
+
t.Fatalf("t2 应当 pending 且带 deps=[t1],得到 %+v", r)
|
|
810
|
+
}
|
|
811
|
+
// 依赖关系在进度上必须成立:t1 还在跑的时候 t2 不能已经开跑。
|
|
812
|
+
for _, rows := range snaps {
|
|
813
|
+
t1, t2 := rowOf(rows, "t1"), rowOf(rows, "t2")
|
|
814
|
+
if t1 == nil || t2 == nil {
|
|
815
|
+
t.Fatalf("快照必须始终是 2 行全量,得到 %+v", rows)
|
|
816
|
+
}
|
|
817
|
+
if t1.Status == RowRunning && t2.Status != RowPending {
|
|
818
|
+
t.Fatalf("t1 还在跑时 t2 不该是 %s", t2.Status)
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
last := snaps[len(snaps)-1]
|
|
822
|
+
for _, id := range []string{"t1", "t2"} {
|
|
823
|
+
if r := rowOf(last, id); r == nil || r.Status != TaskDone {
|
|
824
|
+
t.Fatalf("末份快照里 %s 应当 done,得到 %+v", id, r)
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
if parented != 0 || taskCards != 0 {
|
|
828
|
+
t.Fatalf("子任务事件不该再转发到主会话(parent=%d, tool_use Task=%d)", parented, taskCards)
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
// 子任务**不带会话 id**:这是「不共享上下文」的硬约束。
|
|
832
|
+
// 只挑真正的子任务调用(提示词是任务书),计划 / 汇总那两轮不算。
|
|
833
|
+
taskCalls := 0
|
|
834
|
+
for _, req := range eng.requests() {
|
|
835
|
+
if len(req.Messages) == 0 || !strings.Contains(req.Messages[0].Content, taskDocAnchor) {
|
|
836
|
+
continue
|
|
837
|
+
}
|
|
838
|
+
taskCalls++
|
|
839
|
+
if req.SessionID != "" || req.Continue {
|
|
840
|
+
t.Fatalf("子任务不该接续会话:SessionID=%q Continue=%v", req.SessionID, req.Continue)
|
|
841
|
+
}
|
|
842
|
+
if req.Workspace == "" {
|
|
843
|
+
t.Fatal("子任务必须有独立工作目录")
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
if taskCalls != 2 {
|
|
847
|
+
t.Fatalf("应当派发 2 个子任务,得到 %d", taskCalls)
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
func TestRunDegradesWhenPlanUnparseable(t *testing.T) {
|
|
852
|
+
eng := &fakeEngine{name: "fakebad", plan: "我不会给你 JSON", task: "直接干完了"}
|
|
853
|
+
install(t, eng)
|
|
854
|
+
|
|
855
|
+
opts := newOpts(t, eng, "")
|
|
856
|
+
var notices []string
|
|
857
|
+
opts.OnEvent = func(ev agent.StreamEvent) {
|
|
858
|
+
if ev.Kind == agent.KindNotice {
|
|
859
|
+
notices = append(notices, ev.Text)
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
res, err := Run(context.Background(), opts)
|
|
863
|
+
if err != nil {
|
|
864
|
+
t.Fatal(err)
|
|
865
|
+
}
|
|
866
|
+
if len(res.Tasks) != 1 || res.Tasks[0].ID != "t1" {
|
|
867
|
+
t.Fatalf("应当降级为单任务,得到 %+v", res.Tasks)
|
|
868
|
+
}
|
|
869
|
+
join := strings.Join(notices, "\n")
|
|
870
|
+
if !strings.Contains(join, "降级") {
|
|
871
|
+
t.Fatalf("降级必须有明说:\n%s", join)
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
func TestRunFallsBackToMainEngineForUnroutedKind(t *testing.T) {
|
|
876
|
+
eng := &fakeEngine{
|
|
877
|
+
name: "fakeimg",
|
|
878
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"image","prompt":"画图"}]}`,
|
|
879
|
+
task: "ok",
|
|
880
|
+
}
|
|
881
|
+
install(t, eng)
|
|
882
|
+
|
|
883
|
+
opts := newOpts(t, eng, "")
|
|
884
|
+
res, err := Run(context.Background(), opts)
|
|
885
|
+
if err != nil {
|
|
886
|
+
t.Fatal(err)
|
|
887
|
+
}
|
|
888
|
+
if res.Tasks[0].State != TaskDone {
|
|
889
|
+
t.Fatalf("没配专用引擎的类别应当跟随主引擎跑完,得到 %s(%s)", res.Tasks[0].State, res.Tasks[0].Reason)
|
|
890
|
+
}
|
|
891
|
+
if res.Tasks[0].Engine != eng.Name() {
|
|
892
|
+
t.Fatalf("应当回落主引擎 %s,得到 %q", eng.Name(), res.Tasks[0].Engine)
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
func TestRunSkipsDownstreamOfFailedRequired(t *testing.T) {
|
|
897
|
+
eng := &fakeEngine{
|
|
898
|
+
name: "fakefail",
|
|
899
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","prompt":"这一步会炸"},{"id":"t2","kind":"text","prompt":"依赖它","deps":["t1"]}]}`,
|
|
900
|
+
task: "这一步会炸",
|
|
901
|
+
failOn: "这一步会炸",
|
|
902
|
+
}
|
|
903
|
+
install(t, eng)
|
|
904
|
+
|
|
905
|
+
opts := newOpts(t, eng, "")
|
|
906
|
+
res, err := Run(context.Background(), opts)
|
|
907
|
+
if err != nil {
|
|
908
|
+
t.Fatal(err)
|
|
909
|
+
}
|
|
910
|
+
if res.Tasks[0].State != TaskFailed {
|
|
911
|
+
t.Fatalf("t1 应当失败,得到 %s", res.Tasks[0].State)
|
|
912
|
+
}
|
|
913
|
+
if res.Tasks[1].State != TaskSkipped {
|
|
914
|
+
t.Fatalf("t2 应当被跳过(上游必需任务失败),得到 %s", res.Tasks[1].State)
|
|
915
|
+
}
|
|
916
|
+
if !strings.Contains(res.Tasks[1].Reason, "上游") {
|
|
917
|
+
t.Fatalf("跳过原因要说清是上游:%s", res.Tasks[1].Reason)
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
func TestRunPlanOnlyDoesNotExecute(t *testing.T) {
|
|
922
|
+
eng := &fakeEngine{
|
|
923
|
+
name: "fakeonly",
|
|
924
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","prompt":"p"}]}`,
|
|
925
|
+
task: "不该被调用",
|
|
926
|
+
}
|
|
927
|
+
install(t, eng)
|
|
928
|
+
|
|
929
|
+
opts := newOpts(t, eng, "")
|
|
930
|
+
opts.PlanOnly = true
|
|
931
|
+
res, err := Run(context.Background(), opts)
|
|
932
|
+
if err != nil {
|
|
933
|
+
t.Fatal(err)
|
|
934
|
+
}
|
|
935
|
+
if !strings.Contains(res.Text, res.PlanPath) {
|
|
936
|
+
t.Fatalf("plan-only 的输出应当给出计划路径:%s", res.Text)
|
|
937
|
+
}
|
|
938
|
+
for _, req := range eng.requests() {
|
|
939
|
+
if strings.Contains(req.Messages[0].Content, taskDocAnchor) {
|
|
940
|
+
t.Fatal("plan-only 不该派发子任务")
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if _, err := os.Stat(OutputPath(res.RunDir, "t1")); err == nil {
|
|
944
|
+
t.Fatal("plan-only 不该产出 OUT 文件")
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
func TestTaskDocContainsUpstream(t *testing.T) {
|
|
949
|
+
dir := t.TempDir()
|
|
950
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
951
|
+
{ID: "t1", Kind: KindText, Title: "上游"},
|
|
952
|
+
{ID: "t2", Kind: KindText, Title: "下游", Deps: []string{"t1"}, Prompt: "做事"},
|
|
953
|
+
}}
|
|
954
|
+
up := map[string]TaskResult{"t1": {ID: "t1", Title: "上游", State: TaskDone, Text: "上游结论:用蓝色", Arts: []string{"/tmp/a.png"}}}
|
|
955
|
+
doc := TaskDoc(dir, plan, plan.Tasks[1], up)
|
|
956
|
+
for _, want := range []string{"上游结论:用蓝色", "/tmp/a.png", "## 任务", "## 交付物"} {
|
|
957
|
+
if !strings.Contains(doc, want) {
|
|
958
|
+
t.Fatalf("任务书缺 %q:\n%s", want, doc)
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
func TestTruncateKeepsHeadAndTail(t *testing.T) {
|
|
964
|
+
s := strings.Repeat("头", 100) + strings.Repeat("中", 100) + strings.Repeat("尾", 100)
|
|
965
|
+
got := Truncate(s, 30)
|
|
966
|
+
if !strings.Contains(got, "省略") {
|
|
967
|
+
t.Fatalf("截断要标注省略量:%s", got)
|
|
968
|
+
}
|
|
969
|
+
if !strings.HasPrefix(got, "头") || !strings.HasSuffix(got, "尾") {
|
|
970
|
+
t.Fatalf("截断应保留首尾:%s", got)
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
func TestChooseRunDir(t *testing.T) {
|
|
975
|
+
opts := Options{RunID: "run-abc"}
|
|
976
|
+
// 默认空间 → ~/.magic-agent/team/<run_id>
|
|
977
|
+
home, _ := os.UserHomeDir()
|
|
978
|
+
got, err := chooseRunDir(opts)
|
|
979
|
+
if err != nil {
|
|
980
|
+
t.Fatal(err)
|
|
981
|
+
}
|
|
982
|
+
if want := filepath.Join(home, ".magic-agent", "team", "run-abc"); got != want {
|
|
983
|
+
t.Fatalf("默认空间应落 %s,得到 %s", want, got)
|
|
984
|
+
}
|
|
985
|
+
// 选了空间 → <ws>/.magic/team/<run_id>
|
|
986
|
+
opts.Workspace = "/tmp/ws"
|
|
987
|
+
got, _ = chooseRunDir(opts)
|
|
988
|
+
if want := filepath.Join("/tmp/ws", ".magic", "team", "run-abc"); got != want {
|
|
989
|
+
t.Fatalf("空间内应落 %s,得到 %s", want, got)
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
func TestArtsAndMissing(t *testing.T) {
|
|
994
|
+
dir := t.TempDir()
|
|
995
|
+
png := filepath.Join(dir, "cover.png")
|
|
996
|
+
if err := os.WriteFile(png, []byte("x"), 0o644); err != nil {
|
|
997
|
+
t.Fatal(err)
|
|
998
|
+
}
|
|
999
|
+
got := artsFromText("生成了 " + png + " 与 /nope/x.png 还有 dir/rel.md")
|
|
1000
|
+
if len(got) != 1 || got[0] != png {
|
|
1001
|
+
t.Fatalf("应当只认存在且绝对路径的产物,得到 %v", got)
|
|
1002
|
+
}
|
|
1003
|
+
var r TaskResult
|
|
1004
|
+
collectDeliverables(dir, []string{"cover.png", "brief.md"}, &r)
|
|
1005
|
+
if len(r.Missing) != 1 || r.Missing[0] != "brief.md" {
|
|
1006
|
+
t.Fatalf("缺失清单不对:%v", r.Missing)
|
|
1007
|
+
}
|
|
1008
|
+
if len(r.Arts) != 1 || r.Arts[0] != png {
|
|
1009
|
+
t.Fatalf("已存在的交付物应进产物:%v", r.Arts)
|
|
1010
|
+
}
|
|
1011
|
+
// 越界声明按文件名比,不当路径用。
|
|
1012
|
+
var bad TaskResult
|
|
1013
|
+
collectDeliverables(dir, []string{"../../etc/passwd"}, &bad)
|
|
1014
|
+
if len(bad.Missing) != 1 || bad.Missing[0] != "passwd" {
|
|
1015
|
+
t.Fatalf("越界声明应按文件名处理,得到 %v", bad.Missing)
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
// 交付物声明允许带子目录(`impl/fizzbuzz.js`):模型常把文件分目录放,
|
|
1020
|
+
// 一律取 basename 会误判「缺少交付物」(2026-10-22 实测团队 6/6 全误判)。
|
|
1021
|
+
// 越界写法仍要拦住:路径清洗后才跑出任务目录的(`a/../../etc/passwd`)也按文件名比。
|
|
1022
|
+
func TestCollectDeliverablesSubdir(t *testing.T) {
|
|
1023
|
+
dir := t.TempDir()
|
|
1024
|
+
if err := os.MkdirAll(filepath.Join(dir, "impl"), 0o755); err != nil {
|
|
1025
|
+
t.Fatal(err)
|
|
1026
|
+
}
|
|
1027
|
+
js := filepath.Join(dir, "impl", "fizzbuzz.js")
|
|
1028
|
+
if err := os.WriteFile(js, []byte("x"), 0o644); err != nil {
|
|
1029
|
+
t.Fatal(err)
|
|
1030
|
+
}
|
|
1031
|
+
md := filepath.Join(dir, "README.md")
|
|
1032
|
+
if err := os.WriteFile(md, []byte("x"), 0o644); err != nil {
|
|
1033
|
+
t.Fatal(err)
|
|
1034
|
+
}
|
|
1035
|
+
var r TaskResult
|
|
1036
|
+
collectDeliverables(dir, []string{"./impl/fizzbuzz.js", "README.md", "impl/nope.js"}, &r)
|
|
1037
|
+
if len(r.Arts) != 2 || r.Arts[0] != js || r.Arts[1] != md {
|
|
1038
|
+
t.Fatalf("子目录交付物应进产物:%v", r.Arts)
|
|
1039
|
+
}
|
|
1040
|
+
if len(r.Missing) != 1 || r.Missing[0] != "impl/nope.js" {
|
|
1041
|
+
t.Fatalf("缺失清单应保留声明里的相对路径:%v", r.Missing)
|
|
1042
|
+
}
|
|
1043
|
+
var esc TaskResult
|
|
1044
|
+
collectDeliverables(dir, []string{"impl/../../../etc/passwd"}, &esc)
|
|
1045
|
+
if len(esc.Arts) != 0 || len(esc.Missing) != 1 || esc.Missing[0] != "passwd" {
|
|
1046
|
+
t.Fatalf("清洗后越界的声明应按文件名比:arts=%v missing=%v", esc.Arts, esc.Missing)
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// 子任务失败/超时也要上报**已经落盘**的交付物 —— 否则汇总会说「什么都没产出」。
|
|
1051
|
+
// 回归:2026-10-21 实测 t2 画完 cover.png 后 1 分钟超时,ARTS-t2.json 的 arts 为 null,
|
|
1052
|
+
// 汇总正文写「封面没有做出来」。
|
|
1053
|
+
func TestRunTaskFailureStillReportsDeliverables(t *testing.T) {
|
|
1054
|
+
eng := &fakeEngine{name: "fake-fail-arts", plan: "画画", task: "画画", failOn: "画画"}
|
|
1055
|
+
install(t, eng)
|
|
1056
|
+
opts := newOpts(t, eng, "")
|
|
1057
|
+
runDir := filepath.Join(opts.BaseDir, "run-1")
|
|
1058
|
+
dir := TaskDir(runDir, "t1")
|
|
1059
|
+
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
1060
|
+
t.Fatal(err)
|
|
1061
|
+
}
|
|
1062
|
+
png := filepath.Join(dir, "cover.png")
|
|
1063
|
+
if err := os.WriteFile(png, []byte("png"), 0o644); err != nil {
|
|
1064
|
+
t.Fatal(err)
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
task := Task{ID: "t1", Kind: KindImage, Title: "画封面", Prompt: "画画", Deliverables: []string{"cover.png", "brief.md"}}
|
|
1068
|
+
var mu sync.Mutex
|
|
1069
|
+
var events []agent.StreamEvent
|
|
1070
|
+
board := newBoard(func(ev agent.StreamEvent) {
|
|
1071
|
+
mu.Lock()
|
|
1072
|
+
events = append(events, ev)
|
|
1073
|
+
mu.Unlock()
|
|
1074
|
+
})
|
|
1075
|
+
// 真实流程里 Team.Run 已把计划里的任务先行登记(见 board.add);这里直接调
|
|
1076
|
+
// runTask,所以要自己补上这一行。
|
|
1077
|
+
board.add(task, Route{Engine: eng.Name()})
|
|
1078
|
+
r := runTask(context.Background(), opts, Plan{Goal: "g", Tasks: []Task{task}}, task, runDir, nil, board)
|
|
1079
|
+
|
|
1080
|
+
if r.State != TaskFailed {
|
|
1081
|
+
t.Fatalf("应判失败,得到 %s", r.State)
|
|
1082
|
+
}
|
|
1083
|
+
if len(r.Arts) != 1 || r.Arts[0] != png {
|
|
1084
|
+
t.Fatalf("失败前已落盘的交付物应进产物:%v", r.Arts)
|
|
1085
|
+
}
|
|
1086
|
+
if len(r.Missing) != 1 || r.Missing[0] != "brief.md" {
|
|
1087
|
+
t.Fatalf("缺失清单不对:%v", r.Missing)
|
|
1088
|
+
}
|
|
1089
|
+
raw, err := os.ReadFile(ArtsPath(runDir, "t1"))
|
|
1090
|
+
if err != nil {
|
|
1091
|
+
t.Fatal(err)
|
|
1092
|
+
}
|
|
1093
|
+
if !strings.Contains(string(raw), png) {
|
|
1094
|
+
t.Fatalf("ARTS 应当带上已产出文件:%s", raw)
|
|
1095
|
+
}
|
|
1096
|
+
out, err := os.ReadFile(OutputPath(runDir, "t1"))
|
|
1097
|
+
if err != nil {
|
|
1098
|
+
t.Fatal(err)
|
|
1099
|
+
}
|
|
1100
|
+
if !strings.Contains(string(out), "✓ `cover.png`") || !strings.Contains(string(out), "✗ `brief.md`") {
|
|
1101
|
+
t.Fatalf("OUT 交付物清单不对:\n%s", out)
|
|
1102
|
+
}
|
|
1103
|
+
// 末份快照必须把这一行落定为 failed 并带上原因。
|
|
1104
|
+
mu.Lock()
|
|
1105
|
+
snaps := taskSnapshots(events)
|
|
1106
|
+
mu.Unlock()
|
|
1107
|
+
if len(snaps) == 0 {
|
|
1108
|
+
t.Fatal("应当有任务列表快照")
|
|
1109
|
+
}
|
|
1110
|
+
row := rowOf(snaps[len(snaps)-1], "t1")
|
|
1111
|
+
if row == nil || row.Status != TaskFailed || row.Note == "" {
|
|
1112
|
+
t.Fatalf("末份快照应当是 t1 failed 且带原因,得到 %+v", row)
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/*
|
|
1117
|
+
子任务 = 普通会话(2026-10-22 用户定稿):
|
|
1118
|
+
|
|
1119
|
+
子任务的过程事件原样交给 SubSession,写进它自己的会话日志;主会话里只有一张
|
|
1120
|
+
任务列表卡。这里钉住三条边界:
|
|
1121
|
+
|
|
1122
|
+
① 子任务事件全部 Push 给子会话,一条都不进主会话;
|
|
1123
|
+
② 主会话只有 KindTasks 快照,快照里带 run_id / session_id(点得进子会话);
|
|
1124
|
+
③ 收尾时 Finish 拿到子会话 id 与 done。
|
|
1125
|
+
*/
|
|
1126
|
+
type fakeSub struct {
|
|
1127
|
+
runID string
|
|
1128
|
+
|
|
1129
|
+
mu sync.Mutex
|
|
1130
|
+
pushed []agent.StreamEvent
|
|
1131
|
+
session string
|
|
1132
|
+
finished bool
|
|
1133
|
+
state string
|
|
1134
|
+
reason string
|
|
1135
|
+
hooked bool
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
func (f *fakeSub) RunID() string { return f.runID }
|
|
1139
|
+
|
|
1140
|
+
func (f *fakeSub) SetSessionID(id string) {
|
|
1141
|
+
f.mu.Lock()
|
|
1142
|
+
defer f.mu.Unlock()
|
|
1143
|
+
f.session = id
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
func (f *fakeSub) Push(ev agent.StreamEvent) {
|
|
1147
|
+
f.mu.Lock()
|
|
1148
|
+
defer f.mu.Unlock()
|
|
1149
|
+
f.pushed = append(f.pushed, ev)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
func (f *fakeSub) Finish(sessionID, state, reason string) {
|
|
1153
|
+
f.mu.Lock()
|
|
1154
|
+
defer f.mu.Unlock()
|
|
1155
|
+
f.finished = true
|
|
1156
|
+
if sessionID != "" {
|
|
1157
|
+
f.session = sessionID
|
|
1158
|
+
}
|
|
1159
|
+
f.state, f.reason = state, reason
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
func (f *fakeSub) Context(parent context.Context) context.Context {
|
|
1163
|
+
f.hooked = true
|
|
1164
|
+
return parent
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
func TestRunTaskPushesEventsToSubSession(t *testing.T) {
|
|
1168
|
+
eng := &fakeEngine{name: "fake-sub-sess", plan: "", task: "写好了", sessionID: "sess-child-1"}
|
|
1169
|
+
// 这是**直接** runTask(不经过计划阶段):先记一笔,让 fake 的调用计数越过
|
|
1170
|
+
// 「第一次是计划」那条判断,子任务那次才会返回 task 正文。
|
|
1171
|
+
eng.mu.Lock()
|
|
1172
|
+
eng.calls = append(eng.calls, agent.Request{})
|
|
1173
|
+
eng.mu.Unlock()
|
|
1174
|
+
install(t, eng)
|
|
1175
|
+
opts := newOpts(t, eng, "")
|
|
1176
|
+
runDir := filepath.Join(opts.BaseDir, "run-sub")
|
|
1177
|
+
task := Task{ID: "t1", Kind: KindText, Title: "写正文", Prompt: "写正文"}
|
|
1178
|
+
if err := os.MkdirAll(TaskDir(runDir, task.ID), 0o755); err != nil {
|
|
1179
|
+
t.Fatal(err)
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
sub := &fakeSub{runID: "run-child-1"}
|
|
1183
|
+
opts.OpenSubSession = func(Task, string, string, string) SubSession { return sub }
|
|
1184
|
+
|
|
1185
|
+
var mu sync.Mutex
|
|
1186
|
+
var events []agent.StreamEvent
|
|
1187
|
+
board := newBoard(func(ev agent.StreamEvent) {
|
|
1188
|
+
mu.Lock()
|
|
1189
|
+
events = append(events, ev)
|
|
1190
|
+
mu.Unlock()
|
|
1191
|
+
})
|
|
1192
|
+
// 真实流程里 Team.Run 已把计划里的任务先行登记(见 board.add);这里直接调
|
|
1193
|
+
// runTask,所以要自己补上这一行。
|
|
1194
|
+
board.add(task, Route{Engine: eng.Name()})
|
|
1195
|
+
r := runTask(context.Background(), opts, Plan{Goal: "g", Tasks: []Task{task}}, task, runDir, nil, board)
|
|
1196
|
+
|
|
1197
|
+
if r.SessionID != "sess-child-1" {
|
|
1198
|
+
t.Fatalf("TaskResult 应记下子会话 id,得到 %q", r.SessionID)
|
|
1199
|
+
}
|
|
1200
|
+
// ① 正文事件进了子会话自己的日志。
|
|
1201
|
+
sub.mu.Lock()
|
|
1202
|
+
pushed := append([]agent.StreamEvent(nil), sub.pushed...)
|
|
1203
|
+
finished, state, session := sub.finished, sub.state, sub.session
|
|
1204
|
+
sub.mu.Unlock()
|
|
1205
|
+
if len(pushed) == 0 {
|
|
1206
|
+
t.Fatal("子任务事件应当 Push 给子会话")
|
|
1207
|
+
}
|
|
1208
|
+
var gotText bool
|
|
1209
|
+
for _, ev := range pushed {
|
|
1210
|
+
if ev.Kind == agent.KindText && strings.Contains(ev.Text, "写好了") {
|
|
1211
|
+
gotText = true
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
if !gotText {
|
|
1215
|
+
t.Fatalf("子会话日志里应当有正文,得到 %+v", pushed)
|
|
1216
|
+
}
|
|
1217
|
+
// ③ 收尾带上子会话 id 与 done。
|
|
1218
|
+
if !finished || state != TaskDone || session != "sess-child-1" {
|
|
1219
|
+
t.Fatalf("Finish 参数不对:finished=%v state=%q session=%q", finished, state, session)
|
|
1220
|
+
}
|
|
1221
|
+
if !sub.hooked {
|
|
1222
|
+
t.Fatal("子任务 ctx 应当挂上子引擎 pid 登记钩子")
|
|
1223
|
+
}
|
|
1224
|
+
// ② 主会话只有任务列表快照,没有子任务事件。
|
|
1225
|
+
mu.Lock()
|
|
1226
|
+
defer mu.Unlock()
|
|
1227
|
+
if len(events) == 0 {
|
|
1228
|
+
t.Fatal("主会话应当有任务列表快照")
|
|
1229
|
+
}
|
|
1230
|
+
for _, ev := range events {
|
|
1231
|
+
if ev.Kind != agent.KindTasks {
|
|
1232
|
+
t.Fatalf("主会话只该有 tasks 快照,得到 %+v", ev)
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
row := rowOf(taskSnapshots(events)[len(taskSnapshots(events))-1], "t1")
|
|
1236
|
+
if row == nil || row.RunID != "run-child-1" || row.SessionID != "sess-child-1" || row.Status != TaskDone {
|
|
1237
|
+
t.Fatalf("快照应带子会话 run_id / session_id 且 done,得到 %+v", row)
|
|
1238
|
+
}
|
|
1239
|
+
// 留档:OUT-t1.md 里也有人能读到的「会话:」行。
|
|
1240
|
+
out, err := os.ReadFile(OutputPath(runDir, "t1"))
|
|
1241
|
+
if err != nil {
|
|
1242
|
+
t.Fatal(err)
|
|
1243
|
+
}
|
|
1244
|
+
if !strings.Contains(string(out), "会话:sess-child-1") {
|
|
1245
|
+
t.Fatalf("OUT 应留下子会话 id:\n%s", out)
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
func TestFallbackSummaryIsHonest(t *testing.T) {
|
|
1250
|
+
res := []TaskResult{
|
|
1251
|
+
{ID: "t1", Title: "写正文", Kind: KindText, State: TaskDone, Text: "正文在此", Arts: []string{"/tmp/a.png"}},
|
|
1252
|
+
{ID: "t2", Title: "画封面", Kind: KindImage, State: TaskFailed, Reason: "no engine for kind=image"},
|
|
1253
|
+
}
|
|
1254
|
+
plan := Plan{Goal: "g"}
|
|
1255
|
+
got := fallbackSummary(plan, res, "超时")
|
|
1256
|
+
for _, want := range []string{"已完成 1 / 2", "汇总失败:超时", "/tmp/a.png", "no engine for kind=image"} {
|
|
1257
|
+
if !strings.Contains(got, want) {
|
|
1258
|
+
t.Fatalf("兜底汇报缺 %q:\n%s", want, got)
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// 产物清单由 core 确定性生成:短路径 + 运行目录只出现一次(否则手机上就是
|
|
1264
|
+
// 「三条长绝对路径被折成一大段」,见 reduce.go 的 artsBlock)。
|
|
1265
|
+
func TestArtsBlockUsesRunRelativePaths(t *testing.T) {
|
|
1266
|
+
runDir := filepath.Join("/tmp", "demo", ".magic", "team", "run-1")
|
|
1267
|
+
res := []TaskResult{
|
|
1268
|
+
{ID: "t1", Title: "实现", State: TaskDone, OutputPath: filepath.Join(runDir, "OUT-t1.md"),
|
|
1269
|
+
Arts: []string{filepath.Join(runDir, "t1", "impl", "fizzbuzz.js"), filepath.Join(runDir, "t1", "test", "fizzbuzz.test.js")}},
|
|
1270
|
+
{ID: "t2", Title: "写文档", State: TaskDone,
|
|
1271
|
+
Arts: []string{filepath.Join(runDir, "t2", "docs", "README.md"), "/elsewhere/a.md"}},
|
|
1272
|
+
}
|
|
1273
|
+
got := artsBlock(res)
|
|
1274
|
+
for _, want := range []string{
|
|
1275
|
+
"- `t1/impl/fizzbuzz.js` · 实现\n",
|
|
1276
|
+
"- `t1/test/fizzbuzz.test.js` · 实现\n",
|
|
1277
|
+
"- `t2/docs/README.md` · 写文档\n",
|
|
1278
|
+
"- `/elsewhere/a.md` · 写文档\n", // 运行目录外:原样给绝对路径,不伪造短路径
|
|
1279
|
+
"全部位于:`" + runDir + "`",
|
|
1280
|
+
} {
|
|
1281
|
+
if !strings.Contains(got, want) {
|
|
1282
|
+
t.Fatalf("产物清单缺 %q:\n%s", want, got)
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
if n := strings.Count(got, runDir); n != 1 {
|
|
1286
|
+
t.Fatalf("运行目录应只出现一次,实际 %d 次:\n%s", n, got)
|
|
1287
|
+
}
|
|
1288
|
+
if strings.Contains(got, runDir+"/t1/impl") {
|
|
1289
|
+
t.Fatalf("列表项不该再出现长绝对路径:\n%s", got)
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
func TestReducePromptDoesNotDumpFullText(t *testing.T) {
|
|
1294
|
+
long := strings.Repeat("长", reduceCap*2)
|
|
1295
|
+
res := []TaskResult{{ID: "t1", Title: "t", Kind: KindText, State: TaskDone, Text: long}}
|
|
1296
|
+
got := reducePrompt(Plan{Goal: "g"}, res)
|
|
1297
|
+
if len([]rune(got)) > reduceCap*2 {
|
|
1298
|
+
t.Fatalf("汇总提示词不该灌全文,长度 %d", len([]rune(got)))
|
|
1299
|
+
}
|
|
1300
|
+
if !strings.Contains(got, "省略") {
|
|
1301
|
+
t.Fatal("截断应标注")
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
func TestWriteOutputRecordsMissing(t *testing.T) {
|
|
1306
|
+
dir := t.TempDir()
|
|
1307
|
+
r := TaskResult{ID: "t1", Title: "标题", State: TaskDone, Text: "正文", Missing: []string{"b.md"}}
|
|
1308
|
+
writeOutput(dir, r, []string{"a.md", "b.md"})
|
|
1309
|
+
body, err := os.ReadFile(OutputPath(dir, "t1"))
|
|
1310
|
+
if err != nil {
|
|
1311
|
+
t.Fatal(err)
|
|
1312
|
+
}
|
|
1313
|
+
if !strings.Contains(string(body), "✓ `a.md`") || !strings.Contains(string(body), "✗ `b.md`") {
|
|
1314
|
+
t.Fatalf("交付物核对没写进 OUT:\n%s", body)
|
|
1315
|
+
}
|
|
1316
|
+
var arts map[string]any
|
|
1317
|
+
raw, _ := os.ReadFile(ArtsPath(dir, "t1"))
|
|
1318
|
+
if err := json.Unmarshal(raw, &arts); err != nil {
|
|
1319
|
+
t.Fatalf("ARTS 不是合法 JSON:%v", err)
|
|
1320
|
+
}
|
|
1321
|
+
if arts["state"] != TaskDone {
|
|
1322
|
+
t.Fatalf("ARTS 里的状态不对:%v", arts["state"])
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// 执行阶段心跳:只播报「还在跑且很久没动静」的任务,收尾的不再播。
|
|
1327
|
+
func TestBoardQuietLinesOnlyAnnouncesSilentRunningTasks(t *testing.T) {
|
|
1328
|
+
b := newBoard(func(agent.StreamEvent) {})
|
|
1329
|
+
t1 := Task{ID: "t1", Kind: KindText, Title: "写介绍"}
|
|
1330
|
+
t2 := Task{ID: "t2", Kind: KindImage, Title: "画封面"}
|
|
1331
|
+
t3 := Task{ID: "t3", Kind: KindText, Title: "汇总图"}
|
|
1332
|
+
for _, task := range []Task{t1, t2, t3} {
|
|
1333
|
+
b.add(task, Route{})
|
|
1334
|
+
}
|
|
1335
|
+
b.start(t1, "e", "")
|
|
1336
|
+
b.start(t2, "e", "")
|
|
1337
|
+
b.finish(t3, TaskResult{ID: "t3", State: TaskDone})
|
|
1338
|
+
|
|
1339
|
+
// t1 刚刚有输出;t3 已收尾;t2 静默超过阈值。
|
|
1340
|
+
b.touch("t1")
|
|
1341
|
+
b.mu.Lock()
|
|
1342
|
+
b.last["t2"] = time.Now().Add(-time.Minute)
|
|
1343
|
+
b.mu.Unlock()
|
|
1344
|
+
|
|
1345
|
+
got := b.quietLines()
|
|
1346
|
+
if len(got) != 1 || !strings.Contains(got[0], "t2") || !strings.Contains(got[0], "画封面") {
|
|
1347
|
+
t.Fatalf("只该播报静默中的 t2,得到 %v", got)
|
|
1348
|
+
}
|
|
1349
|
+
if again := b.quietLines(); len(again) != 0 {
|
|
1350
|
+
t.Fatalf("刚播过的任务不应立刻重复:%v", again)
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// 子任务**内部**的事件只刷新活跃时间,不能把这一行当成收尾 —— 否则任务还在跑也被静音。
|
|
1355
|
+
// 回归:2026-10-21 实测 t2 内部读文件后静默十几分钟,一条心跳都没播。
|
|
1356
|
+
func TestBoardTouchKeepsRunningRowAlive(t *testing.T) {
|
|
1357
|
+
b := newBoard(func(agent.StreamEvent) {})
|
|
1358
|
+
t2 := Task{ID: "t2", Kind: KindImage, Title: "画封面"}
|
|
1359
|
+
b.add(t2, Route{})
|
|
1360
|
+
b.start(t2, "e", "")
|
|
1361
|
+
|
|
1362
|
+
b.mu.Lock()
|
|
1363
|
+
b.last["t2"] = time.Now().Add(-time.Minute)
|
|
1364
|
+
b.mu.Unlock()
|
|
1365
|
+
|
|
1366
|
+
b.touch("t2")
|
|
1367
|
+
if got := b.quietLines(); len(got) != 0 {
|
|
1368
|
+
t.Fatalf("刚有动静的任务不该被心跳播报:%v", got)
|
|
1369
|
+
}
|
|
1370
|
+
b.mu.Lock()
|
|
1371
|
+
row := b.rows["t2"]
|
|
1372
|
+
b.mu.Unlock()
|
|
1373
|
+
if row == nil || row.Status != RowRunning {
|
|
1374
|
+
t.Fatalf("内部事件不该改变这一行的状态,得到 %+v", row)
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
// 真正的收尾由 finish 落定(之后不再进心跳候选)。
|
|
1378
|
+
b.finish(t2, TaskResult{ID: "t2", State: TaskDone})
|
|
1379
|
+
b.mu.Lock()
|
|
1380
|
+
b.last["t2"] = time.Now().Add(-time.Minute)
|
|
1381
|
+
b.mu.Unlock()
|
|
1382
|
+
if got := b.quietLines(); len(got) != 0 {
|
|
1383
|
+
t.Fatalf("已收尾的任务不该被心跳播报:%v", got)
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
/*
|
|
1388
|
+
P0 核对闭环:交接单 + 验收命令 + 一次重试 + inputs 开跑前校验。
|
|
1389
|
+
|
|
1390
|
+
四条各自的边界:
|
|
1391
|
+
- inputs 只有「由 deps 声明为交付物」的才算硬依赖(否则计划里一处措辞就能断整条链);
|
|
1392
|
+
- 缺交付物 / 验收命令非零 → 重试一次(执行失败不重试,见 taskMaxAttempts);
|
|
1393
|
+
- 交接单取正文里最后一个 JSON 对象,解析不出就回落正文截断。
|
|
1394
|
+
*/
|
|
1395
|
+
|
|
1396
|
+
func TestMissingInputsOnlyCountsHardDeps(t *testing.T) {
|
|
1397
|
+
dir := t.TempDir()
|
|
1398
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1399
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"brief.md"}},
|
|
1400
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"brief.md", "logo.png"}},
|
|
1401
|
+
}}
|
|
1402
|
+
// brief.md 由 deps 声明 → 硬依赖且当前不存在;logo.png 没人认领 → 只是提示。
|
|
1403
|
+
got := MissingInputs(dir, plan, plan.Tasks[1])
|
|
1404
|
+
if len(got) != 1 {
|
|
1405
|
+
t.Fatalf("只该核对硬依赖,得到 %v", got)
|
|
1406
|
+
}
|
|
1407
|
+
if !strings.Contains(got[0], "brief.md") || !strings.Contains(got[0], "t1") {
|
|
1408
|
+
t.Fatalf("缺失原因要能归因到产出者:%s", got[0])
|
|
1409
|
+
}
|
|
1410
|
+
// 文件补齐后放行。
|
|
1411
|
+
if err := os.MkdirAll(TaskDir(dir, "t1"), 0o755); err != nil {
|
|
1412
|
+
t.Fatal(err)
|
|
1413
|
+
}
|
|
1414
|
+
if err := os.WriteFile(filepath.Join(TaskDir(dir, "t1"), "brief.md"), []byte("x"), 0o644); err != nil {
|
|
1415
|
+
t.Fatal(err)
|
|
1416
|
+
}
|
|
1417
|
+
if got := MissingInputs(dir, plan, plan.Tasks[1]); len(got) != 0 {
|
|
1418
|
+
t.Fatalf("文件已存在就不该报缺:%v", got)
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
func TestMissingInputsResolvesSubdirDeliverable(t *testing.T) {
|
|
1423
|
+
dir := t.TempDir()
|
|
1424
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1425
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"impl/fizzbuzz.js", "./test/fizzbuzz.test.js"}},
|
|
1426
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"impl/fizzbuzz.js"}},
|
|
1427
|
+
}}
|
|
1428
|
+
// 上游交付物在子目录里:只比 basename 会去找 t1/fizzbuzz.js,从而误报缺失。
|
|
1429
|
+
specs := ResolveInputs(dir, plan, plan.Tasks[1])
|
|
1430
|
+
if len(specs) != 1 || specs[0].Path != filepath.Join(TaskDir(dir, "t1"), "impl", "fizzbuzz.js") {
|
|
1431
|
+
t.Fatalf("输入应按上游声明的子目录解析,得到 %+v", specs)
|
|
1432
|
+
}
|
|
1433
|
+
if got := MissingInputs(dir, plan, plan.Tasks[1]); len(got) != 1 {
|
|
1434
|
+
t.Fatalf("文件确实不存在时要报缺,得到 %v", got)
|
|
1435
|
+
}
|
|
1436
|
+
p := filepath.Join(TaskDir(dir, "t1"), "impl", "fizzbuzz.js")
|
|
1437
|
+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
|
1438
|
+
t.Fatal(err)
|
|
1439
|
+
}
|
|
1440
|
+
if err := os.WriteFile(p, []byte("x"), 0o644); err != nil {
|
|
1441
|
+
t.Fatal(err)
|
|
1442
|
+
}
|
|
1443
|
+
if got := MissingInputs(dir, plan, plan.Tasks[1]); len(got) != 0 {
|
|
1444
|
+
t.Fatalf("子目录里的交付物已存在就不该报缺:%v", got)
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
func TestMissingInputsKeepsInsideTaskDir(t *testing.T) {
|
|
1449
|
+
dir := t.TempDir()
|
|
1450
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1451
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"../../etc/passwd"}},
|
|
1452
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"passwd"}},
|
|
1453
|
+
}}
|
|
1454
|
+
specs := ResolveInputs(dir, plan, plan.Tasks[1])
|
|
1455
|
+
if len(specs) != 1 || specs[0].Path != filepath.Join(TaskDir(dir, "t1"), "passwd") {
|
|
1456
|
+
t.Fatalf("越界声明应收窄到任务目录内的同名文件,得到 %+v", specs)
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
func TestTaskDocExplainsInputs(t *testing.T) {
|
|
1461
|
+
dir := t.TempDir()
|
|
1462
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1463
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"brief.md"}},
|
|
1464
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"brief.md", "mood.png"}},
|
|
1465
|
+
}}
|
|
1466
|
+
doc := TaskDoc(dir, plan, plan.Tasks[1], nil)
|
|
1467
|
+
if !strings.Contains(doc, "## 输入") {
|
|
1468
|
+
t.Fatalf("任务书缺输入区:\n%s", doc)
|
|
1469
|
+
}
|
|
1470
|
+
if !strings.Contains(doc, "由 t1 产出,**必须读到**") {
|
|
1471
|
+
t.Fatalf("硬依赖要说清必须读到:\n%s", doc)
|
|
1472
|
+
}
|
|
1473
|
+
if !strings.Contains(doc, "没写明谁产出它") {
|
|
1474
|
+
t.Fatalf("无来源的输入应当诚实说明:\n%s", doc)
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
func TestParseHandoffTakesLastObject(t *testing.T) {
|
|
1479
|
+
text := "先看这段配置 {\"a\":1}\n\n做完了:\n```json\n" +
|
|
1480
|
+
`{"files":["brief.md"],"summary":"写了正文","interface":"brief.md#L1","open":["缺封面"]}` +
|
|
1481
|
+
"\n```\n"
|
|
1482
|
+
h := parseHandoff(text)
|
|
1483
|
+
if h == nil {
|
|
1484
|
+
t.Fatal("应当解析出交接单")
|
|
1485
|
+
}
|
|
1486
|
+
if len(h.Files) != 1 || h.Files[0] != "brief.md" || h.Summary != "写了正文" || len(h.Open) != 1 {
|
|
1487
|
+
t.Fatalf("交接单解析不对:%+v", h)
|
|
1488
|
+
}
|
|
1489
|
+
if parseHandoff("没有 JSON") != nil {
|
|
1490
|
+
t.Fatal("解析不出应当返回 nil")
|
|
1491
|
+
}
|
|
1492
|
+
if parseHandoff(`{"a":1}`) != nil {
|
|
1493
|
+
t.Fatal("全空字段的对象不算交接单")
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
func TestTaskDocPrefersHandoffOverBody(t *testing.T) {
|
|
1498
|
+
dir := t.TempDir()
|
|
1499
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1500
|
+
{ID: "t1", Kind: KindText, Title: "上游"},
|
|
1501
|
+
{ID: "t2", Kind: KindText, Title: "下游", Deps: []string{"t1"}, Prompt: "做事"},
|
|
1502
|
+
}}
|
|
1503
|
+
up := map[string]TaskResult{"t1": {
|
|
1504
|
+
ID: "t1", Title: "上游", State: TaskDone,
|
|
1505
|
+
Text: strings.Repeat("很长很长的正文", 1000),
|
|
1506
|
+
Handoff: &Handoff{Files: []string{"brief.md"}, Summary: "结论:用蓝色", Interface: "brief.md"},
|
|
1507
|
+
}}
|
|
1508
|
+
doc := TaskDoc(dir, plan, plan.Tasks[1], up)
|
|
1509
|
+
if !strings.Contains(doc, "结论:用蓝色") {
|
|
1510
|
+
t.Fatalf("应当渲染交接单:\n%s", doc)
|
|
1511
|
+
}
|
|
1512
|
+
if strings.Contains(doc, strings.Repeat("很长很长的正文", 100)) {
|
|
1513
|
+
t.Fatal("有交接单时不该再灌整段正文")
|
|
1514
|
+
}
|
|
1515
|
+
if !strings.Contains(doc, filepath.Join(TaskDir(dir, "t1"), "brief.md")) {
|
|
1516
|
+
t.Fatalf("交接单里的相对文件名要补成绝对路径:\n%s", doc)
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
// 缺交付物 → 重试一次;第二次交齐 → 完成,Attempts=2,提示词里要说明上次为什么没过。
|
|
1521
|
+
func TestRunRetriesOnceWhenDeliverableMissing(t *testing.T) {
|
|
1522
|
+
eng := &fakeEngine{
|
|
1523
|
+
name: "fake-retry",
|
|
1524
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","title":"写正文","prompt":"写 200 字","deliverables":["brief.md"]}]}`,
|
|
1525
|
+
task: "好的",
|
|
1526
|
+
writeFiles: []string{"brief.md"},
|
|
1527
|
+
writeFrom: 2, // 第一次不交,重试才交
|
|
1528
|
+
}
|
|
1529
|
+
install(t, eng)
|
|
1530
|
+
|
|
1531
|
+
opts := newOpts(t, eng, "")
|
|
1532
|
+
res, err := Run(context.Background(), opts)
|
|
1533
|
+
if err != nil {
|
|
1534
|
+
t.Fatal(err)
|
|
1535
|
+
}
|
|
1536
|
+
if len(res.Tasks) != 1 || res.Tasks[0].State != TaskDone {
|
|
1537
|
+
t.Fatalf("重试后应当完成,得到 %+v", res.Tasks)
|
|
1538
|
+
}
|
|
1539
|
+
if res.Tasks[0].Attempts != 2 {
|
|
1540
|
+
t.Fatalf("应当记录 2 次尝试,得到 %d", res.Tasks[0].Attempts)
|
|
1541
|
+
}
|
|
1542
|
+
var prompts []string
|
|
1543
|
+
for _, req := range eng.requests() {
|
|
1544
|
+
if len(req.Messages) > 0 && strings.Contains(req.Messages[0].Content, taskDocAnchor) {
|
|
1545
|
+
prompts = append(prompts, req.Messages[0].Content)
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1548
|
+
if len(prompts) != 2 {
|
|
1549
|
+
t.Fatalf("应当跑两次,得到 %d", len(prompts))
|
|
1550
|
+
}
|
|
1551
|
+
if strings.Contains(prompts[0], "上一次尝试未通过") {
|
|
1552
|
+
t.Fatal("首次尝试不该带重试说明")
|
|
1553
|
+
}
|
|
1554
|
+
if !strings.Contains(prompts[1], "上一次尝试未通过") || !strings.Contains(prompts[1], "缺少交付物") {
|
|
1555
|
+
t.Fatalf("重试提示词要说清上次为什么没过:\n%s", prompts[1])
|
|
1556
|
+
}
|
|
1557
|
+
// OUT 留档:尝试次数 + 交付物核对。
|
|
1558
|
+
out, err := os.ReadFile(OutputPath(res.RunDir, "t1"))
|
|
1559
|
+
if err != nil {
|
|
1560
|
+
t.Fatal(err)
|
|
1561
|
+
}
|
|
1562
|
+
if !strings.Contains(string(out), "尝试:2 次") || !strings.Contains(string(out), "✓ `brief.md`") {
|
|
1563
|
+
t.Fatalf("OUT 应记录重试与交付物:\n%s", out)
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
// 验收命令非零 → 判失败(重试后仍不过就是最终失败),并在 OUT 留证据。
|
|
1568
|
+
func TestRunFailsOnNonZeroCheck(t *testing.T) {
|
|
1569
|
+
eng := &fakeEngine{
|
|
1570
|
+
name: "fake-check",
|
|
1571
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","title":"写正文","prompt":"写 200 字",
|
|
1572
|
+
"check":["test -s brief.md"]}]}`,
|
|
1573
|
+
task: "我写完了(但没落文件)",
|
|
1574
|
+
}
|
|
1575
|
+
install(t, eng)
|
|
1576
|
+
|
|
1577
|
+
opts := newOpts(t, eng, "")
|
|
1578
|
+
res, err := Run(context.Background(), opts)
|
|
1579
|
+
if err != nil {
|
|
1580
|
+
t.Fatal(err)
|
|
1581
|
+
}
|
|
1582
|
+
task := res.Tasks[0]
|
|
1583
|
+
if task.State != TaskFailed {
|
|
1584
|
+
t.Fatalf("验收命令非零应当判失败,得到 %s", task.State)
|
|
1585
|
+
}
|
|
1586
|
+
if !strings.Contains(task.Reason, "验收命令未通过") {
|
|
1587
|
+
t.Fatalf("失败原因要指明验收命令:%s", task.Reason)
|
|
1588
|
+
}
|
|
1589
|
+
if task.Attempts != taskMaxAttempts {
|
|
1590
|
+
t.Fatalf("验收不过应当重试到上限,得到 %d", task.Attempts)
|
|
1591
|
+
}
|
|
1592
|
+
out, err := os.ReadFile(OutputPath(res.RunDir, "t1"))
|
|
1593
|
+
if err != nil {
|
|
1594
|
+
t.Fatal(err)
|
|
1595
|
+
}
|
|
1596
|
+
if !strings.Contains(string(out), "## 验收命令") || !strings.Contains(string(out), "✗ `test -s brief.md`") {
|
|
1597
|
+
t.Fatalf("OUT 应留下验收命令结果:\n%s", out)
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// 验收命令通过 → 正常完成(别把「能跑 check」实现成「有 check 就失败」)。
|
|
1602
|
+
func TestRunPassesWhenCheckSucceeds(t *testing.T) {
|
|
1603
|
+
eng := &fakeEngine{
|
|
1604
|
+
name: "fake-check-ok",
|
|
1605
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","title":"写正文","prompt":"写 200 字",
|
|
1606
|
+
"deliverables":["brief.md"],"check":["test -s brief.md"]}]}`,
|
|
1607
|
+
task: "写好了",
|
|
1608
|
+
writeFiles: []string{"brief.md"},
|
|
1609
|
+
}
|
|
1610
|
+
install(t, eng)
|
|
1611
|
+
|
|
1612
|
+
res, err := Run(context.Background(), newOpts(t, eng, ""))
|
|
1613
|
+
if err != nil {
|
|
1614
|
+
t.Fatal(err)
|
|
1615
|
+
}
|
|
1616
|
+
if res.Tasks[0].State != TaskDone || res.Tasks[0].Attempts != 1 {
|
|
1617
|
+
t.Fatalf("验收通过应当一次完成,得到 %+v", res.Tasks[0])
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
// inputs 指向 deps 的交付物但文件不在 → 开跑前直接失败,不白跑一次模型。
|
|
1622
|
+
func TestRunTaskFailsFastOnMissingInput(t *testing.T) {
|
|
1623
|
+
eng := &fakeEngine{name: "fake-input", task: "不该被调用"}
|
|
1624
|
+
install(t, eng)
|
|
1625
|
+
opts := newOpts(t, eng, "")
|
|
1626
|
+
runDir := filepath.Join(opts.BaseDir, "run-in")
|
|
1627
|
+
|
|
1628
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1629
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"brief.md"}},
|
|
1630
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"brief.md"}},
|
|
1631
|
+
}}
|
|
1632
|
+
if err := os.MkdirAll(TaskDir(runDir, "t2"), 0o755); err != nil {
|
|
1633
|
+
t.Fatal(err)
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
r := runTask(context.Background(), opts, plan, plan.Tasks[1], runDir, nil, newBoard(func(agent.StreamEvent) {}))
|
|
1637
|
+
if r.State != TaskFailed {
|
|
1638
|
+
t.Fatalf("缺上游输入应当失败,得到 %s", r.State)
|
|
1639
|
+
}
|
|
1640
|
+
if !strings.Contains(r.Reason, "缺少上游输入") || !strings.Contains(r.Reason, "t1") {
|
|
1641
|
+
t.Fatalf("原因要能归因:%s", r.Reason)
|
|
1642
|
+
}
|
|
1643
|
+
if r.Attempts != 0 {
|
|
1644
|
+
t.Fatalf("开跑前就失败,不该记尝试次数,得到 %d", r.Attempts)
|
|
1645
|
+
}
|
|
1646
|
+
if len(eng.requests()) != 0 {
|
|
1647
|
+
t.Fatalf("开跑前核对失败不该调模型,实际调了 %d 次", len(eng.requests()))
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// 交接单要单独落盘一份 JSON(下游 / 外部工具直接读),ARTS 里也带上尝试次数与验收结果。
|
|
1652
|
+
func TestWriteOutputPersistsHandoffAndChecks(t *testing.T) {
|
|
1653
|
+
dir := t.TempDir()
|
|
1654
|
+
r := TaskResult{
|
|
1655
|
+
ID: "t1", Title: "标题", State: TaskDone, Attempts: 2,
|
|
1656
|
+
Handoff: &Handoff{Files: []string{"brief.md"}, Summary: "写完了"},
|
|
1657
|
+
Checks: []CheckResult{{Cmd: "test -s brief.md", Code: 0}},
|
|
1658
|
+
}
|
|
1659
|
+
writeOutput(dir, r, []string{"brief.md"})
|
|
1660
|
+
|
|
1661
|
+
raw, err := os.ReadFile(HandoffPath(dir, "t1"))
|
|
1662
|
+
if err != nil {
|
|
1663
|
+
t.Fatalf("HANDOFF-t1.json 没写:%v", err)
|
|
1664
|
+
}
|
|
1665
|
+
var h Handoff
|
|
1666
|
+
if err := json.Unmarshal(raw, &h); err != nil || h.Summary != "写完了" {
|
|
1667
|
+
t.Fatalf("交接单 JSON 不对:%v %+v", err, h)
|
|
1668
|
+
}
|
|
1669
|
+
out, err := os.ReadFile(OutputPath(dir, "t1"))
|
|
1670
|
+
if err != nil {
|
|
1671
|
+
t.Fatal(err)
|
|
1672
|
+
}
|
|
1673
|
+
for _, want := range []string{"## 交接单", "## 验收命令", "尝试:2 次"} {
|
|
1674
|
+
if !strings.Contains(string(out), want) {
|
|
1675
|
+
t.Fatalf("OUT 缺 %q:\n%s", want, out)
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
var arts map[string]any
|
|
1679
|
+
artsRaw, _ := os.ReadFile(ArtsPath(dir, "t1"))
|
|
1680
|
+
if err := json.Unmarshal(artsRaw, &arts); err != nil {
|
|
1681
|
+
t.Fatal(err)
|
|
1682
|
+
}
|
|
1683
|
+
if arts["attempts"] != float64(2) || arts["checks"] == nil {
|
|
1684
|
+
t.Fatalf("ARTS 应带上 attempts/checks:%s", artsRaw)
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
// 计划阶段带结构化输出 schema;子任务不带(否则正文会被 JSON 替换掉)。
|
|
1689
|
+
func TestPlanRequestCarriesJSONSchema(t *testing.T) {
|
|
1690
|
+
eng := &fakeEngine{
|
|
1691
|
+
name: "fake-schema",
|
|
1692
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","title":"写","prompt":"写一段"}]}`,
|
|
1693
|
+
task: "写好了",
|
|
1694
|
+
}
|
|
1695
|
+
install(t, eng)
|
|
1696
|
+
if _, err := Run(context.Background(), newOpts(t, eng, "")); err != nil {
|
|
1697
|
+
t.Fatal(err)
|
|
1698
|
+
}
|
|
1699
|
+
reqs := eng.requests()
|
|
1700
|
+
if reqs[0].JSONSchema == nil {
|
|
1701
|
+
t.Fatal("计划请求应当带 JSONSchema")
|
|
1702
|
+
}
|
|
1703
|
+
if got := strings.Join(reqs[0].JSONSchema.Required, ","); got != "goal,tasks" {
|
|
1704
|
+
t.Fatalf("计划 schema 的必填字段应为 goal,tasks,得到 %s", got)
|
|
1705
|
+
}
|
|
1706
|
+
for _, req := range reqs[1:] {
|
|
1707
|
+
if req.JSONSchema != nil {
|
|
1708
|
+
t.Fatalf("子任务 / 汇总请求不该带 JSONSchema(会吞掉正文):%+v", req.JSONSchema)
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
|
|
1713
|
+
func TestHandoffRequiredOnlyForHardInputs(t *testing.T) {
|
|
1714
|
+
plan := Plan{Tasks: []Task{
|
|
1715
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"brief.md"}},
|
|
1716
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"brief.md"}},
|
|
1717
|
+
{ID: "t3", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"mood.png"}},
|
|
1718
|
+
}}
|
|
1719
|
+
if !handoffRequired(plan, "t1") {
|
|
1720
|
+
t.Fatal("交付物被下游当硬输入时应强制交接单")
|
|
1721
|
+
}
|
|
1722
|
+
if handoffRequired(plan, "t2") {
|
|
1723
|
+
t.Fatal("叶子任务不该强制交接单")
|
|
1724
|
+
}
|
|
1725
|
+
// 只写了 deps 没写 inputs(软提示)→ 不强制。
|
|
1726
|
+
soft := Plan{Tasks: []Task{
|
|
1727
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"brief.md"}},
|
|
1728
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}},
|
|
1729
|
+
}}
|
|
1730
|
+
if handoffRequired(soft, "t1") {
|
|
1731
|
+
t.Fatal("deps 没声明 inputs 时不该强制交接单")
|
|
1732
|
+
}
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
func TestHandoffProblemsChecksSchema(t *testing.T) {
|
|
1736
|
+
plan := Plan{Tasks: []Task{
|
|
1737
|
+
{ID: "t1", Kind: KindText, Prompt: "p", Deliverables: []string{"brief.md"}},
|
|
1738
|
+
{ID: "t2", Kind: KindText, Prompt: "p", Deps: []string{"t1"}, Inputs: []string{"brief.md"}},
|
|
1739
|
+
}}
|
|
1740
|
+
// 完全没交接单。
|
|
1741
|
+
if got := handoffProblems(plan, plan.Tasks[0], &TaskResult{}); len(got) == 0 {
|
|
1742
|
+
t.Fatal("缺交接单应当报问题")
|
|
1743
|
+
}
|
|
1744
|
+
// 缺 summary。
|
|
1745
|
+
got := handoffProblems(plan, plan.Tasks[0], &TaskResult{Handoff: &Handoff{Files: []string{"brief.md"}}})
|
|
1746
|
+
if len(got) == 0 || !strings.Contains(got[0], "summary") {
|
|
1747
|
+
t.Fatalf("缺 summary 应当报问题,得到 %v", got)
|
|
1748
|
+
}
|
|
1749
|
+
// 齐了。
|
|
1750
|
+
ok := &TaskResult{Handoff: &Handoff{Files: []string{"brief.md"}, Summary: "完成"}}
|
|
1751
|
+
if got := handoffProblems(plan, plan.Tasks[0], ok); len(got) != 0 {
|
|
1752
|
+
t.Fatalf("交接单齐全时不该报问题,得到 %v", got)
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
// 被下游当硬输入的产出者:交接单不全 → 复用一次重试;补齐后通过。
|
|
1757
|
+
func TestRunRetriesWhenHandoffIncomplete(t *testing.T) {
|
|
1758
|
+
eng := &fakeEngine{
|
|
1759
|
+
name: "fake-handoff",
|
|
1760
|
+
plan: `{"goal":"g","tasks":[
|
|
1761
|
+
{"id":"t1","kind":"text","title":"写正文","prompt":"写 200 字","deliverables":["brief.md"]},
|
|
1762
|
+
{"id":"t2","kind":"text","title":"写摘要","prompt":"写 50 字","deps":["t1"],"inputs":["brief.md"]}]}`,
|
|
1763
|
+
taskSeq: []string{
|
|
1764
|
+
"写好了", // 第一次正文里没有交接单
|
|
1765
|
+
"写好了\n```json\n{\"files\":[\"brief.md\"],\"summary\":\"正文已完成\"}\n```",
|
|
1766
|
+
},
|
|
1767
|
+
writeFiles: []string{"brief.md"},
|
|
1768
|
+
}
|
|
1769
|
+
install(t, eng)
|
|
1770
|
+
|
|
1771
|
+
res, err := Run(context.Background(), newOpts(t, eng, ""))
|
|
1772
|
+
if err != nil {
|
|
1773
|
+
t.Fatal(err)
|
|
1774
|
+
}
|
|
1775
|
+
t1 := res.Tasks[0]
|
|
1776
|
+
if t1.State != TaskDone || t1.Attempts != 2 {
|
|
1777
|
+
t.Fatalf("交接单不全应重试一次后通过,得到 state=%s attempts=%d reason=%s",
|
|
1778
|
+
t1.State, t1.Attempts, t1.Reason)
|
|
1779
|
+
}
|
|
1780
|
+
// 重试提示词里要写清上次为什么没过。
|
|
1781
|
+
reqs := eng.requests()
|
|
1782
|
+
var retry string
|
|
1783
|
+
for _, req := range reqs {
|
|
1784
|
+
if len(req.Messages) > 0 && strings.Contains(req.Messages[0].Content, "上一次尝试未通过") {
|
|
1785
|
+
retry = req.Messages[0].Content
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
if !strings.Contains(retry, "交接单") {
|
|
1789
|
+
t.Fatalf("重试提示词应说明交接单问题,得到 %q", retry)
|
|
1790
|
+
}
|
|
1791
|
+
if res.Tasks[1].State != TaskDone {
|
|
1792
|
+
t.Fatalf("下游任务应正常完成,得到 %+v", res.Tasks[1])
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
// 再规划的信号只认两类:必需任务失败、已完成任务的交接单 open 项。
|
|
1797
|
+
func TestAdaptiveSignals(t *testing.T) {
|
|
1798
|
+
optional := false
|
|
1799
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1800
|
+
{ID: "t1", Kind: KindText, Title: "写正文", Prompt: "p"},
|
|
1801
|
+
{ID: "t2", Kind: KindText, Title: "配图", Prompt: "p", Required: &optional},
|
|
1802
|
+
{ID: "t3", Kind: KindText, Title: "改稿", Prompt: "p"},
|
|
1803
|
+
{ID: "t4", Kind: KindText, Title: "出稿", Prompt: "p"},
|
|
1804
|
+
{ID: "t5", Kind: KindText, Title: "归档", Prompt: "p"},
|
|
1805
|
+
}}
|
|
1806
|
+
results := []TaskResult{
|
|
1807
|
+
{ID: "t1", State: TaskFailed, Reason: "缺交付物"},
|
|
1808
|
+
{ID: "t2", State: TaskFailed, Reason: "可选任务失败"}, // required=false:不补
|
|
1809
|
+
{ID: "t3", State: TaskSkipped, Reason: "上游失败"}, // 级联结果:不补
|
|
1810
|
+
{ID: "t4", State: TaskDone, Handoff: &Handoff{Open: []string{
|
|
1811
|
+
"封面还没配", "标题待定", "字数不足", "第四条不该进提示词",
|
|
1812
|
+
}}},
|
|
1813
|
+
{ID: "t5", State: TaskDone}, // 没有交接单:无信号
|
|
1814
|
+
}
|
|
1815
|
+
got := adaptiveSignals(plan, results)
|
|
1816
|
+
if len(got) != 2 {
|
|
1817
|
+
t.Fatalf("应当只有 2 条信号(失败 t1 + open t4),得到 %d 条:%v", len(got), got)
|
|
1818
|
+
}
|
|
1819
|
+
if !strings.Contains(got[0], "t1") || !strings.Contains(got[0], "缺交付物") {
|
|
1820
|
+
t.Fatalf("失败信号应带上任务与原因,得到 %q", got[0])
|
|
1821
|
+
}
|
|
1822
|
+
if !strings.Contains(got[1], "封面还没配") || strings.Contains(got[1], "第四条") {
|
|
1823
|
+
t.Fatalf("open 项应最多取 %d 条,得到 %q", adaptiveOpenPerTask, got[1])
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// 补充任务的 id 重编号、deps 收窄、无效项过滤。
|
|
1828
|
+
func TestNormalizeAdaptiveTasks(t *testing.T) {
|
|
1829
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1830
|
+
{ID: "t1", Kind: KindText, Prompt: "p"},
|
|
1831
|
+
{ID: "a1", Kind: KindText, Prompt: "p"}, // 已占用 → 不能覆盖
|
|
1832
|
+
}}
|
|
1833
|
+
got, dropped := normalizeAdaptiveTasks([]Task{
|
|
1834
|
+
{ID: "x1", Role: "视觉", Prompt: "补封面", Deps: []string{"t1", "x2"}},
|
|
1835
|
+
{ID: "x0", Prompt: " "}, // 空 prompt:丢弃
|
|
1836
|
+
{ID: "x2", Prompt: "补文案", Deps: []string{"x1"}},
|
|
1837
|
+
{ID: "x3", Prompt: "补一"},
|
|
1838
|
+
{ID: "x4", Prompt: "补二"}, // 超过 adaptiveMaxTasks:丢弃
|
|
1839
|
+
}, plan)
|
|
1840
|
+
if dropped != 0 {
|
|
1841
|
+
t.Fatalf("没有同名补充任务时应返回 0,得到 %d", dropped)
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
if len(got) != adaptiveMaxTasks {
|
|
1845
|
+
t.Fatalf("应当保留 %d 个补充任务,得到 %d 个:%+v", adaptiveMaxTasks, len(got), got)
|
|
1846
|
+
}
|
|
1847
|
+
// a1 已被原计划占用 → 从 a2 开始编号。
|
|
1848
|
+
wantIDs := []string{"a2", "a3", "a4"}
|
|
1849
|
+
for i, want := range wantIDs {
|
|
1850
|
+
if got[i].ID != want {
|
|
1851
|
+
t.Fatalf("第 %d 个补充任务 id 应为 %s,得到 %s", i, want, got[i].ID)
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
// 指向原任务的 dep 被丢弃;指向本批的 dep 重映射。
|
|
1855
|
+
if len(got[0].Deps) != 1 || got[0].Deps[0] != "a3" {
|
|
1856
|
+
t.Fatalf("a2 的 deps 应当只剩重映射后的 a3,得到 %v", got[0].Deps)
|
|
1857
|
+
}
|
|
1858
|
+
if len(got[1].Deps) != 1 || got[1].Deps[0] != "a2" {
|
|
1859
|
+
t.Fatalf("a3 的 deps 应当只剩重映射后的 a2,得到 %v", got[1].Deps)
|
|
1860
|
+
}
|
|
1861
|
+
if len(got[2].Deps) != 0 {
|
|
1862
|
+
t.Fatalf("a4 没有依赖,得到 %v", got[2].Deps)
|
|
1863
|
+
}
|
|
1864
|
+
// 归一化同样生效(角色 → 类别)。
|
|
1865
|
+
if got[0].Role != "designer" || got[0].Kind != KindImage {
|
|
1866
|
+
t.Fatalf("补充任务也应过 normalizeTask,得到 role=%q kind=%q", got[0].Role, got[0].Kind)
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
|
|
1870
|
+
// 与原任务同名的补充任务等同重试,应当丢弃:否则任务卡上会多出一行同名任务
|
|
1871
|
+
// (2026-10-22 实测 t1/a1、t2/a2、t3/a3 各自并列两行)。
|
|
1872
|
+
func TestNormalizeAdaptiveTasksDropsSameTitle(t *testing.T) {
|
|
1873
|
+
plan := Plan{Goal: "g", Tasks: []Task{
|
|
1874
|
+
{ID: "t1", Kind: KindText, Title: "实现fizzbuzz", Prompt: "p"},
|
|
1875
|
+
{ID: "t2", Kind: KindText, Title: "编写测试", Prompt: "p"},
|
|
1876
|
+
}}
|
|
1877
|
+
got, dropped := normalizeAdaptiveTasks([]Task{
|
|
1878
|
+
{ID: "x1", Title: " 实现FizzBuzz ", Prompt: "再做一遍"},
|
|
1879
|
+
{ID: "x2", Title: "补测试断言", Prompt: "补断言"},
|
|
1880
|
+
{ID: "x3", Title: "补测试断言", Prompt: "同名再提一次"},
|
|
1881
|
+
}, plan)
|
|
1882
|
+
if dropped != 2 {
|
|
1883
|
+
t.Fatalf("应当丢弃 2 个同名补充任务,得到 %d", dropped)
|
|
1884
|
+
}
|
|
1885
|
+
if len(got) != 1 || got[0].Title != "补测试断言" || got[0].ID != "a1" {
|
|
1886
|
+
t.Fatalf("只该保留不同名的那一个,得到 %+v", got)
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// 端到端:必需任务失败 → 再规划一轮 → 追加 a1 并执行 → 汇总包含补充任务。
|
|
1891
|
+
func TestRunAdaptiveAppendsReplanTasks(t *testing.T) {
|
|
1892
|
+
eng := &fakeEngine{
|
|
1893
|
+
name: "fake-adaptive",
|
|
1894
|
+
plan: `{"goal":"g","tasks":[
|
|
1895
|
+
{"id":"t1","kind":"text","title":"写正文","prompt":"写正文","check":["test -f never.txt"]}]}`,
|
|
1896
|
+
task: "做完了",
|
|
1897
|
+
replan: `{"goal":"g","tasks":[
|
|
1898
|
+
{"id":"x1","kind":"text","title":"补正文","prompt":"重新写一版正文","deps":["t1"]}]}`,
|
|
1899
|
+
}
|
|
1900
|
+
install(t, eng)
|
|
1901
|
+
|
|
1902
|
+
opts := newOpts(t, eng, "")
|
|
1903
|
+
opts.Adaptive = true
|
|
1904
|
+
res, err := Run(context.Background(), opts)
|
|
1905
|
+
if err != nil {
|
|
1906
|
+
t.Fatal(err)
|
|
1907
|
+
}
|
|
1908
|
+
if len(res.Tasks) != 2 {
|
|
1909
|
+
t.Fatalf("应当追加 1 个补充任务,得到 %d 个:%+v", len(res.Tasks), res.Tasks)
|
|
1910
|
+
}
|
|
1911
|
+
if res.Tasks[0].State != TaskFailed {
|
|
1912
|
+
t.Fatalf("t1 的 check 应当失败,得到 %+v", res.Tasks[0])
|
|
1913
|
+
}
|
|
1914
|
+
if res.Tasks[1].ID != "a1" || res.Tasks[1].State != TaskDone {
|
|
1915
|
+
t.Fatalf("补充任务应为 a1 且完成,得到 %+v", res.Tasks[1])
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
var replanReq, extraReq *agent.Request
|
|
1919
|
+
reqs := eng.requests()
|
|
1920
|
+
for i := range reqs {
|
|
1921
|
+
req := &reqs[i]
|
|
1922
|
+
if req.Workspace == "" && strings.Contains(req.SystemPrompt, adaptiveMarker) {
|
|
1923
|
+
replanReq = req
|
|
1924
|
+
}
|
|
1925
|
+
if req.Workspace != "" && len(req.Messages) > 0 &&
|
|
1926
|
+
strings.Contains(req.Messages[0].Content, "重新写一版正文") {
|
|
1927
|
+
extraReq = req
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1930
|
+
if replanReq == nil {
|
|
1931
|
+
t.Fatal("没有发出再规划请求")
|
|
1932
|
+
}
|
|
1933
|
+
if replanReq.JSONSchema == nil {
|
|
1934
|
+
t.Fatal("再规划请求应当带计划 schema")
|
|
1935
|
+
}
|
|
1936
|
+
if extraReq == nil {
|
|
1937
|
+
t.Fatal("补充任务没有真正执行")
|
|
1938
|
+
}
|
|
1939
|
+
// 原任务已跑完,指向它的 dep 必须先被丢掉,否则整批校验会失败。
|
|
1940
|
+
if extraReq.Messages[0].Content == "" {
|
|
1941
|
+
t.Fatal("补充任务提示词为空")
|
|
1942
|
+
}
|
|
1943
|
+
planDoc, err := os.ReadFile(PlanPath(res.RunDir))
|
|
1944
|
+
if err != nil {
|
|
1945
|
+
t.Fatal(err)
|
|
1946
|
+
}
|
|
1947
|
+
if !strings.Contains(string(planDoc), "a1") {
|
|
1948
|
+
t.Fatalf("PLAN.md 应重写成含补充任务的完整集合:\n%s", planDoc)
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
|
|
1952
|
+
// 没有信号时不该多调一次模型(成功轮次不能被再规划打扰)。
|
|
1953
|
+
func TestRunAdaptiveSkipsWhenNoSignal(t *testing.T) {
|
|
1954
|
+
eng := &fakeEngine{
|
|
1955
|
+
name: "fake-adaptive-clean",
|
|
1956
|
+
plan: `{"goal":"g","tasks":[{"id":"t1","kind":"text","title":"写","prompt":"写"}]}`,
|
|
1957
|
+
task: "写好了",
|
|
1958
|
+
}
|
|
1959
|
+
install(t, eng)
|
|
1960
|
+
opts := newOpts(t, eng, "")
|
|
1961
|
+
opts.Adaptive = true
|
|
1962
|
+
if _, err := Run(context.Background(), opts); err != nil {
|
|
1963
|
+
t.Fatal(err)
|
|
1964
|
+
}
|
|
1965
|
+
for _, req := range eng.requests() {
|
|
1966
|
+
if strings.Contains(req.SystemPrompt, adaptiveMarker) {
|
|
1967
|
+
t.Fatal("没有未解决项时不该发再规划请求")
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
if len(eng.requests()) != 3 {
|
|
1971
|
+
t.Fatalf("一次计划 + 一次子任务 + 一次汇总 = 3 次调用,得到 %d 次", len(eng.requests()))
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
|
|
1975
|
+
// 再规划输出不可解析时:记一条 notice 收尾,不把整轮变成错误。
|
|
1976
|
+
func TestRunAdaptiveIgnoresBadReplan(t *testing.T) {
|
|
1977
|
+
eng := &fakeEngine{
|
|
1978
|
+
name: "fake-adaptive-bad",
|
|
1979
|
+
plan: `{"goal":"g","tasks":[
|
|
1980
|
+
{"id":"t1","kind":"text","title":"写","prompt":"写","check":["test -f never.txt"]}]}`,
|
|
1981
|
+
task: "做完了",
|
|
1982
|
+
replan: "我觉得不用补了", // 没有 JSON 对象
|
|
1983
|
+
}
|
|
1984
|
+
install(t, eng)
|
|
1985
|
+
opts := newOpts(t, eng, "")
|
|
1986
|
+
opts.Adaptive = true
|
|
1987
|
+
res, err := Run(context.Background(), opts)
|
|
1988
|
+
if err != nil {
|
|
1989
|
+
t.Fatalf("再规划失败不该让整轮报错:%v", err)
|
|
1990
|
+
}
|
|
1991
|
+
if len(res.Tasks) != 1 {
|
|
1992
|
+
t.Fatalf("再规划失败时应保持原结果,得到 %+v", res.Tasks)
|
|
1993
|
+
}
|
|
1994
|
+
if res.Text == "" {
|
|
1995
|
+
t.Fatal("仍然应当汇总出正文")
|
|
1996
|
+
}
|
|
1997
|
+
}
|