| 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 |