返回 DeepSeek-Reasonix
planner_hooks_test.go
根目录 / internal / boot / planner_hooks_test.go
1 package boot
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "sync"
11 "testing"
12
13 "reasonix/internal/event"
14 "reasonix/internal/hook"
15 "reasonix/internal/provider"
16 )
17
18 const plannerHookProbeKind = "boot-planner-hook-probe"
19
20 var (
21 plannerHookProbeOnce sync.Once
22 plannerHookProbeMu sync.Mutex
23 plannerHookProbeCurrent *plannerHookProbeProvider
24 )
25
26 type hookPayloadForTest struct {
27 Event string `json:"event"`
28 ToolName string `json:"toolName"`
29 SessionID string `json:"sessionId"`
30 }
31
32 func TestBuildRunsPreToolUseInsidePlanner(t *testing.T) {
33 isolateConfigHome(t)
34 dir := robustTempDir(t)
35 t.Chdir(dir)
36 plannerHookProbeOnce.Do(func() {
37 provider.Register(plannerHookProbeKind, func(cfg provider.Config) (provider.Provider, error) {
38 plannerHookProbeMu.Lock()
39 defer plannerHookProbeMu.Unlock()
40 if plannerHookProbeCurrent == nil {
41 return nil, errors.New("planner hook probe provider is not installed")
42 }
43 if cfg.Model != "planner-model" {
44 return &plannerHookProbeProvider{}, nil
45 }
46 return plannerHookProbeCurrent, nil
47 })
48 })
49 prov := &plannerHookProbeProvider{planner: true}
50 plannerHookProbeMu.Lock()
51 plannerHookProbeCurrent = prov
52 plannerHookProbeMu.Unlock()
53 t.Cleanup(func() {
54 plannerHookProbeMu.Lock()
55 plannerHookProbeCurrent = nil
56 plannerHookProbeMu.Unlock()
57 })
58 writeFile(t, dir, "reasonix.toml", `
59 default_model = "executor"
60
61 [agent]
62 planner_model = "planner"
63
64 [[providers]]
65 name = "executor"
66 kind = "`+plannerHookProbeKind+`"
67 model = "executor-model"
68
69 [[providers]]
70 name = "planner"
71 kind = "`+plannerHookProbeKind+`"
72 model = "planner-model"
73 `)
74 approveWorkspace(t, dir)
75 writeFile(t, dir, "marker.txt", "planner hook probe")
76 logPath := filepath.Join(dir, "hook.log")
77 writeFile(t, dir, "deny-read.sh", "#!/bin/sh\ncat >> "+shellQuoteForTest(logPath)+"\nexit 2\n")
78 writeFile(t, dir, "log-prompt.sh", "#!/bin/sh\ncat >> "+shellQuoteForTest(logPath)+"\n")
79 for _, name := range []string{"deny-read.sh", "log-prompt.sh"} {
80 if err := os.Chmod(filepath.Join(dir, name), 0o755); err != nil {
81 t.Fatal(err)
82 }
83 }
84 settings, err := json.Marshal(map[string]any{"hooks": map[string]any{
85 "PreToolUse": []any{map[string]string{"match": "read_file", "command": filepath.Join(dir, "deny-read.sh")}},
86 "UserPromptSubmit": []any{map[string]string{"command": filepath.Join(dir, "log-prompt.sh")}},
87 }})
88 if err != nil {
89 t.Fatal(err)
90 }
91 writeFile(t, dir, ".reasonix/settings.json", string(settings))
92 if err := hook.ApproveProjectHooks(hook.LoadOptions{ProjectRoot: dir}); err != nil {
93 t.Fatal(err)
94 }
95
96 ctrl, err := Build(context.Background(), withTestSession(t, Options{Sink: event.Discard}))
97 if err != nil {
98 t.Fatalf("Build: %v", err)
99 }
100 defer ctrl.Close()
101 if err := ctrl.Run(context.Background(), "plan only: read marker.txt and outline the change"); err != nil {
102 t.Fatalf("Run: %v", err)
103 }
104 if !prov.plannerRan() {
105 t.Fatal("planner never reached the provider")
106 }
107 first := plannerHookSessions(t, logPath)
108 if prov.readLeaked() {
109 t.Fatal("planner received marker.txt contents past a PreToolUse deny")
110 }
111 if !prov.readBlocked() {
112 t.Fatal("planner's read_file result was not blocked by PreToolUse")
113 }
114
115 if err := ctrl.ClearSession(); err != nil {
116 t.Fatalf("ClearSession: %v", err)
117 }
118 if err := os.Remove(logPath); err != nil {
119 t.Fatal(err)
120 }
121 if err := ctrl.Run(context.Background(), "plan only: read marker.txt again and outline the change"); err != nil {
122 t.Fatalf("Run after ClearSession: %v", err)
123 }
124 second := plannerHookSessions(t, logPath)
125 if second == first {
126 t.Fatalf("parent session %q did not change across ClearSession", first)
127 }
128 }
129
130 // plannerHookSessions returns the parent session the prompt hook saw, after
131 // checking the planner's read_file hook fired as "<parent>:planner".
132 func plannerHookSessions(t *testing.T, logPath string) string {
133 t.Helper()
134 log, err := os.ReadFile(logPath)
135 if err != nil {
136 t.Fatalf("no hook ran: %v", err)
137 }
138 var parent, planner string
139 for line := range strings.SplitSeq(strings.TrimSpace(string(log)), "\n") {
140 if strings.TrimSpace(line) == "" {
141 continue
142 }
143 var p hookPayloadForTest
144 if err := json.Unmarshal([]byte(line), &p); err != nil {
145 t.Fatalf("decode hook payload %q: %v", line, err)
146 }
147 switch {
148 case p.Event == "UserPromptSubmit":
149 parent = p.SessionID
150 case p.Event == "PreToolUse" && p.ToolName == "read_file":
151 planner = p.SessionID
152 }
153 }
154 if parent == "" {
155 t.Fatalf("UserPromptSubmit hook logged no session ID:\n%s", log)
156 }
157 if planner == "" {
158 t.Fatalf("PreToolUse hook never ran for the planner's read_file:\n%s", log)
159 }
160 if planner != parent+":planner" {
161 t.Fatalf("planner hook session = %q, want %q", planner, parent+":planner")
162 }
163 return parent
164 }
165
166 type plannerHookProbeProvider struct {
167 planner bool
168 mu sync.Mutex
169 calls int
170 blocked bool
171 leaked bool
172 }
173
174 func (p *plannerHookProbeProvider) Name() string { return plannerHookProbeKind }
175
176 func (p *plannerHookProbeProvider) plannerRan() bool {
177 p.mu.Lock()
178 defer p.mu.Unlock()
179 return p.calls > 0
180 }
181
182 func (p *plannerHookProbeProvider) readBlocked() bool {
183 p.mu.Lock()
184 defer p.mu.Unlock()
185 return p.blocked
186 }
187
188 func (p *plannerHookProbeProvider) readLeaked() bool {
189 p.mu.Lock()
190 defer p.mu.Unlock()
191 return p.leaked
192 }
193
194 func (p *plannerHookProbeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
195 p.mu.Lock()
196 p.calls++
197 for _, msg := range req.Messages {
198 if msg.Role != provider.RoleTool || msg.Name != "read_file" {
199 continue
200 }
201 if strings.Contains(msg.Content, "blocked:") {
202 p.blocked = true
203 }
204 if strings.Contains(msg.Content, "planner hook probe") {
205 p.leaked = true
206 }
207 }
208 p.mu.Unlock()
209
210 chunks := []provider.Chunk{{Type: provider.ChunkText, Text: "done"}, {Type: provider.ChunkDone}}
211 if p.planner && len(req.Messages) > 0 {
212 switch last := req.Messages[len(req.Messages)-1]; {
213 case last.Role == provider.RoleUser:
214 chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "planner-read", Name: "read_file", Arguments: `{"path":"marker.txt"}`}}}
215 case last.Role == provider.RoleTool && last.Name == "read_file":
216 chunks = []provider.Chunk{{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "planner-submit", Name: "submit_plan", Arguments: `{"objective":"outline","steps":[{"title":"outline the change"}]}`}}}
217 }
218 }
219 ch := make(chan provider.Chunk, len(chunks))
220 for _, chunk := range chunks {
221 ch <- chunk
222 }
223 close(ch)
224 return ch, nil
225 }
226
226 lines GO