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