返回 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/runtimepolicy"
17 "reasonix/internal/tool"
18 "reasonix/internal/workspacelease"
19 )
20
21 func TestParallelTasksToolIsReadOnly(t *testing.T) {
22 p := &ParallelTasksTool{}
23 if !p.ReadOnly() {
24 t.Fatal("parallel_tasks must be read-only because spawned sub-agents receive only read-only tools")
25 }
26 if !p.PlanModeSafe() {
27 t.Fatal("parallel_tasks must explicitly allow the planning phase")
28 }
29 }
30
31 func TestParallelTasksSchemaKeepsDependencyOrderingHidden(t *testing.T) {
32 schema := string((&ParallelTasksTool{}).Schema())
33 if strings.Contains(schema, "depends_on") {
34 t.Fatal("parallel_tasks schema should not expose depends_on by default; changing tool schema hurts prompt-cache stability")
35 }
36 }
37
38 func TestParallelTasksValidatesAllTasksBeforeRuntimeLookup(t *testing.T) {
39 tool := &ParallelTasksTool{}
40 _, err := tool.Execute(context.Background(), json.RawMessage(`{
41 "tasks": [
42 {"prompt": "inspect the parser"},
43 {"prompt": " "}
44 ]
45 }`))
46 if err == nil {
47 t.Fatal("Execute returned nil error for an empty later task")
48 }
49 if !strings.Contains(err.Error(), "task 2: prompt is required") {
50 t.Fatalf("Execute error = %v, want task validation before runtime lookup", err)
51 }
52 if strings.Contains(err.Error(), "background jobs are not available") {
53 t.Fatalf("Execute looked up background jobs before validating all tasks: %v", err)
54 }
55 }
56
57 func TestParallelTasksRejectsHiddenDependencyFieldBeforeRuntimeLookup(t *testing.T) {
58 tool := &ParallelTasksTool{}
59 _, err := tool.Execute(context.Background(), json.RawMessage(`{
60 "tasks": [
61 {"prompt": "first", "depends_on": [1]},
62 {"prompt": "second"}
63 ]
64 }`))
65 if err == nil {
66 t.Fatal("Execute returned nil error for a hidden dependency field")
67 }
68 if !strings.Contains(err.Error(), "depends_on") {
69 t.Fatalf("Execute error = %v, want hidden dependency field rejection", err)
70 }
71 if strings.Contains(err.Error(), "background jobs are not available") {
72 t.Fatalf("Execute looked up background jobs before rejecting hidden dependencies: %v", err)
73 }
74 }
75
76 func TestParallelTasksRejectsUnboundedBatchBeforeRuntimeLookup(t *testing.T) {
77 tasks := make([]parallelTaskItem, parallelTasksMaxTasks+1)
78 for i := range tasks {
79 tasks[i].Prompt = "inspect"
80 }
81 args, err := json.Marshal(map[string]any{"tasks": tasks})
82 if err != nil {
83 t.Fatalf("Marshal: %v", err)
84 }
85
86 _, err = (&ParallelTasksTool{}).Execute(context.Background(), args)
87 if err == nil {
88 t.Fatal("Execute returned nil error for an oversized batch")
89 }
90 if !strings.Contains(err.Error(), "at most 64 tasks") {
91 t.Fatalf("Execute error = %v, want bounded-task rejection", err)
92 }
93 if strings.Contains(err.Error(), "not configured") {
94 t.Fatalf("Execute looked up runtime before enforcing the batch cap: %v", err)
95 }
96 }
97
98 func TestParallelTasksForegroundCompletesAndClosesWorkers(t *testing.T) {
99 task := newTestTaskTool(t, parallelStaticProvider{}, tool.NewRegistry(), "sys", "", "", nil)
100 parallel := NewParallelTasksTool(task, tool.NewRegistry())
101 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
102
103 done := make(chan error, 1)
104 go func() {
105 out, err := parallel.Execute(ctx, json.RawMessage(`{
106 "tasks": [
107 {"prompt": "first"},
108 {"prompt": "second"}
109 ]
110 }`))
111 if err != nil {
112 done <- err
113 return
114 }
115 if !strings.Contains(out, "Completed 2 parallel tasks") {
116 done <- stringsError("missing aggregate output: " + out)
117 return
118 }
119 done <- nil
120 }()
121
122 select {
123 case err := <-done:
124 if err != nil {
125 t.Fatal(err)
126 }
127 case <-time.After(2 * time.Second):
128 t.Fatal("parallel_tasks foreground execution did not return; workers likely waited on spawnCh forever")
129 }
130 }
131
132 func TestParallelTasksLongResultsStayIndependentlyRetrievable(t *testing.T) {
133 workspace := t.TempDir()
134 store := NewSubagentStore(t.TempDir())
135 task := NewTaskTool(parallelLongResultProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
136 WithTranscripts(store, workspace, "base-model", "base-effort")
137 parallel := NewParallelTasksTool(task, tool.NewRegistry())
138 ctx := WithParentSession(withCallContext(context.Background(), "parallel-call", event.Discard, nil, false), "parent-session")
139
140 out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"first report"},{"prompt":"second report"}]}`))
141 if err != nil {
142 t.Fatalf("Execute: %v", err)
143 }
144 if len(out) > subagentAggregateBudgetBytes {
145 t.Fatalf("aggregate bytes = %d, want <= %d", len(out), subagentAggregateBudgetBytes)
146 }
147 if _, notice := truncateToolOutput(out); notice != "" {
148 t.Fatalf("bounded aggregate still hit the generic truncator: %s", notice)
149 }
150 if strings.Count(out, "Subagent reference: sa_") != 2 {
151 t.Fatalf("aggregate did not preserve every child ref:\n%s", out)
152 }
153 if !strings.Contains(out, "preview truncated; read the full result") {
154 t.Fatalf("aggregate did not explain lossless retrieval:\n%s", out)
155 }
156
157 refs := subagentRefsFromText(out)
158 if len(refs) != 2 {
159 t.Fatalf("refs = %v, want 2", refs)
160 }
161 reader := NewSubagentResultTool(task)
162 for i, ref := range refs {
163 page, readErr := reader.Execute(ctx, json.RawMessage(fmt.Sprintf(`{"ref":%q,"limit_bytes":24576}`, ref)))
164 if readErr != nil {
165 t.Fatalf("read result %d: %v", i+1, readErr)
166 }
167 wantBegin, wantEnd := "FIRST-BEGIN", "FIRST-END"
168 if i == 1 {
169 wantBegin, wantEnd = "SECOND-BEGIN", "SECOND-END"
170 }
171 if !strings.Contains(page, wantBegin) || !strings.Contains(page, wantEnd) || !strings.Contains(page, "End of subagent result") {
172 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):])
173 }
174 }
175
176 otherCtx := WithParentSession(context.Background(), "other-session")
177 if _, err := reader.Execute(otherCtx, json.RawMessage(fmt.Sprintf(`{"ref":%q}`, refs[0]))); err == nil {
178 t.Fatal("reader accepted a result from an unrelated parent session")
179 }
180 }
181
182 func TestParallelTasksInjectsWorkspaceContextIntoChildren(t *testing.T) {
183 workspace := t.TempDir()
184 task := NewTaskTool(promptRoutingProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
185 WithTranscripts(NewSubagentStore(t.TempDir()), workspace, "base-model", "base-effort")
186 parallel := NewParallelTasksTool(task, tool.NewRegistry())
187 ctx := withCallContext(context.Background(), "parallel-call", event.Discard, nil, false)
188
189 out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"inspect one"},{"prompt":"inspect two"}]}`))
190 if err != nil {
191 t.Fatalf("Execute: %v", err)
192 }
193 if !strings.Contains(out, "Current workspace: "+strconv.Quote(workspace)) ||
194 !strings.Contains(out, `prefer "." or relative paths`) ||
195 !strings.Contains(out, "inspect one") ||
196 !strings.Contains(out, "inspect two") {
197 t.Fatalf("parallel output = %q, want child workspace context and prompt", out)
198 }
199 }
200
201 // TestParallelTasksDeliveryClassifiesPristinePrompt pins the trusted
202 // classifier channel on the parallel_tasks path: delivery intent must be
203 // judged from the child's pristine prompt, not the workspace-wrapped text.
204 // The wrapper is long enough that the IsTask length fallback classifies it as
205 // a task; without ClassifierTaskText a plain conversational child ("Who are
206 // you?") would be required to produce work receipts it has no reason to earn
207 // and would exhaust final-answer readiness instead of answering.
208 func TestParallelTasksDeliveryClassifiesPristinePrompt(t *testing.T) {
209 workspace := t.TempDir()
210 task := NewTaskTool(promptRoutingProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
211 WithTranscripts(NewSubagentStore(t.TempDir()), workspace, "base-model", "base-effort")
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") || !strings.Contains(got.out, "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.SplitSeq(text, "\n") {
381 if after, ok := strings.CutPrefix(line, "Subagent reference: "); ok {
382 refs = append(refs, strings.TrimSpace(after))
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 TestTaskToolPropagatesInheritedExecutionToSubagents(t *testing.T) {
494 parent := runtimepolicy.InheritedExecutionContext{
495 Constraints: runtimepolicy.Constraints{ForbidMutation: true},
496 PlanReadOnly: true,
497 GoalScopeID: "goal-1",
498 }
499 task := &TaskTool{}
500 opts := task.subagentOptions(runtimepolicy.WithInherited(context.Background(), parent), 0, nil, 0, 1, "", nil)
501 if opts.InheritedExecution == nil {
502 t.Fatal("sub-agent options did not inherit parent execution context")
503 }
504 if !opts.InheritedExecution.PlanReadOnly || !opts.InheritedExecution.Constraints.ForbidMutation {
505 t.Fatalf("inherited execution = %+v", opts.InheritedExecution)
506 }
507 if opts.InheritedExecution.GoalScopeID != "goal-1" {
508 t.Fatalf("inherited goal scope = %q", opts.InheritedExecution.GoalScopeID)
509 }
510 }
511
512 func TestTaskToolSharesWorkspaceLeaseWithSubagents(t *testing.T) {
513 owner, err := workspacelease.New(t.TempDir(), t.TempDir(), nil)
514 if err != nil {
515 t.Fatalf("New workspace lease: %v", err)
516 }
517 task := (&TaskTool{}).WithWorkspaceLease(owner)
518 opts := task.subagentOptions(context.Background(), 0, nil, 0, 1, "", nil)
519 if opts.WorkspaceLease != owner {
520 t.Fatal("sub-agent options did not share the parent's workspace lease owner")
521 }
522 }
523
524 func TestTaskToolPropagatesWorkspaceRootToSubagents(t *testing.T) {
525 root := t.TempDir()
526 task := &TaskTool{workspaceRoot: root}
527 opts := task.subagentOptions(context.Background(), 0, nil, 0, 1, "", nil)
528 if opts.WriteWorkspaceRoot != root {
529 t.Fatalf("sub-agent workspace root = %q, want %q", opts.WriteWorkspaceRoot, root)
530 }
531 }
532
533 func TestSubagentRecoveryTaskIDIsStableAndIsolated(t *testing.T) {
534 ctx := WithToolCallContext(context.Background(), "call-17", event.Discard, nil, false)
535 if got := subagentRecoveryTaskID(ctx, ""); got != "subagent:call-17" {
536 t.Fatalf("call-scoped recovery task id = %q", got)
537 }
538 if got := subagentRecoveryTaskID(ctx, "ref-abc"); got != "subagent:ref-abc" {
539 t.Fatalf("transcript-scoped recovery task id = %q", got)
540 }
541 }
542
542 lines GO