| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "os" |
| 6 | "path/filepath" |
| 7 | "strings" |
| 8 | "testing" |
| 9 | |
| 10 | "reasonix/internal/event" |
| 11 | "reasonix/internal/provider" |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | // knownDirectChildRunners are the entry points that still construct a child |
| 16 | // outside TaskTool.RunProfileSpec. Each one re-resolves tools, depth, and |
| 17 | // permissions on its own, which is exactly how a boundary added in one place |
| 18 | // gets missed in another. The list may shrink; adding to it needs a reason in |
| 19 | // the pull request. |
| 20 | var knownDirectChildRunners = map[string]string{ |
| 21 | "internal/agent/task.go": "defines the runners and is the unified path itself", |
| 22 | "internal/boot/boot.go": "skill try + run_skill runners", |
| 23 | "internal/cli/review.go": "reasonix review", |
| 24 | "desktop/subagents_app.go": "desktop profile preview", |
| 25 | } |
| 26 | |
| 27 | // A new fork must be a deliberate, reviewed choice rather than something that |
| 28 | // appears because one more caller found the low-level runner convenient. |
| 29 | func TestChildConstructionForksStayEnumerated(t *testing.T) { |
| 30 | root, err := filepath.Abs("../..") |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | var found []string |
| 35 | err = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| 36 | if err != nil || d.IsDir() { |
| 37 | if d != nil && d.IsDir() && (d.Name() == ".git" || d.Name() == "node_modules" || d.Name() == ".claude") { |
| 38 | return filepath.SkipDir |
| 39 | } |
| 40 | return nil |
| 41 | } |
| 42 | if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") { |
| 43 | return nil |
| 44 | } |
| 45 | body, readErr := os.ReadFile(path) |
| 46 | if readErr != nil { |
| 47 | return readErr |
| 48 | } |
| 49 | text := string(body) |
| 50 | if !strings.Contains(text, "RunSubAgentWithSession(") && !strings.Contains(text, "RunReadOnlySubAgentWithSession(") { |
| 51 | return nil |
| 52 | } |
| 53 | rel, relErr := filepath.Rel(root, path) |
| 54 | if relErr != nil { |
| 55 | return relErr |
| 56 | } |
| 57 | rel = filepath.ToSlash(rel) |
| 58 | if _, known := knownDirectChildRunners[rel]; !known { |
| 59 | found = append(found, rel) |
| 60 | } |
| 61 | return nil |
| 62 | }) |
| 63 | if err != nil { |
| 64 | t.Fatal(err) |
| 65 | } |
| 66 | if len(found) > 0 { |
| 67 | t.Fatalf("these files construct a sub-agent outside the unified runner: %s\n\nCompile the call into a ProfileExecSpec and run it through TaskTool.RunProfileSpec so it inherits tool scoping, depth caps, write claims, scheduler slots, and the completion contract. If a direct runner is genuinely required, add the file to knownDirectChildRunners with a reason.", |
| 68 | strings.Join(found, ", ")) |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | // Converging read_only_task onto the unified runner must not quietly give it |
| 73 | // durable side effects: its contract is that the call leaves nothing behind. |
| 74 | func TestReadOnlyTaskStaysEphemeralOnTheUnifiedRunner(t *testing.T) { |
| 75 | root := t.TempDir() |
| 76 | reg := tool.NewRegistry() |
| 77 | reg.Add(fakeReadFileTool{}) |
| 78 | prov := &scriptedProvider{name: "p", turns: [][]provider.Chunk{ |
| 79 | {{Type: provider.ChunkText, Text: "research done"}, {Type: provider.ChunkDone}}, |
| 80 | }} |
| 81 | task := NewTaskTool(prov, nil, reg, 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil). |
| 82 | WithTranscripts(mustSubagentStore(t), root, "base", "high"). |
| 83 | WithScheduler(NewSubagentScheduler(4, 4)) |
| 84 | |
| 85 | ctx := withCallContext(context.Background(), "call-1", event.Discard, nil, false) |
| 86 | ctx = WithParentSession(ctx, filepath.Join(root, "parent.jsonl")) |
| 87 | out, err := NewReadOnlyTaskTool(task).Execute(ctx, []byte(`{"prompt":"inspect the parser"}`)) |
| 88 | if err != nil { |
| 89 | t.Fatalf("read_only_task: %v", err) |
| 90 | } |
| 91 | if !strings.Contains(out, "research done") { |
| 92 | t.Fatalf("answer = %q", out) |
| 93 | } |
| 94 | if strings.Contains(out, "Subagent reference") { |
| 95 | t.Fatalf("read_only_task must not persist a transcript even under a parent session:\n%s", out) |
| 96 | } |
| 97 | } |
| 98 |