返回 DeepSeek-Reasonix
task_test.go
根目录 / internal / agent / task_test.go
1 package agent
2
3 import (
4 "context"
5 "errors"
6 "io"
7 "path/filepath"
8 "strconv"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/event"
14 "reasonix/internal/evidence"
15 "reasonix/internal/jobs"
16 "reasonix/internal/provider"
17 "reasonix/internal/tool"
18 )
19
20 func testTaskContext() context.Context {
21 return WithParentSession(context.Background(), "parent-session")
22 }
23
24 // TestTaskToolReturnsSubAgentFinalAnswer runs a task against a mock provider
25 // that emits a single text turn, and verifies the tool returns that text with a
26 // transcript reference — sub-agent intermediate state isn't supposed to leak.
27 func TestTaskToolReturnsSubAgentFinalAnswer(t *testing.T) {
28 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
29 {Type: provider.ChunkText, Text: "found 3 callers of Foo"},
30 {Type: provider.ChunkDone},
31 }}
32 parentReg := tool.NewRegistry()
33 task := newTestTaskTool(t, sub, parentReg, "test-sys-prompt", "", "", nil)
34
35 out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"find callers of Foo"}`))
36 if err != nil {
37 t.Fatalf("Execute: %v", err)
38 }
39 _ = subagentRefFromOutput(t, out)
40 if !strings.Contains(out, "found 3 callers of Foo") {
41 t.Errorf("got %q, want sub-agent final answer", out)
42 }
43 if !strings.Contains(out, "To continue this same subagent transcript in a later call, pass this ref as `continue_from`. Start a fresh subagent when the next task is independent.") {
44 t.Errorf("got %q, want continuation guidance", out)
45 }
46
47 // The sub-agent must have received the prompt as its user message and
48 // the configured system prompt at the top — proving the session was
49 // fresh, not the parent's.
50 if sys := sub.lastReq.Messages[0]; sys.Role != provider.RoleSystem || sys.Content != "test-sys-prompt" {
51 t.Errorf("first message = %+v, want system 'test-sys-prompt'", sys)
52 }
53 if got := lastUser(sub.lastReq); !strings.Contains(got, `<subagent-context event="SubagentStart">`) || !strings.HasSuffix(got, "find callers of Foo") {
54 t.Errorf("sub-agent user = %q, want SubagentStart context plus prompt", got)
55 }
56 }
57
58 func TestSubagentResultWarnsOnHostDecisionLanguage(t *testing.T) {
59 out := GuardSubagentHostDecisionText("等待用户批准后再执行修改")
60 if !strings.Contains(out, "Subagent boundary") {
61 t.Fatalf("guarded output missing boundary warning:\n%s", out)
62 }
63 plain := "found 3 callers of Foo"
64 if got := GuardSubagentHostDecisionText(plain); got != plain {
65 t.Fatalf("plain output changed: %q", got)
66 }
67 }
68
69 func TestTaskToolInjectsWorkspaceContextIntoSubagentPrompt(t *testing.T) {
70 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
71 {Type: provider.ChunkText, Text: "answer"},
72 {Type: provider.ChunkDone},
73 }}
74 workspace := t.TempDir()
75 task := NewTaskTool(sub, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
76 WithTranscripts(NewSubagentStore(t.TempDir()), workspace, "base-model", "base-effort")
77
78 if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"inspect project"}`)); err != nil {
79 t.Fatalf("Execute: %v", err)
80 }
81 if sys := sub.lastReq.Messages[0]; sys.Role != provider.RoleSystem || sys.Content != "sys" {
82 t.Fatalf("system prompt = %+v, want original prompt", sys)
83 }
84 got := lastUser(sub.lastReq)
85 if !strings.Contains(got, `<workspace-context event="SubagentWorkspace">`) ||
86 !strings.Contains(got, "Current workspace: "+strconv.Quote(workspace)) ||
87 !strings.Contains(got, `prefer "." or relative paths`) ||
88 !strings.HasSuffix(got, "inspect project") {
89 t.Fatalf("sub-agent user = %q, want workspace context plus prompt", got)
90 }
91 }
92
93 func TestTaskToolCancelDuringStuckProviderReturnsPromptly(t *testing.T) {
94 task := newTestTaskTool(t, stuckStreamProvider{}, tool.NewRegistry(), "sys", "", "", nil)
95
96 ctx, cancel := context.WithCancel(testTaskContext())
97 done := make(chan error, 1)
98 go func() {
99 _, err := task.Execute(ctx, []byte(`{"prompt":"wait on stuck provider"}`))
100 done <- err
101 }()
102
103 time.Sleep(50 * time.Millisecond)
104 cancel()
105
106 select {
107 case err := <-done:
108 if err == nil {
109 t.Fatal("Execute returned nil after context cancellation")
110 }
111 if !errors.Is(err, context.Canceled) {
112 t.Fatalf("Execute error = %v, want context cancellation", err)
113 }
114 case <-time.After(500 * time.Millisecond):
115 t.Fatal("TaskTool.Execute did not return promptly after cancellation")
116 }
117 }
118
119 func TestTaskToolSchemaExposesOnlyContinueFromForPersistence(t *testing.T) {
120 task := NewTaskTool(&mockProvider{name: "sub"}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
121 schema := string(task.Schema())
122 if !strings.Contains(schema, `"continue_from"`) {
123 t.Fatalf("task schema = %s, want continue_from", schema)
124 }
125 if strings.Contains(schema, "fork_from") {
126 t.Fatalf("task schema = %s, want no fork_from", schema)
127 }
128 }
129
130 func TestParallelTasksSchemaDoesNotExposePersistentContinuation(t *testing.T) {
131 task := NewTaskTool(&mockProvider{name: "sub"}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
132 parallel := NewParallelTasksTool(task, tool.NewRegistry())
133 schema := string(parallel.Schema())
134 if strings.Contains(schema, "continue_from") || strings.Contains(schema, "fork_from") {
135 t.Fatalf("parallel_tasks schema = %s, want no persistent continuation fields", schema)
136 }
137 }
138
139 func TestTaskToolInheritsReasoningLanguageFromContext(t *testing.T) {
140 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
141 {Type: provider.ChunkText, Text: "done"},
142 {Type: provider.ChunkDone},
143 }}
144 task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil)
145
146 ctx := WithReasoningLanguagePreference(testTaskContext(), "zh")
147 if _, err := task.Execute(ctx, []byte(`{"prompt":"inspect auth"}`)); err != nil {
148 t.Fatalf("Execute: %v", err)
149 }
150 got := lastUser(sub.lastReq)
151 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") || !strings.HasSuffix(got, "inspect auth") {
152 t.Fatalf("sub-agent user = %q, want reasoning-language-prefixed prompt", got)
153 }
154 }
155
156 // TestTaskToolFiltersTools verifies the whitelist behaviour: when the caller
157 // names a subset of tools, the sub-agent's registry contains exactly that set
158 // with recursive delegation tools available while max_subagent_depth leaves one
159 // more layer.
160 func TestTaskToolFiltersTools(t *testing.T) {
161 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
162 {Type: provider.ChunkText, Text: "ok"},
163 {Type: provider.ChunkDone},
164 }}
165 parentReg := tool.NewRegistry()
166 parentReg.Add(fakeTool{name: "read_file", readOnly: true})
167 parentReg.Add(fakeTool{name: "write_file", readOnly: false})
168 parentReg.Add(fakeTool{name: "bash", readOnly: false})
169 task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil)
170 parentReg.Add(task) // simulate the wiring in cli.setup
171 parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
172 parentReg.Add(fakeTool{name: "read_only_skill", readOnly: true})
173 parentReg.Add(fakeTool{name: "research", readOnly: false})
174
175 args := []byte(`{"prompt":"x","tools":["read_file","task","write_file","run_skill","read_only_skill","research"]}`)
176 if _, err := task.Execute(testTaskContext(), args); err != nil {
177 t.Fatalf("Execute: %v", err)
178 }
179 // The sub-agent's tool schemas should reflect the whitelist minus always
180 // unavailable background/install tools. Recursive tools stay visible at depth 1.
181 got := map[string]bool{}
182 for _, s := range sub.lastReq.Tools {
183 got[s.Name] = true
184 }
185 for _, want := range []string{"read_file", "write_file", "task", "run_skill", "read_only_skill", "research"} {
186 if !got[want] {
187 t.Errorf("sub-agent tools = %v, want %q exposed at depth 1", got, want)
188 }
189 }
190 if got["bash"] {
191 t.Errorf("sub-agent tools = %v, want bash omitted when not requested", got)
192 }
193 }
194
195 // TestTaskToolDefaultsToParentToolsWithDepthRemaining covers the no-whitelist
196 // path: the first-layer sub-agent inherits parent tools except always-hidden
197 // background/install tools because it still has one delegation layer available.
198 func TestTaskToolDefaultsToParentToolsWithoutMetaTools(t *testing.T) {
199 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
200 {Type: provider.ChunkText, Text: "ok"},
201 {Type: provider.ChunkDone},
202 }}
203 parentReg := tool.NewRegistry()
204 parentReg.Add(fakeTool{name: "read_file", readOnly: true})
205 parentReg.Add(fakeTool{name: "grep", readOnly: true})
206 task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil)
207 parentReg.Add(task)
208 parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
209 parentReg.Add(fakeTool{name: "read_only_skill", readOnly: true})
210 parentReg.Add(fakeTool{name: "explore", readOnly: false})
211 parentReg.Add(fakeTool{name: "research", readOnly: false})
212 parentReg.Add(fakeTool{name: "review", readOnly: false})
213 parentReg.Add(fakeTool{name: "security_review", readOnly: false})
214 parentReg.Add(fakeTool{name: "remember", readOnly: false})
215
216 if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`)); err != nil {
217 t.Fatalf("Execute: %v", err)
218 }
219 got := map[string]bool{}
220 for _, s := range sub.lastReq.Tools {
221 got[s.Name] = true
222 }
223 for _, want := range []string{"read_file", "grep", "remember", "task", "run_skill", "read_only_skill", "explore", "research", "review", "security_review"} {
224 if !got[want] {
225 t.Errorf("default sub-agent tools = %v, want %q inherited at depth 1", got, want)
226 }
227 }
228 }
229
230 func TestTaskToolAllowsSecondLayerAndStopsThere(t *testing.T) {
231 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
232 {Type: provider.ChunkText, Text: "depth two answer"},
233 {Type: provider.ChunkDone},
234 }}
235 parentReg := tool.NewRegistry()
236 parentReg.Add(fakeTool{name: "read_file", readOnly: true})
237 task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil).WithMaxSubagentDepth(2)
238 parentReg.Add(task)
239 parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
240
241 depthOneCtx := WithSubagentDepth(testTaskContext(), 1)
242 if _, err := task.Execute(depthOneCtx, []byte(`{"prompt":"spawn second layer"}`)); err != nil {
243 t.Fatalf("depth-1 task should be able to spawn depth 2: %v", err)
244 }
245 got := map[string]bool{}
246 for _, s := range sub.lastReq.Tools {
247 got[s.Name] = true
248 }
249 if got["task"] || got["run_skill"] {
250 t.Fatalf("depth-2 child should not receive recursive tools; tools=%v", toolSchemaNames(sub.lastReq.Tools))
251 }
252
253 depthTwoCtx := WithSubagentDepth(testTaskContext(), 2)
254 if _, err := task.Execute(depthTwoCtx, []byte(`{"prompt":"spawn third layer"}`)); err == nil || !strings.Contains(err.Error(), "subagent delegation depth limit reached") {
255 t.Fatalf("depth-2 task error = %v, want depth limit", err)
256 }
257 }
258
259 func TestTaskToolMaxSubagentDepthOneRestoresSingleLayerBoundary(t *testing.T) {
260 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
261 {Type: provider.ChunkText, Text: "single layer answer"},
262 {Type: provider.ChunkDone},
263 }}
264 parentReg := tool.NewRegistry()
265 parentReg.Add(fakeTool{name: "read_file", readOnly: true})
266 task := newTestTaskTool(t, sub, parentReg, "sys", "", "", nil).WithMaxSubagentDepth(1)
267 parentReg.Add(task)
268 parentReg.Add(fakeTool{name: "run_skill", readOnly: false})
269 parentReg.Add(fakeTool{name: "read_only_task", readOnly: true})
270
271 if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"single layer"}`)); err != nil {
272 t.Fatalf("root task should still spawn first-layer subagent: %v", err)
273 }
274 got := map[string]bool{}
275 for _, s := range sub.lastReq.Tools {
276 got[s.Name] = true
277 }
278 for _, hidden := range []string{"task", "run_skill", "read_only_task"} {
279 if got[hidden] {
280 t.Fatalf("max_subagent_depth=1 should hide recursive tool %q; tools=%v", hidden, toolSchemaNames(sub.lastReq.Tools))
281 }
282 }
283
284 depthOneCtx := WithSubagentDepth(testTaskContext(), 1)
285 if _, err := task.Execute(depthOneCtx, []byte(`{"prompt":"too deep"}`)); err == nil || !strings.Contains(err.Error(), "max_subagent_depth=1") {
286 t.Fatalf("depth-1 task error = %v, want max depth rejection", err)
287 }
288 }
289
290 func TestTaskToolUsesConfiguredProfileForExecution(t *testing.T) {
291 parent := &mockProvider{name: "parent", chunks: []provider.Chunk{
292 {Type: provider.ChunkText, Text: "parent answer"},
293 {Type: provider.ChunkDone},
294 }}
295 resolved := &mockProvider{name: "resolved", chunks: []provider.Chunk{
296 {Type: provider.ChunkText, Text: "resolved answer"},
297 {Type: provider.ChunkDone},
298 }}
299 var gotModel, gotEffort string
300 resolve := func(model, effort string) (provider.Provider, *provider.Pricing, int, error) {
301 gotModel, gotEffort = model, effort
302 return resolved, nil, 0, nil
303 }
304 task := newTestTaskTool(t, parent, tool.NewRegistry(), "sys", "deepseek-pro", "max", resolve)
305
306 out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`))
307 if err != nil {
308 t.Fatalf("Execute: %v", err)
309 }
310 if !strings.Contains(out, "resolved answer") {
311 t.Fatalf("sub-agent did not use resolved provider, got %q", out)
312 }
313 if gotModel != "deepseek-pro" || gotEffort != "max" {
314 t.Fatalf("resolved profile = %q/%q, want deepseek-pro/max", gotModel, gotEffort)
315 }
316 }
317
318 func TestTaskToolReturnsProfileResolutionErrors(t *testing.T) {
319 parent := &mockProvider{name: "parent", chunks: []provider.Chunk{
320 {Type: provider.ChunkText, Text: "parent answer"},
321 {Type: provider.ChunkDone},
322 }}
323 resolve := func(string, string) (provider.Provider, *provider.Pricing, int, error) {
324 return nil, nil, 0, errors.New("bad effort")
325 }
326 task := newTestTaskTool(t, parent, tool.NewRegistry(), "sys", "", "", resolve)
327
328 _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x","effort":"turbo"}`))
329 if err == nil || !strings.Contains(err.Error(), "bad effort") {
330 t.Fatalf("Execute error = %v, want profile resolution error", err)
331 }
332 }
333
334 func TestTaskToolRequiresTranscriptStore(t *testing.T) {
335 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
336 {Type: provider.ChunkText, Text: "answer"},
337 {Type: provider.ChunkDone},
338 }}
339 task := NewTaskTool(sub, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
340
341 _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"x"}`))
342 if err == nil || !strings.Contains(err.Error(), "transcript store is required") {
343 t.Fatalf("Execute error = %v, want transcript store requirement", err)
344 }
345 }
346
347 // TestTaskToolRunsEphemerallyWithoutParentSession mirrors headless `reasonix run`:
348 // the store is wired but the context carries no parent session, so the sub-agent
349 // must run without persistence and return its plain answer (no transcript ref).
350 func TestTaskToolRunsEphemerallyWithoutParentSession(t *testing.T) {
351 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
352 {Type: provider.ChunkText, Text: "headless answer"},
353 {Type: provider.ChunkDone},
354 }}
355 task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil)
356
357 out, err := task.Execute(context.Background(), []byte(`{"prompt":"x"}`))
358 if err != nil {
359 t.Fatalf("Execute: %v", err)
360 }
361 if !strings.Contains(out, "headless answer") {
362 t.Fatalf("got %q, want sub-agent final answer", out)
363 }
364 if strings.Contains(out, "Subagent reference") {
365 t.Fatalf("ephemeral run should not emit a transcript reference: %q", out)
366 }
367 }
368
369 func TestReadOnlyTaskToolRunsEphemerallyWithReadOnlyRegistry(t *testing.T) {
370 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
371 {Type: provider.ChunkText, Text: "read-only findings"},
372 {Type: provider.ChunkDone},
373 }}
374 parentReg := tool.NewRegistry()
375 parentReg.Add(fakeTool{name: "read_file", readOnly: true})
376 parentReg.Add(fakeTool{name: "write_file", readOnly: false})
377 parentReg.Add(fakeTool{name: "todo_write", readOnly: true})
378 parentReg.Add(fakeTool{name: "complete_step", readOnly: true})
379 parentReg.Add(fakeTool{name: "connect_tool_source", readOnly: true})
380 parentReg.Add(fakeTool{name: "read_only_skill", readOnly: true})
381 parentReg.Add(fakeTool{name: "bash", readOnly: false})
382 task := newTestTaskTool(t, sub, parentReg, "writer sys", "", "", nil)
383 readonly := NewReadOnlyTaskTool(task)
384 parentReg.Add(task)
385 parentReg.Add(readonly)
386
387 out, err := readonly.Execute(testTaskContext(), []byte(`{"prompt":"inspect callers"}`))
388 if err != nil {
389 t.Fatalf("Execute: %v", err)
390 }
391 if !strings.Contains(out, "read-only findings") {
392 t.Fatalf("output = %q, want final answer", out)
393 }
394 if strings.Contains(out, "Subagent reference") {
395 t.Fatalf("read_only_task should not persist transcript refs: %q", out)
396 }
397 if sys := sub.lastReq.Messages[0]; sys.Role != provider.RoleSystem || sys.Content != DefaultReadOnlyTaskSystemPrompt {
398 t.Fatalf("read_only_task system prompt = %+v, want read-only prompt", sys)
399 }
400 if got := lastUser(sub.lastReq); !strings.Contains(got, "Current workspace: ") || !strings.HasSuffix(got, "inspect callers") {
401 t.Fatalf("read_only_task user = %q, want workspace context plus prompt", got)
402 }
403
404 got := map[string]bool{}
405 for _, s := range sub.lastReq.Tools {
406 got[s.Name] = true
407 }
408 for _, want := range []string{"read_file", "bash"} {
409 if !got[want] {
410 t.Fatalf("read_only_task sub-agent missing %q; tools=%v", want, toolSchemaNames(sub.lastReq.Tools))
411 }
412 }
413 for _, hidden := range []string{"write_file", "todo_write", "complete_step", "connect_tool_source", "task"} {
414 if got[hidden] {
415 t.Fatalf("read_only_task sub-agent should hide %q; tools=%v", hidden, toolSchemaNames(sub.lastReq.Tools))
416 }
417 }
418 for _, want := range []string{"read_only_task", "read_only_skill"} {
419 if !got[want] {
420 t.Fatalf("read_only_task depth-1 sub-agent should expose %q; tools=%v", want, toolSchemaNames(sub.lastReq.Tools))
421 }
422 }
423 }
424
425 func TestTaskToolRejectsContinuationWithoutParentSession(t *testing.T) {
426 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
427 {Type: provider.ChunkText, Text: "answer"},
428 {Type: provider.ChunkDone},
429 }}
430 task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil)
431
432 _, err := task.Execute(context.Background(), []byte(`{"prompt":"x","continue_from":"sa_whatever"}`))
433 if err == nil || !strings.Contains(err.Error(), "persisted session") {
434 t.Fatalf("Execute error = %v, want persisted-session requirement", err)
435 }
436 }
437
438 func TestTaskToolPersistsAndContinuesTranscript(t *testing.T) {
439 sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
440 {
441 {Type: provider.ChunkText, Text: "first answer"},
442 {Type: provider.ChunkDone},
443 },
444 {
445 {Type: provider.ChunkText, Text: "second answer"},
446 {Type: provider.ChunkDone},
447 },
448 }}
449 reg := tool.NewRegistry()
450 reg.Add(fakeTool{name: "read_file", readOnly: true})
451 store := NewSubagentStore(t.TempDir())
452 task := newTestTaskTool(t, sub, reg, "sys", "", "", nil).
453 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
454
455 first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
456 if err != nil {
457 t.Fatalf("first Execute: %v", err)
458 }
459 ref := subagentRefFromOutput(t, first)
460 meta, err := store.LoadMeta(ref)
461 if err != nil {
462 t.Fatalf("LoadMeta: %v", err)
463 }
464 if meta.ParentSession != "parent-session" {
465 t.Fatalf("parent session = %q, want parent-session", meta.ParentSession)
466 }
467 if !strings.Contains(first, "first answer") {
468 t.Fatalf("first output = %q, want answer", first)
469 }
470
471 second, err := task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`"}`))
472 if err != nil {
473 t.Fatalf("second Execute: %v", err)
474 }
475 if !strings.Contains(second, "second answer") {
476 t.Fatalf("second output = %q, want answer", second)
477 }
478 if len(sub.requests) != 2 {
479 t.Fatalf("provider requests = %d, want 2", len(sub.requests))
480 }
481 msgs := sub.requests[1].Messages
482 if len(msgs) < 4 {
483 t.Fatalf("continued request messages = %+v, want prior transcript plus new task", msgs)
484 }
485 if !strings.HasSuffix(msgs[1].Content, "first task") || msgs[2].Content != "first answer" || !strings.HasSuffix(lastUser(sub.requests[1]), "second task") {
486 t.Fatalf("continued request messages = %+v, want first task/answer then second task", msgs)
487 }
488 }
489
490 func TestTaskToolContinueFromAncestorReturnsCopiedReferenceGuidance(t *testing.T) {
491 sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
492 {
493 {Type: provider.ChunkText, Text: "root answer"},
494 {Type: provider.ChunkDone},
495 },
496 {
497 {Type: provider.ChunkText, Text: "child answer"},
498 {Type: provider.ChunkDone},
499 },
500 }}
501 sessionDir := t.TempDir()
502 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
503 reg := tool.NewRegistry()
504 reg.Add(fakeTool{name: "read_file", readOnly: true})
505 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
506 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
507
508 rootCtx := WithParentSession(context.Background(), "root")
509 first, err := task.Execute(rootCtx, []byte(`{"prompt":"root task"}`))
510 if err != nil {
511 t.Fatalf("first Execute: %v", err)
512 }
513 rootRef := subagentRefFromOutput(t, first)
514
515 if err := SaveBranchMeta(filepath.Join(sessionDir, "root.jsonl"), BranchMeta{}); err != nil {
516 t.Fatalf("SaveBranchMeta root: %v", err)
517 }
518 if err := SaveBranchMeta(filepath.Join(sessionDir, "child.jsonl"), BranchMeta{ParentID: "root"}); err != nil {
519 t.Fatalf("SaveBranchMeta child: %v", err)
520 }
521
522 childCtx := WithParentSession(context.Background(), "child")
523 second, err := task.Execute(childCtx, []byte(`{"prompt":"child task","continue_from":"`+rootRef+`"}`))
524 if err != nil {
525 t.Fatalf("second Execute: %v", err)
526 }
527 childRef := subagentRefFromOutput(t, second)
528 if childRef == rootRef {
529 t.Fatalf("child ref = source ref %q, want copied ref", childRef)
530 }
531 if !strings.Contains(second, "Forked from: "+rootRef) {
532 t.Fatalf("second output = %q, want Forked from source ref", second)
533 }
534 if !strings.Contains(second, "The requested ref resolves to an ancestor conversation transcript") {
535 t.Fatalf("second output = %q, want ancestor-copy guidance", second)
536 }
537 if !strings.Contains(second, "Final answer:\nchild answer") {
538 t.Fatalf("second output = %q, want final answer", second)
539 }
540 }
541
542 func TestTaskToolLegacyForkFromAncestorConvertsToCopiedReference(t *testing.T) {
543 sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
544 {
545 {Type: provider.ChunkText, Text: "root answer"},
546 {Type: provider.ChunkDone},
547 },
548 {
549 {Type: provider.ChunkText, Text: "child answer"},
550 {Type: provider.ChunkDone},
551 },
552 }}
553 sessionDir := t.TempDir()
554 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
555 reg := tool.NewRegistry()
556 reg.Add(fakeTool{name: "read_file", readOnly: true})
557 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
558 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
559
560 rootCtx := WithParentSession(context.Background(), "root")
561 first, err := task.Execute(rootCtx, []byte(`{"prompt":"root task"}`))
562 if err != nil {
563 t.Fatalf("first Execute: %v", err)
564 }
565 rootRef := subagentRefFromOutput(t, first)
566
567 if err := SaveBranchMeta(filepath.Join(sessionDir, "root.jsonl"), BranchMeta{}); err != nil {
568 t.Fatalf("SaveBranchMeta root: %v", err)
569 }
570 if err := SaveBranchMeta(filepath.Join(sessionDir, "child.jsonl"), BranchMeta{ParentID: "root"}); err != nil {
571 t.Fatalf("SaveBranchMeta child: %v", err)
572 }
573
574 childCtx := WithParentSession(context.Background(), "child")
575 second, err := task.Execute(childCtx, []byte(`{"prompt":"child task","fork_from":"`+rootRef+`"}`))
576 if err != nil {
577 t.Fatalf("second Execute: %v", err)
578 }
579 childRef := subagentRefFromOutput(t, second)
580 if childRef == rootRef {
581 t.Fatalf("child ref = source ref %q, want copied ref", childRef)
582 }
583 if !strings.Contains(second, "Forked from: "+rootRef) ||
584 !strings.Contains(second, "Final answer:\nchild answer") {
585 t.Fatalf("second output = %q, want copied reference guidance and final answer", second)
586 }
587 }
588
589 func TestTaskToolRejectsLegacyForkFromCurrentSession(t *testing.T) {
590 sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
591 {
592 {Type: provider.ChunkText, Text: "first answer"},
593 {Type: provider.ChunkDone},
594 },
595 {
596 {Type: provider.ChunkText, Text: "should not run"},
597 {Type: provider.ChunkDone},
598 },
599 }}
600 task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil).
601 WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "base-effort")
602
603 first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
604 if err != nil {
605 t.Fatalf("first Execute: %v", err)
606 }
607 ref := subagentRefFromOutput(t, first)
608 _, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","fork_from":"`+ref+`"}`))
609 if err == nil || !strings.Contains(err.Error(), "cannot be safely converted") {
610 t.Fatalf("legacy fork error = %v, want unsafe conversion rejection", err)
611 }
612 if len(sub.requests) != 1 {
613 t.Fatalf("provider requests = %d, want only first run", len(sub.requests))
614 }
615 }
616
617 func TestTaskToolFailedForegroundContinuationPersistsAndRejectsReuse(t *testing.T) {
618 sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
619 {
620 {Type: provider.ChunkText, Text: "first answer"},
621 {Type: provider.ChunkDone},
622 },
623 {
624 {Type: provider.ChunkError, Err: errors.New("provider failed")},
625 },
626 }}
627 store := NewSubagentStore(t.TempDir())
628 reg := tool.NewRegistry()
629 reg.Add(fakeTool{name: "read_file", readOnly: true})
630 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
631 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
632
633 first, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
634 if err != nil {
635 t.Fatalf("first Execute: %v", err)
636 }
637 ref := subagentRefFromOutput(t, first)
638
639 _, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`"}`))
640 if err == nil || !strings.Contains(err.Error(), "provider failed") {
641 t.Fatalf("second Execute error = %v, want provider failure", err)
642 }
643 meta, err := store.LoadMeta(ref)
644 if err != nil {
645 t.Fatalf("LoadMeta: %v", err)
646 }
647 if meta.Status != SubagentFailed {
648 t.Fatalf("status = %q, want failed", meta.Status)
649 }
650 loaded, err := LoadSession(store.sessionPath(ref))
651 if err != nil {
652 t.Fatalf("LoadSession: %v", err)
653 }
654 msgs := loaded.Snapshot()
655 if len(msgs) != 5 || !strings.HasSuffix(msgs[1].Content, "first task") || msgs[2].Content != "first answer" || !strings.HasSuffix(msgs[3].Content, "second task") || !msgs[4].LocalOnly {
656 t.Fatalf("failed continuation transcript = %+v, want tasks plus provider-excluded failure recovery", msgs)
657 }
658 if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"third task","continue_from":"`+ref+`"}`)); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") {
659 t.Fatalf("reuse error = %v, want failed ref rejection", err)
660 }
661 }
662
663 func TestTaskToolBackgroundPanicPersistsFailedMetadata(t *testing.T) {
664 sub := panicProvider{name: "panic-sub"}
665 store := NewSubagentStore(t.TempDir())
666 reg := tool.NewRegistry()
667 reg.Add(fakeTool{name: "read_file", readOnly: true})
668 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
669 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
670
671 jm := jobs.NewManager(event.Discard)
672 defer jm.Close()
673 ctx := testTaskContext()
674 ctx = jobs.WithSession(ctx, "parent-session")
675 ctx = jobs.WithManager(ctx, jm)
676 out, err := task.Execute(ctx, []byte(`{"prompt":"panic task","run_in_background":true}`))
677 if err != nil {
678 t.Fatalf("Execute: %v", err)
679 }
680 ref := subagentRefFromOutput(t, out)
681 jobID := extractJobID(out)
682 if jobID == "" {
683 t.Fatalf("no background job id in output:\n%s", out)
684 }
685 res := jm.WaitForSession(context.Background(), "parent-session", []string{jobID}, 5)
686 if len(res) != 1 || res[0].Status != jobs.Failed {
687 t.Fatalf("background job result = %+v, want failed", res)
688 }
689 if !strings.Contains(res[0].Output, "Subagent reference (failed): "+ref) {
690 t.Fatalf("job output = %q, want failed subagent ref %s", res[0].Output, ref)
691 }
692 meta, err := store.LoadMeta(ref)
693 if err != nil {
694 t.Fatalf("LoadMeta: %v", err)
695 }
696 if meta.Status != SubagentFailed {
697 t.Fatalf("status = %q, want failed", meta.Status)
698 }
699 if _, err := task.Execute(testTaskContext(), []byte(`{"prompt":"again","continue_from":"`+ref+`"}`)); err == nil || !strings.Contains(err.Error(), "failed and cannot be continued") {
700 t.Fatalf("reuse error = %v, want failed continuation rejection", err)
701 }
702 }
703
704 func TestTaskToolBackgroundResultIncludesReferenceGuidance(t *testing.T) {
705 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
706 {Type: provider.ChunkText, Text: "background answer"},
707 {Type: provider.ChunkDone},
708 }}
709 store := NewSubagentStore(t.TempDir())
710 reg := tool.NewRegistry()
711 reg.Add(fakeTool{name: "read_file", readOnly: true})
712 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
713 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
714
715 jm := jobs.NewManager(event.Discard)
716 defer jm.Close()
717 ctx := testTaskContext()
718 ctx = jobs.WithSession(ctx, "parent-session")
719 ctx = jobs.WithManager(ctx, jm)
720 out, err := task.Execute(ctx, []byte(`{"prompt":"background task","run_in_background":true}`))
721 if err != nil {
722 t.Fatalf("Execute: %v", err)
723 }
724 ref := subagentRefFromOutput(t, out)
725 if !strings.Contains(out, "To continue this same subagent transcript in a later call") {
726 t.Fatalf("start output = %q, want reference guidance", out)
727 }
728 jobID := extractJobID(out)
729 if jobID == "" {
730 t.Fatalf("no background job id in output:\n%s", out)
731 }
732 res := jm.WaitForSession(context.Background(), "parent-session", []string{jobID}, 5)
733 if len(res) != 1 || res[0].Status != jobs.Done {
734 t.Fatalf("background job result = %+v, want succeeded", res)
735 }
736 if !strings.Contains(res[0].Output, "Subagent reference: "+ref) ||
737 !strings.Contains(res[0].Output, "To continue this same subagent transcript in a later call") ||
738 !strings.Contains(res[0].Output, "Final answer:\nbackground answer") {
739 t.Fatalf("job output = %q, want reference guidance and final answer", res[0].Output)
740 }
741 }
742
743 func TestTaskToolBackgroundAncestorContinuationIncludesForkGuidance(t *testing.T) {
744 sub := &mockProvider{name: "sub", streams: [][]provider.Chunk{
745 {
746 {Type: provider.ChunkText, Text: "root answer"},
747 {Type: provider.ChunkDone},
748 },
749 {
750 {Type: provider.ChunkText, Text: "child background answer"},
751 {Type: provider.ChunkDone},
752 },
753 }}
754 sessionDir := t.TempDir()
755 store := NewSubagentStore(filepath.Join(sessionDir, "subagents"))
756 reg := tool.NewRegistry()
757 reg.Add(fakeTool{name: "read_file", readOnly: true})
758 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
759 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
760
761 rootCtx := WithParentSession(context.Background(), "root")
762 rootOut, err := task.Execute(rootCtx, []byte(`{"prompt":"root task"}`))
763 if err != nil {
764 t.Fatalf("root Execute: %v", err)
765 }
766 rootRef := subagentRefFromOutput(t, rootOut)
767 if err := SaveBranchMeta(filepath.Join(sessionDir, "root.jsonl"), BranchMeta{}); err != nil {
768 t.Fatalf("SaveBranchMeta root: %v", err)
769 }
770 if err := SaveBranchMeta(filepath.Join(sessionDir, "child.jsonl"), BranchMeta{ParentID: "root"}); err != nil {
771 t.Fatalf("SaveBranchMeta child: %v", err)
772 }
773
774 jm := jobs.NewManager(event.Discard)
775 defer jm.Close()
776 childCtx := WithParentSession(context.Background(), "child")
777 childCtx = jobs.WithSession(childCtx, "child")
778 childCtx = jobs.WithManager(childCtx, jm)
779 startOut, err := task.Execute(childCtx, []byte(`{"prompt":"child task","continue_from":"`+rootRef+`","run_in_background":true}`))
780 if err != nil {
781 t.Fatalf("child Execute: %v", err)
782 }
783 childRef := subagentRefFromOutput(t, startOut)
784 if childRef == rootRef {
785 t.Fatalf("child ref = source ref %q, want copied ref", childRef)
786 }
787 if !strings.Contains(startOut, "Forked from: "+rootRef) ||
788 !strings.Contains(startOut, "The requested ref resolves to an ancestor conversation transcript") ||
789 strings.Contains(startOut, "Final answer:") {
790 t.Fatalf("start output = %q, want fork guidance without final answer", startOut)
791 }
792 jobID := extractJobID(startOut)
793 if jobID == "" {
794 t.Fatalf("no background job id in output:\n%s", startOut)
795 }
796 res := jm.WaitForSession(context.Background(), "child", []string{jobID}, 5)
797 if len(res) != 1 || res[0].Status != jobs.Done {
798 t.Fatalf("background job result = %+v, want succeeded", res)
799 }
800 if !strings.Contains(res[0].Output, "Subagent reference: "+childRef) ||
801 !strings.Contains(res[0].Output, "Forked from: "+rootRef) ||
802 !strings.Contains(res[0].Output, "The requested ref resolves to an ancestor conversation transcript") ||
803 !strings.Contains(res[0].Output, "Final answer:\nchild background answer") {
804 t.Fatalf("job output = %q, want copied ref guidance and final answer", res[0].Output)
805 }
806 }
807
808 func TestTaskToolBackgroundCapRefusesFanOut(t *testing.T) {
809 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
810 {Type: provider.ChunkText, Text: "background answer"},
811 {Type: provider.ChunkDone},
812 }}
813 store := NewSubagentStore(t.TempDir())
814 reg := tool.NewRegistry()
815 reg.Add(fakeTool{name: "read_file", readOnly: true})
816 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
817 WithTranscripts(store, t.TempDir(), "base-model", "base-effort")
818
819 jm := jobs.NewManager(event.Discard)
820 defer jm.Close()
821 ctx := testTaskContext()
822 ctx = jobs.WithSession(ctx, "parent-session")
823 ctx = jobs.WithManager(ctx, jm)
824
825 // Saturate the cap with still-running task jobs owned by this session.
826 release := make(chan struct{})
827 var ids []string
828 for i := 0; i < maxConcurrentBackgroundTasks; i++ {
829 j := jm.StartForSession("parent-session", "task", "busy", func(jctx context.Context, _ io.Writer) (string, error) {
830 select {
831 case <-release:
832 case <-jctx.Done():
833 }
834 return "ok", nil
835 })
836 ids = append(ids, j.ID)
837 }
838
839 if _, err := task.Execute(ctx, []byte(`{"prompt":"one more","run_in_background":true}`)); err == nil ||
840 !strings.Contains(err.Error(), "limit") || !strings.Contains(err.Error(), "wait") {
841 t.Fatalf("Execute over cap = %v, want background task limit refusal", err)
842 }
843
844 // Foreground execution is not capped.
845 if out, err := task.Execute(ctx, []byte(`{"prompt":"foreground task"}`)); err != nil || !strings.Contains(out, "background answer") {
846 t.Fatalf("foreground Execute = %q, %v; want uncapped foreground run", out, err)
847 }
848
849 // Collecting the running jobs frees the cap.
850 close(release)
851 jm.WaitForSession(context.Background(), "parent-session", ids, 5)
852 out, err := task.Execute(ctx, []byte(`{"prompt":"after drain","run_in_background":true}`))
853 if err != nil {
854 t.Fatalf("Execute after drain: %v", err)
855 }
856 jobID := extractJobID(out)
857 if jobID == "" {
858 t.Fatalf("no background job id in output:\n%s", out)
859 }
860 if res := jm.WaitForSession(context.Background(), "parent-session", []string{jobID}, 5); len(res) != 1 || res[0].Status != jobs.Done {
861 t.Fatalf("post-drain job = %+v, want done", res)
862 }
863 }
864
865 func TestTaskToolBackgroundSalvagePublishesEvidenceForCollection(t *testing.T) {
866 reg := evidenceRegistry()
867 finalText := []provider.Chunk{{Type: provider.ChunkText, Text: "done, explanations added"}, {Type: provider.ChunkDone}}
868 sub := &scriptedProvider{name: "sub", turns: [][]provider.Chunk{
869 {toolCallChunk("criteria", "todo_write", `{"todos":[{"content":"Add explanations","status":"in_progress"}]}`), {Type: provider.ChunkDone}},
870 {toolCallChunk("write", "write_file", `{"path":"qa/bank.md"}`), {Type: provider.ChunkDone}},
871 finalText,
872 finalText,
873 finalText,
874 }}
875 task := NewTaskTool(sub, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
876 WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "base-effort").
877 WithDeliveryProfile(true)
878
879 jm := jobs.NewManager(event.Discard)
880 defer jm.Close()
881 parentLedger := evidence.NewLedger()
882 ctx := testTaskContext()
883 ctx = jobs.WithSession(ctx, "parent-session")
884 ctx = jobs.WithManager(ctx, jm)
885 ctx = evidence.WithLedger(ctx, parentLedger)
886
887 out, err := task.Execute(ctx, []byte(`{"prompt":"add explanations to the question bank","run_in_background":true}`))
888 if err != nil {
889 t.Fatalf("Execute: %v", err)
890 }
891 jobID := extractJobID(out)
892 res := jm.WaitForSession(context.Background(), "parent-session", []string{jobID}, 5)
893 if len(res) != 1 || res[0].Status != jobs.Done || !strings.Contains(res[0].Output, "[unverified]") {
894 t.Fatalf("background salvage = %+v, want done unverified result", res)
895 }
896 if parentLedger.Summary().HasMutation() {
897 t.Fatal("background goroutine wrote directly into the parent turn ledger")
898 }
899
900 summary := jm.LeaseEvidenceForSession("parent-session", jobID)
901 if !summary.HasMutation() {
902 t.Fatal("terminal background task did not publish its mutation evidence")
903 }
904 paths := summary.MutationPaths()
905 if len(paths) != 1 || filepath.ToSlash(paths[0]) != "qa/bank.md" {
906 t.Fatalf("background mutation paths = %v, want qa/bank.md", paths)
907 }
908 // Lease does not consume: the evidence stays available until the collecting
909 // turn commits, so a cancelled/errored turn can re-collect it.
910 if again := jm.LeaseEvidenceForSession("parent-session", jobID); !again.HasMutation() {
911 t.Fatalf("lease consumed background evidence without a commit: %+v", again)
912 }
913 jm.CommitEvidenceForSession("parent-session", jobID)
914 if after := jm.LeaseEvidenceForSession("parent-session", jobID); len(after.Receipts) != 0 {
915 t.Fatalf("committed background evidence still leasable: %+v", after)
916 }
917 }
918
919 // startTerminalBackgroundMutation registers a background task job that publishes
920 // one mutation and returns after it reaches a terminal state, ready to collect.
921 func startTerminalBackgroundMutation(t *testing.T, jm *jobs.Manager, session, path string) string {
922 t.Helper()
923 j := jm.StartForSession(session, "task", "bg writer", func(ctx context.Context, _ io.Writer) (string, error) {
924 jobs.PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
925 ToolName: "write_file", Success: true, Write: true, Mutation: true, Paths: []string{path},
926 }}})
927 return "background answer", nil
928 })
929 if res := jm.WaitForSession(context.Background(), session, []string{j.ID}, 5); len(res) != 1 || res[0].Status != jobs.Done {
930 t.Fatalf("background job = %+v, want done", res)
931 }
932 return j.ID
933 }
934
935 func waitBuiltin(t *testing.T, reg *tool.Registry) {
936 t.Helper()
937 wait, ok := tool.LookupBuiltin("wait")
938 if !ok {
939 t.Fatal("wait builtin not registered")
940 }
941 reg.Add(wait)
942 }
943
944 func TestBackgroundEvidenceNotCommittedWhenTurnFails(t *testing.T) {
945 // The delivery turn collects a background writer's mutation via wait, then
946 // fails to sign it off, exhausting readiness. Because the turn never
947 // delivered, the lease must not be committed: the mutation stays collectable
948 // so the next turn can review it instead of shipping it unreviewed.
949 jm := jobs.NewManager(event.Discard)
950 defer jm.Close()
951 jobID := startTerminalBackgroundMutation(t, jm, "parent-session", "qa/bank.md")
952
953 reg := evidenceRegistry()
954 waitBuiltin(t, reg)
955 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
956 {toolCallChunk("w", "wait", `{"job_ids":["`+jobID+`"]}`), {Type: provider.ChunkDone}},
957 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}}, // no sign-off
958 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}},
959 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}},
960 }}
961 a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true, Jobs: jm}, event.Discard)
962 ctx := jobs.WithManager(WithParentSession(context.Background(), "parent-session"), jm)
963 ctx = jobs.WithSession(ctx, "parent-session")
964
965 err := a.Run(ctx, "collect and finish the background task")
966 var readiness *FinalReadinessError
967 if !errors.As(err, &readiness) {
968 t.Fatalf("turn = %v, want readiness exhaustion on the uncollected sign-off", err)
969 }
970 // The failed turn must not have consumed the evidence.
971 if leased := jm.LeaseEvidenceForSession("parent-session", jobID); !leased.HasMutation() {
972 t.Fatalf("failed delivery turn consumed the background evidence: %+v", leased)
973 }
974 }
975
976 func TestBackgroundEvidenceCommittedWhenTurnDelivers(t *testing.T) {
977 // A successful turn that collected a background writer's mutation commits the
978 // lease, permanently draining the job's evidence so a later re-poll does not
979 // re-demand review of work already delivered.
980 jm := jobs.NewManager(event.Discard)
981 defer jm.Close()
982 jobID := startTerminalBackgroundMutation(t, jm, "parent-session", "notes.txt")
983
984 reg := tool.NewRegistry()
985 reg.Add(fakeTool{name: "read_file", readOnly: true})
986 waitBuiltin(t, reg)
987 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
988 {toolCallChunk("w", "wait", `{"job_ids":["`+jobID+`"]}`), {Type: provider.ChunkDone}},
989 {{Type: provider.ChunkText, Text: "collected the result"}, {Type: provider.ChunkDone}},
990 }}
991 // No delivery profile: the turn succeeds immediately after collecting, so the
992 // commit-on-success hook fires without a full sign-off script.
993 a := New(prov, reg, NewSession(""), Options{Jobs: jm}, event.Discard)
994 ctx := jobs.WithManager(WithParentSession(context.Background(), "parent-session"), jm)
995 ctx = jobs.WithSession(ctx, "parent-session")
996
997 if err := a.Run(ctx, "collect the background task"); err != nil {
998 t.Fatalf("delivering turn failed: %v", err)
999 }
1000 if leased := jm.LeaseEvidenceForSession("parent-session", jobID); len(leased.Receipts) != 0 {
1001 t.Fatalf("delivered turn did not commit the background lease: %+v", leased)
1002 }
1003 }
1004
1005 // TestFailedTurnBackgroundMutationForcesReadinessOnNextRunWithoutWait extends
1006 // TestBackgroundEvidenceNotCommittedWhenTurnFails: after the first turn collects
1007 // a background mutation via wait but fails to sign it off, Run's Reset wipes the
1008 // per-turn ledger before the second turn starts. Without re-injecting the still
1009 // uncommitted mutation, a second turn that never calls wait/bash_output again
1010 // would sail through final-readiness having never seen it. Run must re-lease it
1011 // automatically so the gate still blocks.
1012 func TestFailedTurnBackgroundMutationForcesReadinessOnNextRunWithoutWait(t *testing.T) {
1013 jm := jobs.NewManager(event.Discard)
1014 defer jm.Close()
1015 jobID := startTerminalBackgroundMutation(t, jm, "parent-session", "qa/bank.md")
1016
1017 reg := evidenceRegistry()
1018 waitBuiltin(t, reg)
1019 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
1020 {toolCallChunk("w", "wait", `{"job_ids":["`+jobID+`"]}`), {Type: provider.ChunkDone}},
1021 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}}, // no sign-off
1022 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}},
1023 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}},
1024 // Second Run: the model never calls wait/bash_output again.
1025 {{Type: provider.ChunkText, Text: "sure, here you go"}, {Type: provider.ChunkDone}},
1026 {{Type: provider.ChunkText, Text: "sure, here you go"}, {Type: provider.ChunkDone}},
1027 {{Type: provider.ChunkText, Text: "sure, here you go"}, {Type: provider.ChunkDone}},
1028 }}
1029 a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true, Jobs: jm}, event.Discard)
1030 ctx := jobs.WithManager(WithParentSession(context.Background(), "parent-session"), jm)
1031 ctx = jobs.WithSession(ctx, "parent-session")
1032
1033 var readiness *FinalReadinessError
1034 if err := a.Run(ctx, "collect and finish the background task"); !errors.As(err, &readiness) {
1035 t.Fatalf("first turn = %v, want readiness exhaustion on the uncollected sign-off", err)
1036 }
1037 if leased := jm.LeaseEvidenceForSession("parent-session", jobID); !leased.HasMutation() {
1038 t.Fatalf("first failed turn consumed the background evidence: %+v", leased)
1039 }
1040
1041 readiness = nil
1042 if err := a.Run(ctx, "never mind, just answer directly"); !errors.As(err, &readiness) {
1043 t.Fatalf("second turn (no wait call) = %v, want readiness exhaustion on the still-pending mutation", err)
1044 }
1045 if leased := jm.LeaseEvidenceForSession("parent-session", jobID); !leased.HasMutation() {
1046 t.Fatalf("second failed turn consumed the background evidence: %+v", leased)
1047 }
1048 }
1049
1050 // TestRestartRecoversPendingBackgroundMutationForcesReadinessWithoutWait mirrors
1051 // the same guarantee across a process restart: a background task mutates and
1052 // finishes while no turn is collecting it, the process exits before any turn
1053 // commits (or even leases) that evidence, and a fresh Manager + Agent pair —
1054 // standing in for the restarted process — must still see it and enforce
1055 // final-readiness on the very first turn, with no wait/bash_output call at all.
1056 func TestRestartRecoversPendingBackgroundMutationForcesReadinessWithoutWait(t *testing.T) {
1057 sessionPath := filepath.Join(t.TempDir(), "session.jsonl")
1058 first := jobs.NewManager(event.Discard)
1059 first.SetActiveSessionPath("parent-session", sessionPath)
1060 j := first.StartForSession("parent-session", "task", "bg writer", func(ctx context.Context, _ io.Writer) (string, error) {
1061 jobs.PublishEvidence(ctx, evidence.ChildEvidenceSummary{Receipts: []evidence.Receipt{{
1062 ToolName: "write_file", Success: true, Write: true, Mutation: true, Paths: []string{"qa/bank.md"},
1063 }}})
1064 return "background answer", nil
1065 })
1066 if res := first.WaitForSession(context.Background(), "parent-session", []string{j.ID}, 5); len(res) != 1 || res[0].Status != jobs.Done {
1067 t.Fatalf("background job = %+v, want done", res)
1068 }
1069 first.Close() // the process exits before any turn ever leased this evidence
1070
1071 second := jobs.NewManager(event.Discard)
1072 defer second.Close()
1073 second.SetActiveSessionPath("parent-session", sessionPath)
1074
1075 reg := evidenceRegistry()
1076 prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{
1077 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}},
1078 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}},
1079 {{Type: provider.ChunkText, Text: "all set"}, {Type: provider.ChunkDone}},
1080 }}
1081 a := New(prov, reg, NewSession(""), Options{DeliveryProfile: true, Jobs: second}, event.Discard)
1082 ctx := jobs.WithManager(WithParentSession(context.Background(), "parent-session"), second)
1083 ctx = jobs.WithSession(ctx, "parent-session")
1084
1085 var readiness *FinalReadinessError
1086 if err := a.Run(ctx, "what's the status?"); !errors.As(err, &readiness) {
1087 t.Fatalf("post-restart turn = %v, want readiness exhaustion on the recovered mutation", err)
1088 }
1089 if leased := second.LeaseEvidenceForSession("parent-session", j.ID); !leased.HasMutation() {
1090 t.Fatalf("recovered evidence lost after the failed post-restart turn: %+v", leased)
1091 }
1092 }
1093
1094 func TestTaskToolRejectsMismatchedContinuationProfile(t *testing.T) {
1095 sub := &mockProvider{name: "sub", chunks: []provider.Chunk{
1096 {Type: provider.ChunkText, Text: "answer"},
1097 {Type: provider.ChunkDone},
1098 }}
1099 task := newTestTaskTool(t, sub, tool.NewRegistry(), "sys", "", "", nil).
1100 WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "")
1101
1102 out, err := task.Execute(testTaskContext(), []byte(`{"prompt":"first task"}`))
1103 if err != nil {
1104 t.Fatalf("first Execute: %v", err)
1105 }
1106 ref := subagentRefFromOutput(t, out)
1107 _, err = task.Execute(testTaskContext(), []byte(`{"prompt":"second task","continue_from":"`+ref+`","model":"other-model"}`))
1108 if err == nil || !strings.Contains(err.Error(), "model/effort") {
1109 t.Fatalf("mismatched model error = %v, want compatibility failure", err)
1110 }
1111 }
1112
1113 func extractJobID(msg string) string {
1114 quote := strings.Index(msg, `"`)
1115 if quote < 0 {
1116 return ""
1117 }
1118 end := strings.Index(msg[quote+1:], `"`)
1119 if end < 0 {
1120 return ""
1121 }
1122 return msg[quote+1 : quote+1+end]
1123 }
1124
1125 func subagentRefFromOutput(t *testing.T, out string) string {
1126 t.Helper()
1127 for _, line := range strings.Split(out, "\n") {
1128 if strings.HasPrefix(line, "Subagent reference: ") {
1129 return strings.TrimSpace(strings.TrimPrefix(line, "Subagent reference: "))
1130 }
1131 }
1132 t.Fatalf("no subagent reference in output:\n%s", out)
1133 return ""
1134 }
1135
1136 func TestSubSinkForwardsUsageToParent(t *testing.T) {
1137 var got []event.Event
1138 parent := event.FuncSink(func(e event.Event) {
1139 got = append(got, e)
1140 })
1141 subSinkFor("task_1", parent).Emit(event.Event{
1142 Kind: event.Usage,
1143 Usage: &provider.Usage{PromptTokens: 10, CompletionTokens: 2, TotalTokens: 12},
1144 UsageSource: event.UsageSourceSubagent,
1145 })
1146 if len(got) != 1 || got[0].Usage == nil || got[0].UsageSource != event.UsageSourceSubagent {
1147 t.Fatalf("forwarded events = %+v, want subagent usage", got)
1148 }
1149 }
1150
1151 func TestTaskToolCarriesRecentKeepIntoSubsessions(t *testing.T) {
1152 task := NewTaskTool(&mockProvider{name: "sub"}, nil, tool.NewRegistry(), 20, 0, 7, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil)
1153 if task.recentKeep != 7 {
1154 t.Fatalf("recentKeep = %d, want 7", task.recentKeep)
1155 }
1156 }
1157
1158 func newTestTaskTool(t *testing.T, prov provider.Provider, reg *tool.Registry, sysPrompt, subagentModel, subagentEffort string, resolve func(string, string) (provider.Provider, *provider.Pricing, int, error)) *TaskTool {
1159 t.Helper()
1160 return NewTaskToolWithOptions(TaskToolOptions{
1161 Provider: prov,
1162 ParentRegistry: reg,
1163 MaxSteps: 20,
1164 SysPrompt: sysPrompt,
1165 SubagentModel: subagentModel,
1166 SubagentEffort: subagentEffort,
1167 ResolveProvider: resolve,
1168 }).WithTranscripts(NewSubagentStore(t.TempDir()), t.TempDir(), "base-model", "base-effort")
1169 }
1170
1171 type panicProvider struct{ name string }
1172
1173 func (p panicProvider) Name() string { return p.name }
1174
1175 func (p panicProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
1176 panic("subagent boom")
1177 }
1178
1178 lines GO