1package tools
2
3import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "github.com/bnema/zut/packages/agent/subagents"
10 "github.com/bnema/zut/packages/core"
11)
12
13func TestResidentToolsUseManagerOnly(t *testing.T) {
14 runs := make(chan string, 2)
15 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
16 return func(_ context.Context, prompt string) error { runs <- prompt; return nil }, nil
17 })
18 t.Cleanup(func() { _ = manager.Close(context.Background()) })
19 spawn := &SubagentSpawnTool{
20 ResidentManager: manager,
21 Enabled: func() bool { return true },
22 DefaultProvider: func() string { return "openai" },
23 DefaultModel: func() string { return "gpt-5" },
24 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
25 return subagents.ResidentChildSpec{ID: "resident-tool", SessionID: "child-session", Provider: request.Provider, Model: request.Model, Required: request.Required}, nil
26 },
27 }
28 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"initial"}`), nil)
29 if err != nil {
30 t.Fatal(err)
31 }
32 if len(result.Content) == 0 {
33 t.Fatal("spawn returned no result")
34 }
35 if got := <-runs; got != "initial" {
36 t.Fatalf("initial prompt = %q", got)
37 }
38 status := &SubagentStatusTool{ResidentManager: manager, Enabled: func() bool { return true }}
39 result, err = status.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool"}`), nil)
40 if err != nil {
41 t.Fatal(err)
42 }
43 if details, ok := result.Details.(subagentStatusResponse); !ok || details.Agent == nil || details.Agent.State == "" {
44 t.Fatalf("status details = %#v", result.Details)
45 }
46 resume := &SubagentResumeTool{ResidentManager: manager, Enabled: func() bool { return true }}
47 if _, err := resume.Execute(context.Background(), json.RawMessage(`{"agent_id":"resident-tool","prompt":"follow up"}`), nil); err != nil {
48 t.Fatal(err)
49 }
50 if got := <-runs; got != "follow up" {
51 t.Fatalf("follow-up prompt = %q", got)
52 }
53}
54
55func TestResidentSpawnRejectsInvalidInputBeforeCreatingChild(t *testing.T) {
56 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
57 return func(context.Context, string) error { return nil }, nil
58 })
59 t.Cleanup(func() { _ = manager.Close(context.Background()) })
60 built := 0
61 spawn := &SubagentSpawnTool{
62 ResidentManager: manager,
63 Enabled: func() bool { return true },
64 BuildResidentSpec: func(context.Context, ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
65 built++
66 return subagents.ResidentChildSpec{ID: "should-not-exist", SessionID: "child"}, nil
67 },
68 }
69 for _, raw := range []string{
70 `{"task":"x","unexpected":true}`,
71 `{"task":"x"}{}`,
72 `{"task":"x","model":"gpt-5"}`,
73 `{"task":"x","isolation":"outside"}`,
74 } {
75 result, err := spawn.Execute(context.Background(), json.RawMessage(raw), nil)
76 if err != nil && !strings.Contains(err.Error(), "invalid args") {
77 t.Fatalf("Execute(%s) error = %v", raw, err)
78 }
79 if err == nil && !result.IsError {
80 t.Fatalf("Execute(%s) = %#v, want protocol error", raw, result)
81 }
82 }
83 if built != 0 || len(manager.Snapshot()) != 0 {
84 t.Fatalf("invalid input reached resident manager: built=%d snapshots=%#v", built, manager.Snapshot())
85 }
86}
87
88func TestResidentSpawnProfileAndExplicitOverridesReachFactory(t *testing.T) {
89 manager := subagents.NewResidentManager(t.TempDir(), func(subagents.ResidentChildSpec, *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
90 return func(context.Context, string) error { return nil }, nil
91 })
92 t.Cleanup(func() { _ = manager.Close(context.Background()) })
93 profileFast := false
94 var got ResidentSpawnRequest
95 spawn := &SubagentSpawnTool{
96 ResidentManager: manager,
97 Enabled: func() bool { return true },
98 DefaultModel: func() string { return "host-model" },
99 DefaultProvider: func() string { return "host-provider" },
100 DefaultReasoning: func() string { return "medium" },
101 ResolveSubagent: func(name string) (*subagents.Profile, error) {
102 return &subagents.Profile{Name: name, Model: "openai/gpt-5.6-sol", Thinking: "low", FastMode: &profileFast}, nil
103 },
104 BuildResidentSpec: func(_ context.Context, request ResidentSpawnRequest) (subagents.ResidentChildSpec, error) {
105 got = request
106 return subagents.ResidentChildSpec{ID: "profiled", SessionID: "child", Provider: request.Provider, Model: request.Model, Profile: request.Profile.Name}, nil
107 },
108 }
109 result, err := spawn.Execute(context.Background(), json.RawMessage(`{"task":"review","agent":"reviewer","reasoning":"high","fast_mode":true}`), nil)
110 if err != nil || result.IsError {
111 t.Fatalf("Execute = (%#v, %v)", result, err)
112 }
113 if got.Model != "gpt-5.6-sol" || got.Provider != "openai" || got.Reasoning != "high" || got.FastMode == nil || !*got.FastMode {
114 t.Fatalf("factory request = %#v", got)
115 }
116}
117
118func TestResidentToolsRejectUnknownFields(t *testing.T) {
119 for _, tool := range []interface {
120 Execute(context.Context, json.RawMessage, func(string)) (core.ToolResult, error)
121 }{
122 &SubagentStatusTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
123 &SubagentStopTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
124 &SubagentResumeTool{ResidentManager: subagents.NewResidentManager(t.TempDir(), nil), Enabled: func() bool { return true }},
125 } {
126 _, err := tool.Execute(context.Background(), json.RawMessage(`{"unknown":true}`), nil)
127 if err == nil || !strings.Contains(err.Error(), "unknown field") {
128 t.Fatalf("unknown field error = %v", err)
129 }
130 }
131}
132
133func TestFindResidentStatusSnapshotRejectsAmbiguousPrefix(t *testing.T) {
134 _, ok := findResidentStatusSnapshot([]subagents.ResidentSnapshot{{ID: "resident-abcd"}, {ID: "resident-abef"}}, "resident-ab")
135 if ok {
136 t.Fatal("ambiguous resident prefix resolved")
137 }
138}