返回 DeepSeek-Reasonix
parallel_tasks_test.go
根目录 / internal / agent / parallel_tasks_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "strconv"
9 "strings"
10 "sync/atomic"
11 "testing"
12 "time"
13
14 "reasonix/internal/event"
15 "reasonix/internal/provider"
16 "reasonix/internal/tool"
17 "reasonix/internal/workspacelease"
18 )
19
20 func TestParallelTasksToolIsReadOnly(t *testing.T) {
21 p := &ParallelTasksTool{}
22 if !p.ReadOnly() {
23 t.Fatal("parallel_tasks must be read-only because spawned sub-agents receive only read-only tools")
24 }
25 if !p.PlanModeSafe() {
26 t.Fatal("parallel_tasks must explicitly allow the planning phase")
27 }
28 }
29
30 func TestParallelTasksSchemaKeepsDependencyOrderingHidden(t *testing.T) {
31 schema := string((&ParallelTasksTool{}).Schema())
32 if strings.Contains(schema, "depends_on") {
33 t.Fatal("parallel_tasks schema should not expose depends_on by default; changing tool schema hurts prompt-cache stability")
34 }
35 }
36
37 func TestParallelTasksValidatesAllTasksBeforeRuntimeLookup(t *testing.T) {
38 tool := &ParallelTasksTool{}
39 _, err := tool.Execute(context.Background(), json.RawMessage(`{
40 "tasks": [
41 {"prompt": "inspect the parser"},
42 {"prompt": " "}
43 ]
44 }`))
45 if err == nil {
46 t.Fatal("Execute returned nil error for an empty later task")
47 }
48 if !strings.Contains(err.Error(), "task 2: prompt is required") {
49 t.Fatalf("Execute error = %v, want task validation before runtime lookup", err)
50 }
51 if strings.Contains(err.Error(), "background jobs are not available") {
52 t.Fatalf("Execute looked up background jobs before validating all tasks: %v", err)
53 }
54 }
55
56 func TestParallelTasksRejectsHiddenDependencyFieldBeforeRuntimeLookup(t *testing.T) {
57 tool := &ParallelTasksTool{}
58 _, err := tool.Execute(context.Background(), json.RawMessage(`{
59 "tasks": [
60 {"prompt": "first", "depends_on": [1]},
61 {"prompt": "second"}
62 ]
63 }`))
64 if err == nil {
65 t.Fatal("Execute returned nil error for a hidden dependency field")
66 }
67 if !strings.Contains(err.Error(), "depends_on") {
68 t.Fatalf("Execute error = %v, want hidden dependency field rejection", err)
69 }
70 if strings.Contains(err.Error(), "background jobs are not available") {
71 t.Fatalf("Execute looked up background jobs before rejecting hidden dependencies: %v", err)
72 }
73 }
74
75 func TestParallelTasksRejectsUnboundedBatchBeforeRuntimeLookup(t *testing.T) {
76 tasks := make([]parallelTaskItem, parallelTasksMaxTasks+1)
77 for i := range tasks {
78 tasks[i].Prompt = "inspect"
79 }
80 args, err := json.Marshal(map[string]any{"tasks": tasks})
81 if err != nil {
82 t.Fatalf("Marshal: %v", err)
83 }
84
85 _, err = (&ParallelTasksTool{}).Execute(context.Background(), args)
86 if err == nil {
87 t.Fatal("Execute returned nil error for an oversized batch")
88 }
89 if !strings.Contains(err.Error(), "at most 64 tasks") {
90 t.Fatalf("Execute error = %v, want bounded-task rejection", err)
91 }
92 if strings.Contains(err.Error(), "not configured") {
93 t.Fatalf("Execute looked up runtime before enforcing the batch cap: %v", err)
94 }
95 }
96
97 func TestParallelTasksForegroundCompletesAndClosesWorkers(t *testing.T) {
98 task := newTestTaskTool(t, parallelStaticProvider{}, tool.NewRegistry(), "sys", "", "", nil)
99 parallel := NewParallelTasksTool(task, tool.NewRegistry())
100 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
101
102 done := make(chan error, 1)
103 go func() {
104 out, err := parallel.Execute(ctx, json.RawMessage(`{
105 "tasks": [
106 {"prompt": "first"},
107 {"prompt": "second"}
108 ]
109 }`))
110 if err != nil {
111 done <- err
112 return
113 }
114 if !strings.Contains(out, "Completed 2 parallel tasks") {
115 done <- stringsError("missing aggregate output: " + out)
116 return
117 }
118 done <- nil
119 }()
120
121 select {
122 case err := <-done:
123 if err != nil {
124 t.Fatal(err)
125 }
126 case <-time.After(2 * time.Second):
127 t.Fatal("parallel_tasks foreground execution did not return; workers likely waited on spawnCh forever")
128 }
129 }
130
131 func TestParallelTasksLongResultsStayIndependentlyRetrievable(t *testing.T) {
132 workspace := t.TempDir()
133 store := NewSubagentStore(t.TempDir())
134 task := NewTaskTool(parallelLongResultProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
135 WithTranscripts(store, workspace, "base-model", "base-effort")
136 parallel := NewParallelTasksTool(task, tool.NewRegistry())
137 ctx := WithParentSession(withCallContext(context.Background(), "parallel-call", event.Discard, nil, false), "parent-session")
138
139 out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"first report"},{"prompt":"second report"}]}`))
140 if err != nil {
141 t.Fatalf("Execute: %v", err)
142 }
143 if len(out) > subagentAggregateBudgetBytes {
144 t.Fatalf("aggregate bytes = %d, want <= %d", len(out), subagentAggregateBudgetBytes)
145 }
146 if _, notice := truncateToolOutput(out); notice != "" {
147 t.Fatalf("bounded aggregate still hit the generic truncator: %s", notice)
148 }
149 if strings.Count(out, "Subagent reference: sa_") != 2 {
150 t.Fatalf("aggregate did not preserve every child ref:\n%s", out)
151 }
152 if !strings.Contains(out, "preview truncated; read the full result") {
153 t.Fatalf("aggregate did not explain lossless retrieval:\n%s", out)
154 }
155
156 refs := subagentRefsFromText(out)
157 if len(refs) != 2 {
158 t.Fatalf("refs = %v, want 2", refs)
159 }
160 reader := NewSubagentResultTool(task)
161 for i, ref := range refs {
162 page, readErr := reader.Execute(ctx, json.RawMessage(fmt.Sprintf(`{"ref":%q,"limit_bytes":24576}`, ref)))
163 if readErr != nil {
164 t.Fatalf("read result %d: %v", i+1, readErr)
165 }
166 wantBegin, wantEnd := "FIRST-BEGIN", "FIRST-END"
167 if i == 1 {
168 wantBegin, wantEnd = "SECOND-BEGIN", "SECOND-END"
169 }
170 if !strings.Contains(page, wantBegin) || !strings.Contains(page, wantEnd) || !strings.Contains(page, "End of subagent result") {
171 t.Fatalf("read result %d was not complete: head=%q tail=%q", i+1, page[:minInt(120, len(page))], page[max(0, len(page)-120):])
172 }
173 }
174
175 otherCtx := WithParentSession(context.Background(), "other-session")
176 if _, err := reader.Execute(otherCtx, json.RawMessage(fmt.Sprintf(`{"ref":%q}`, refs[0]))); err == nil {
177 t.Fatal("reader accepted a result from an unrelated parent session")
178 }
179 }
180
181 func TestParallelTasksInjectsWorkspaceContextIntoChildren(t *testing.T) {
182 workspace := t.TempDir()
183 task := NewTaskTool(promptRoutingProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
184 WithTranscripts(NewSubagentStore(t.TempDir()), workspace, "base-model", "base-effort")
185 parallel := NewParallelTasksTool(task, tool.NewRegistry())
186 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
187
188 out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"inspect one"},{"prompt":"inspect two"}]}`))
189 if err != nil {
190 t.Fatalf("Execute: %v", err)
191 }
192 if !strings.Contains(out, "Current workspace: "+strconv.Quote(workspace)) ||
193 !strings.Contains(out, `prefer "." or relative paths`) ||
194 !strings.Contains(out, "inspect one ok") ||
195 !strings.Contains(out, "inspect two ok") {
196 t.Fatalf("parallel output = %q, want child workspace context and prompt", out)
197 }
198 }
199
200 // TestParallelTasksDeliveryClassifiesPristinePrompt pins the trusted
201 // classifier channel on the parallel_tasks path: delivery intent must be
202 // judged from the child's pristine prompt, not the workspace-wrapped text.
203 // The wrapper is long enough that the IsTask length fallback classifies it as
204 // a task; without ClassifierTaskText a plain conversational child ("Who are
205 // you?") would be required to produce work receipts it has no reason to earn
206 // and would exhaust final-answer readiness instead of answering.
207 func TestParallelTasksDeliveryClassifiesPristinePrompt(t *testing.T) {
208 workspace := t.TempDir()
209 task := NewTaskTool(promptRoutingProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
210 WithTranscripts(NewSubagentStore(t.TempDir()), workspace, "base-model", "base-effort").
211 WithDeliveryProfile(true)
212 parallel := NewParallelTasksTool(task, tool.NewRegistry())
213 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
214
215 out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"Who are you?"},{"prompt":"Nice to meet you"}]}`))
216 if err != nil {
217 t.Fatalf("Execute: %v", err)
218 }
219 if strings.Contains(out, "readiness") {
220 t.Fatalf("delivery readiness leaked into a conversational parallel child: %q", out)
221 }
222 // The echo provider replays the child's full user turn; both children must
223 // have answered (their prompts echo back with the trailing " ok").
224 if !strings.Contains(out, "Who are you?") || !strings.Contains(out, "Nice to meet you") || strings.Count(out, " ok") < 2 {
225 t.Fatalf("parallel output = %q, want both children's answers", out)
226 }
227 }
228
229 // TestParallelTasksInheritLanguagePreferencesFromContext pins parallel children
230 // to the same transient language injection the task tool applies: both the
231 // response- and reasoning-language blocks must reach each child's user turn.
232 func TestParallelTasksInheritLanguagePreferencesFromContext(t *testing.T) {
233 task := newTestTaskTool(t, promptRoutingProvider{}, tool.NewRegistry(), "sys", "", "", nil)
234 parallel := NewParallelTasksTool(task, tool.NewRegistry())
235 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
236 ctx = WithResponseLanguagePreference(ctx, "zh")
237 ctx = WithReasoningLanguagePreference(ctx, "zh")
238
239 out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"inspect one"},{"prompt":"inspect two"}]}`))
240 if err != nil {
241 t.Fatalf("Execute: %v", err)
242 }
243 if !strings.Contains(out, "<response-language>") || !strings.Contains(out, "<reasoning-language>") {
244 t.Fatalf("parallel output = %q, want response/reasoning language blocks injected into child prompts", out)
245 }
246 }
247
248 func TestParallelTasksDoesNotExposeWriterToolsToChildren(t *testing.T) {
249 var writerCalls int32
250 parentReg := tool.NewRegistry()
251 parentReg.Add(fakeTool{name: "write_file", readOnly: false, calls: &writerCalls})
252 task := newTestTaskTool(t, writerCallingProvider{}, parentReg, "sys", "", "", nil)
253 parallel := NewParallelTasksTool(task, parentReg)
254 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
255
256 out, err := parallel.Execute(ctx, json.RawMessage(`{
257 "tasks": [
258 {"prompt": "try writer one"},
259 {"prompt": "try writer two"}
260 ]
261 }`))
262 if err != nil {
263 t.Fatalf("Execute returned error: %v\n%s", err, out)
264 }
265 if got := atomic.LoadInt32(&writerCalls); got != 0 {
266 t.Fatalf("writer tool was exposed to read-only sub-agents and called %d times", got)
267 }
268 if !strings.Contains(out, "Completed 2 parallel tasks") {
269 t.Fatalf("missing aggregate output: %s", out)
270 }
271 }
272
273 func TestParallelTasksBlocksWriterResolvedThroughReadOnlyProxy(t *testing.T) {
274 var writerCalls int32
275 parentReg := tool.NewRegistry()
276 target := parallelResolvedWriterTarget{calls: &writerCalls}
277 parentReg.Add(readOnlyBoundaryProxy{resolved: tool.ResolvedCall{
278 ProxyAction: "call",
279 TargetName: target.Name(),
280 Target: target,
281 ReadOnly: false,
282 Args: json.RawMessage(`{}`),
283 }})
284 task := newTestTaskTool(t, proxyWriterCallingProvider{}, parentReg, "sys", "", "", nil)
285 parallel := NewParallelTasksTool(task, parentReg)
286 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
287
288 out, err := parallel.Execute(ctx, json.RawMessage(`{
289 "tasks": [
290 {"prompt": "resolve writer one"},
291 {"prompt": "resolve writer two"}
292 ]
293 }`))
294 if err != nil {
295 t.Fatalf("Execute returned error: %v\n%s", err, out)
296 }
297 if got := atomic.LoadInt32(&writerCalls); got != 0 {
298 t.Fatalf("use_capability resolved writer executed %d times, want 0", got)
299 }
300 if !strings.Contains(out, "Completed 2 parallel tasks") {
301 t.Fatalf("missing aggregate output: %s", out)
302 }
303 }
304
305 func TestParallelTasksCancelReturnsPartialAggregate(t *testing.T) {
306 task := newTestTaskTool(t, promptRoutingProvider{}, tool.NewRegistry(), "sys", "", "", nil)
307 parallel := NewParallelTasksTool(task, tool.NewRegistry())
308
309 ctx, cancel := context.WithCancel(withCallContext(context.Background(), "parallel-call", event.Discard, nil, false))
310 defer cancel()
311 done := make(chan struct {
312 out string
313 err error
314 }, 1)
315 go func() {
316 out, err := parallel.Execute(ctx, json.RawMessage(`{
317 "tasks": [
318 {"prompt": "done child"},
319 {"prompt": "stuck child"}
320 ]
321 }`))
322 done <- struct {
323 out string
324 err error
325 }{out: out, err: err}
326 }()
327
328 time.Sleep(100 * time.Millisecond)
329 cancel()
330
331 select {
332 case got := <-done:
333 if !errors.Is(got.err, context.Canceled) {
334 t.Fatalf("Execute error = %v, want context cancellation", got.err)
335 }
336 if strings.Contains(got.out, "Completed 2 parallel tasks") {
337 t.Fatalf("cancelled aggregate reported full completion:\n%s", got.out)
338 }
339 if !strings.Contains(got.out, "done child ok") {
340 t.Fatalf("cancelled aggregate lost completed child output:\n%s", got.out)
341 }
342 if !strings.Contains(strings.ToLower(got.out), "cancelled") {
343 t.Fatalf("cancelled aggregate did not mark unfinished child:\n%s", got.out)
344 }
345 case <-time.After(500 * time.Millisecond):
346 t.Fatal("parallel_tasks did not return promptly after cancellation")
347 }
348 }
349
350 type parallelStaticProvider struct{}
351
352 func (parallelStaticProvider) Name() string { return "parallel-static" }
353
354 func (parallelStaticProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
355 ch := make(chan provider.Chunk, 2)
356 ch <- provider.Chunk{Type: provider.ChunkText, Text: "ok"}
357 ch <- provider.Chunk{Type: provider.ChunkDone}
358 close(ch)
359 return ch, nil
360 }
361
362 type parallelLongResultProvider struct{}
363
364 func (parallelLongResultProvider) Name() string { return "parallel-long-result" }
365
366 func (parallelLongResultProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
367 begin, end, fill := "FIRST-BEGIN", "FIRST-END", "a"
368 if strings.Contains(lastUser(req), "second report") {
369 begin, end, fill = "SECOND-BEGIN", "SECOND-END", "b"
370 }
371 ch := make(chan provider.Chunk, 2)
372 ch <- provider.Chunk{Type: provider.ChunkText, Text: begin + "\n" + strings.Repeat(fill, 20*1024) + "\n" + end}
373 ch <- provider.Chunk{Type: provider.ChunkDone}
374 close(ch)
375 return ch, nil
376 }
377
378 func subagentRefsFromText(text string) []string {
379 var refs []string
380 for _, line := range strings.Split(text, "\n") {
381 if strings.HasPrefix(line, "Subagent reference: ") {
382 refs = append(refs, strings.TrimSpace(strings.TrimPrefix(line, "Subagent reference: ")))
383 }
384 }
385 return refs
386 }
387
388 type promptRoutingProvider struct{}
389
390 func (promptRoutingProvider) Name() string { return "prompt-routing" }
391
392 func (promptRoutingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
393 if strings.Contains(lastUser(req), "stuck") {
394 return make(chan provider.Chunk), nil
395 }
396 ch := make(chan provider.Chunk, 2)
397 ch <- provider.Chunk{Type: provider.ChunkText, Text: lastUser(req) + " ok"}
398 ch <- provider.Chunk{Type: provider.ChunkDone}
399 close(ch)
400 return ch, nil
401 }
402
403 type writerCallingProvider struct{}
404
405 func (writerCallingProvider) Name() string { return "writer-calling" }
406
407 func (writerCallingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
408 ch := make(chan provider.Chunk, 2)
409 if !hasToolResult(req, "write_file") {
410 ch <- toolCallChunk("write-1", "write_file", `{"path":"x","content":"y"}`)
411 ch <- provider.Chunk{Type: provider.ChunkDone}
412 close(ch)
413 return ch, nil
414 }
415 ch <- provider.Chunk{Type: provider.ChunkText, Text: "writer unavailable"}
416 ch <- provider.Chunk{Type: provider.ChunkDone}
417 close(ch)
418 return ch, nil
419 }
420
421 type proxyWriterCallingProvider struct{}
422
423 func (proxyWriterCallingProvider) Name() string { return "proxy-writer-calling" }
424
425 func (proxyWriterCallingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
426 ch := make(chan provider.Chunk, 2)
427 if !hasToolResult(req, "use_capability") {
428 ch <- toolCallChunk("proxy-write-1", "use_capability", `{"action":"call","capability_id":"mcp-tool:test/write","arguments":{}}`)
429 ch <- provider.Chunk{Type: provider.ChunkDone}
430 close(ch)
431 return ch, nil
432 }
433 ch <- provider.Chunk{Type: provider.ChunkText, Text: "writer blocked"}
434 ch <- provider.Chunk{Type: provider.ChunkDone}
435 close(ch)
436 return ch, nil
437 }
438
439 type parallelResolvedWriterTarget struct {
440 calls *int32
441 }
442
443 func (parallelResolvedWriterTarget) Name() string { return "mcp__test__write" }
444 func (parallelResolvedWriterTarget) Description() string { return "" }
445 func (parallelResolvedWriterTarget) Schema() json.RawMessage {
446 return json.RawMessage(`{"type":"object"}`)
447 }
448 func (parallelResolvedWriterTarget) ReadOnly() bool { return false }
449 func (t parallelResolvedWriterTarget) Execute(context.Context, json.RawMessage) (string, error) {
450 atomic.AddInt32(t.calls, 1)
451 return "writer executed", nil
452 }
453
454 func hasToolResult(req provider.Request, name string) bool {
455 for _, m := range req.Messages {
456 if m.Role == provider.RoleTool && m.Name == name {
457 return true
458 }
459 }
460 return false
461 }
462
463 type stringsError string
464
465 func (e stringsError) Error() string { return string(e) }
466
467 // TestChildMaxStepsSharedDefault pins the single step-budget rule shared by
468 // task, read_only_task, and parallel_tasks children: explicit request wins,
469 // a finite parent yields half its budget (min 5), an unbounded parent yields
470 // an unbounded child. parallel_tasks used to hardcode 20 instead.
471 func TestChildMaxStepsSharedDefault(t *testing.T) {
472 cases := []struct {
473 name string
474 parent int
475 requested int
476 want int
477 }{
478 {"explicit request wins", 30, 7, 7},
479 {"finite parent halves", 30, 0, 15},
480 {"half is floored at 5", 8, 0, 5},
481 {"unbounded parent stays unbounded", 0, 0, 0},
482 }
483 for _, tc := range cases {
484 t.Run(tc.name, func(t *testing.T) {
485 task := &TaskTool{maxSteps: tc.parent}
486 if got := task.childMaxSteps(tc.requested); got != tc.want {
487 t.Fatalf("childMaxSteps(parent=%d, requested=%d) = %d, want %d", tc.parent, tc.requested, got, tc.want)
488 }
489 })
490 }
491 }
492
493 func TestTaskToolPropagatesDeliveryProfileToSubagents(t *testing.T) {
494 task := (&TaskTool{}).WithDeliveryProfile(true)
495 opts := task.subagentOptions(context.Background(), 0, nil, 0, 1, "", nil)
496 if !opts.DeliveryProfile {
497 t.Fatal("sub-agent options did not inherit delivery profile")
498 }
499 }
500
501 func TestTaskToolSharesWorkspaceLeaseWithSubagents(t *testing.T) {
502 owner, err := workspacelease.New(t.TempDir(), t.TempDir(), nil)
503 if err != nil {
504 t.Fatalf("New workspace lease: %v", err)
505 }
506 task := (&TaskTool{}).WithWorkspaceLease(owner)
507 opts := task.subagentOptions(context.Background(), 0, nil, 0, 1, "", nil)
508 if opts.WorkspaceLease != owner {
509 t.Fatal("sub-agent options did not share the parent's workspace lease owner")
510 }
511 }
512
513 func TestSubagentRecoveryTaskIDIsStableAndIsolated(t *testing.T) {
514 ctx := WithToolCallContext(context.Background(), "call-17", event.Discard, nil, false)
515 if got := subagentRecoveryTaskID(ctx, ""); got != "subagent:call-17" {
516 t.Fatalf("call-scoped recovery task id = %q", got)
517 }
518 if got := subagentRecoveryTaskID(ctx, "ref-abc"); got != "subagent:ref-abc" {
519 t.Fatalf("transcript-scoped recovery task id = %q", got)
520 }
521 }
522
522 lines GO