返回 DeepSeek-Reasonix
hooks_test.go
根目录 / internal / agent / hooks_test.go
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
144 lines GO