| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | |
| 9 | "reasonix/internal/event" |
| 10 | "reasonix/internal/hook" |
| 11 | "reasonix/internal/provider" |
| 12 | "reasonix/internal/tool" |
| 13 | ) |
| 14 | |
| 15 | func TestToolHooksMayMutateWorkspaceUsesRunnerCapabilities(t *testing.T) { |
| 16 | if toolHooksMayMutateWorkspace(hook.NewRunner(nil, "/tmp", nil, nil)) { |
| 17 | t.Fatal("empty hook runner must not create a checkpoint coverage gap") |
| 18 | } |
| 19 | sessionOnly := hook.NewRunner([]hook.ResolvedHook{{Event: hook.SessionStart}}, "/tmp", nil, nil) |
| 20 | if toolHooksMayMutateWorkspace(sessionOnly) { |
| 21 | t.Fatal("non-tool hooks must not create a tool mutation coverage gap") |
| 22 | } |
| 23 | preTool := hook.NewRunner([]hook.ResolvedHook{{Event: hook.PreToolUse}}, "/tmp", nil, nil) |
| 24 | if !toolHooksMayMutateWorkspace(preTool) { |
| 25 | t.Fatal("PreToolUse shell hook must preserve the conservative coverage gap") |
| 26 | } |
| 27 | if !toolHooksMayMutateWorkspace(&stubHooks{}) { |
| 28 | t.Fatal("custom legacy ToolHooks without a capability report must remain conservative") |
| 29 | } |
| 30 | } |
| 31 | |
| 32 | // stubHooks blocks PreToolUse for named tools and records what it saw. |
| 33 | type stubHooks struct { |
| 34 | blockPre map[string]bool |
| 35 | preSeen []string |
| 36 | postSeen []string |
| 37 | postFailureSeen []string |
| 38 | preCompactOut string // returned from PreCompact (extra summary guidance) |
| 39 | subagentSeen []string // last-answer text passed to each SubagentStop |
| 40 | hasPostLLM bool // whether HasPostLLMCall reports a PostLLMCall hook |
| 41 | postLLMOut string // replacement returned from PostLLMCall (when hasPostLLM) |
| 42 | postLLMSeen []string // reasoning text each PostLLMCall received |
| 43 | postLLMTurns []int // turn number each PostLLMCall received |
| 44 | } |
| 45 | |
| 46 | func (h *stubHooks) PreToolUse(_ context.Context, name string, _ json.RawMessage) (bool, string) { |
| 47 | h.preSeen = append(h.preSeen, name) |
| 48 | if h.blockPre[name] { |
| 49 | return true, "blocked by test hook" |
| 50 | } |
| 51 | return false, "" |
| 52 | } |
| 53 | |
| 54 | func (h *stubHooks) PostToolUse(_ context.Context, name string, _ json.RawMessage, _ string) { |
| 55 | h.postSeen = append(h.postSeen, name) |
| 56 | } |
| 57 | |
| 58 | func (h *stubHooks) PostToolUseFailure(_ context.Context, name string, _ json.RawMessage, _ string, _ error) { |
| 59 | h.postFailureSeen = append(h.postFailureSeen, name) |
| 60 | } |
| 61 | |
| 62 | func (h *stubHooks) SubagentStop(_ context.Context, last string) { |
| 63 | h.subagentSeen = append(h.subagentSeen, last) |
| 64 | } |
| 65 | func (h *stubHooks) PreCompact(context.Context, string) string { return h.preCompactOut } |
| 66 | |
| 67 | func (h *stubHooks) PostLLMCall(_ context.Context, reasoning string, turn int) string { |
| 68 | h.postLLMSeen = append(h.postLLMSeen, reasoning) |
| 69 | h.postLLMTurns = append(h.postLLMTurns, turn) |
| 70 | if h.hasPostLLM && h.postLLMOut != "" { |
| 71 | return h.postLLMOut |
| 72 | } |
| 73 | return reasoning |
| 74 | } |
| 75 | |
| 76 | func (h *stubHooks) HasPostLLMCall() bool { return h.hasPostLLM } |
| 77 | |
| 78 | // TestSubagentStopFiresForForegroundTask checks SubagentStop fires (with the |
| 79 | // sub-agent's answer) when a foreground `task` call completes, but not for a |
| 80 | // backgrounded one (which only returns a "started" handle and stops later). |
| 81 | func TestSubagentStopFiresForForegroundTask(t *testing.T) { |
| 82 | reg := tool.NewRegistry() |
| 83 | reg.Add(okTool{name: "task"}) // stands in for the real task tool; returns "ok" |
| 84 | h := &stubHooks{} |
| 85 | a := New(nil, reg, NewSession(""), Options{Hooks: h}, event.Discard) |
| 86 | |
| 87 | a.executeBatch(context.Background(), []provider.ToolCall{{Name: "task", Arguments: `{"prompt":"x"}`}}) |
| 88 | if len(h.subagentSeen) != 1 || h.subagentSeen[0] != "ok" { |
| 89 | t.Fatalf("foreground task should fire SubagentStop with the answer, saw %v", h.subagentSeen) |
| 90 | } |
| 91 | |
| 92 | a.executeBatch(context.Background(), []provider.ToolCall{{Name: "task", Arguments: `{"run_in_background":true}`}}) |
| 93 | if len(h.subagentSeen) != 1 { |
| 94 | t.Errorf("backgrounded task must not fire SubagentStop, saw %v", h.subagentSeen) |
| 95 | } |
| 96 | } |
| 97 | |
| 98 | // TestPreToolUseHookBlocks proves a gating PreToolUse hook refuses a tool call |
| 99 | // (returning a blocked result, never running the tool or its PostToolUse), while |
| 100 | // an unblocked call runs and fires PostToolUse. |
| 101 | func TestPreToolUseHookBlocks(t *testing.T) { |
| 102 | reg := tool.NewRegistry() |
| 103 | reg.Add(fakeTool{name: "bash", readOnly: false}) |
| 104 | reg.Add(fakeTool{name: "read_file", readOnly: true}) |
| 105 | |
| 106 | h := &stubHooks{blockPre: map[string]bool{"bash": true}} |
| 107 | a := New(nil, reg, NewSession(""), Options{Hooks: h}, event.Discard) |
| 108 | |
| 109 | blocked := a.executeOne(context.Background(), provider.ToolCall{Name: "bash", Arguments: `{"command":"x"}`}) |
| 110 | if !blocked.blocked || !strings.HasPrefix(blocked.output, "blocked:") { |
| 111 | t.Errorf("PreToolUse block should yield a blocked result, got %+v", blocked) |
| 112 | } |
| 113 | if !strings.Contains(blocked.output, "blocked by test hook") { |
| 114 | t.Errorf("block reason should be surfaced to the model, got %q", blocked.output) |
| 115 | } |
| 116 | |
| 117 | ok := a.executeOne(context.Background(), provider.ToolCall{Name: "read_file", Arguments: `{"path":"/a"}`}) |
| 118 | if ok.blocked || !strings.Contains(ok.output, "done") { |
| 119 | t.Errorf("unblocked call should run, got %+v", ok) |
| 120 | } |
| 121 | |
| 122 | if got := strings.Join(h.preSeen, ","); got != "bash,read_file" { |
| 123 | t.Errorf("PreToolUse should fire for both calls, saw %q", got) |
| 124 | } |
| 125 | // PostToolUse fires only for the call that actually ran. |
| 126 | if got := strings.Join(h.postSeen, ","); got != "read_file" { |
| 127 | t.Errorf("PostToolUse should fire only for the run tool, saw %q", got) |
| 128 | } |
| 129 | } |
| 130 | |
| 131 | func TestPostToolUseFailureUsesFailureHook(t *testing.T) { |
| 132 | reg := tool.NewRegistry() |
| 133 | reg.Add(failTool{name: "broken"}) |
| 134 | h := &stubHooks{} |
| 135 | a := New(nil, reg, NewSession(""), Options{Hooks: h}, event.Discard) |
| 136 | a.executeOne(context.Background(), provider.ToolCall{Name: "broken", Arguments: `{}`}) |
| 137 | if got := strings.Join(h.postFailureSeen, ","); got != "broken" { |
| 138 | t.Fatalf("failure hooks = %q", got) |
| 139 | } |
| 140 | if len(h.postSeen) != 0 { |
| 141 | t.Fatalf("success hook fired for failure: %v", h.postSeen) |
| 142 | } |
| 143 | } |
| 144 |