返回 DeepSeek-Reasonix
runner_test.go
根目录 / internal / hook / runner_test.go
1 package hook
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12 )
13
14 // --- Runner construction ---
15
16 func TestNewRunnerNil(t *testing.T) {
17 var r *Runner
18 if r.Enabled() {
19 t.Error("nil Runner should not be enabled")
20 }
21 if r.Hooks() != nil {
22 t.Error("nil Runner.Hooks() should be nil")
23 }
24 }
25
26 func TestNewRunnerEmpty(t *testing.T) {
27 r := NewRunner(nil, "/tmp", nil, nil)
28 if r.Enabled() {
29 t.Error("empty hooks Runner should not be enabled")
30 }
31 }
32
33 func TestNewRunnerWithHooks(t *testing.T) {
34 hooks := []ResolvedHook{
35 {HookConfig: HookConfig{Command: "echo hi"}, Event: PreToolUse, Scope: ScopeGlobal},
36 }
37 r := NewRunner(hooks, "/tmp", nil, nil)
38 if !r.Enabled() {
39 t.Error("Runner with hooks should be enabled")
40 }
41 if len(r.Hooks()) != 1 {
42 t.Errorf("Hooks() count = %d, want 1", len(r.Hooks()))
43 }
44 }
45
46 func TestToolMutationHooksEnabled(t *testing.T) {
47 tests := []struct {
48 name string
49 event Event
50 want bool
51 }{
52 {name: "session hook", event: SessionStart, want: false},
53 {name: "pre tool", event: PreToolUse, want: true},
54 {name: "post tool", event: PostToolUse, want: true},
55 {name: "post tool failure", event: PostToolUseFailure, want: true},
56 }
57 for _, tt := range tests {
58 t.Run(tt.name, func(t *testing.T) {
59 r := NewRunner([]ResolvedHook{{Event: tt.event}}, "/tmp", nil, nil)
60 if got := r.ToolMutationHooksEnabled(); got != tt.want {
61 t.Fatalf("ToolMutationHooksEnabled() = %v, want %v", got, tt.want)
62 }
63 })
64 }
65 }
66
67 // --- Runner.PreToolUse ---
68
69 func TestRunnerPreToolUseNoHooks(t *testing.T) {
70 r := NewRunner(nil, "/tmp", nil, nil)
71 block, msg := r.PreToolUse(context.Background(), "bash", nil)
72 if block || msg != "" {
73 t.Errorf("no hooks should pass: block=%v msg=%q", block, msg)
74 }
75 }
76
77 func TestRunnerPreToolUsePass(t *testing.T) {
78 hooks := []ResolvedHook{
79 {HookConfig: HookConfig{Command: "allow"}, Event: PreToolUse},
80 }
81 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
82 return SpawnResult{ExitCode: 0}
83 }
84 r := NewRunner(hooks, "/tmp", spawner, nil)
85 block, msg := r.PreToolUse(context.Background(), "bash", nil)
86 if block {
87 t.Errorf("exit 0 should not block: msg=%q", msg)
88 }
89 }
90
91 func TestRunnerPreToolUseBlock(t *testing.T) {
92 hooks := []ResolvedHook{
93 {HookConfig: HookConfig{Command: "deny"}, Event: PreToolUse},
94 }
95 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
96 return SpawnResult{ExitCode: 2, Stderr: "blocked by policy"}
97 }
98 var notified string
99 notify := func(msg string) { notified = msg }
100 r := NewRunner(hooks, "/tmp", spawner, notify)
101 block, msg := r.PreToolUse(context.Background(), "bash", nil)
102 if !block {
103 t.Error("exit 2 on PreToolUse should block")
104 }
105 if msg == "" {
106 t.Error("block message should not be empty")
107 }
108 if notified == "" {
109 t.Error("notify should have been called")
110 }
111 }
112
113 // --- Runner.PostToolUse ---
114
115 func TestRunnerPostToolUseNoHooks(t *testing.T) {
116 r := NewRunner(nil, "/tmp", nil, nil)
117 // Should not panic.
118 r.PostToolUse(context.Background(), "bash", nil, "ok")
119 }
120
121 func TestRunnerPostToolUseWarn(t *testing.T) {
122 hooks := []ResolvedHook{
123 {HookConfig: HookConfig{Command: "warn"}, Event: PostToolUse},
124 }
125 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
126 return SpawnResult{ExitCode: 1, Stdout: "warning message"}
127 }
128 var notified string
129 notify := func(msg string) { notified = msg }
130 r := NewRunner(hooks, "/tmp", spawner, notify)
131 r.PostToolUse(context.Background(), "bash", nil, "result")
132 if notified == "" {
133 t.Error("PostToolUse warn should notify")
134 }
135 }
136
137 func TestRunnerPostToolUseFailurePreservesNativeObserver(t *testing.T) {
138 hooks := []ResolvedHook{
139 {HookConfig: HookConfig{Command: "claude-failure", PayloadFormat: "claude"}, Event: PostToolUseFailure},
140 {HookConfig: HookConfig{Command: "native-post"}, Event: PostToolUse},
141 }
142 var commands []string
143 r := NewRunner(hooks, "/tmp", func(_ context.Context, in SpawnInput) SpawnResult {
144 commands = append(commands, in.Command)
145 return SpawnResult{ExitCode: 0}
146 }, nil)
147 r.PostToolUseFailure(context.Background(), "bash", json.RawMessage(`{}`), "failed", errors.New("exit 1"))
148 if got := strings.Join(commands, ","); got != "claude-failure,native-post" {
149 t.Fatalf("failure observers = %q", got)
150 }
151 }
152
153 // --- Runner.PermissionRequest ---
154
155 func TestRunnerPermissionRequestPayload(t *testing.T) {
156 hooks := []ResolvedHook{
157 {HookConfig: HookConfig{Command: "notify", Match: "bash"}, Event: PermissionRequest},
158 }
159 var got Payload
160 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
161 if err := json.Unmarshal([]byte(in.Stdin), &got); err != nil {
162 t.Fatalf("payload json: %v", err)
163 }
164 return SpawnResult{ExitCode: 0}
165 }
166 args := json.RawMessage(`{"command":"go test ./..."}`)
167 r := NewRunner(hooks, "/tmp", spawner, nil)
168 r.PermissionRequest(context.Background(), "bash", "go test ./...", args)
169
170 if got.Event != PermissionRequest {
171 t.Errorf("Event = %q, want PermissionRequest", got.Event)
172 }
173 if got.ToolName != "bash" {
174 t.Errorf("ToolName = %q, want bash", got.ToolName)
175 }
176 if got.Subject != "go test ./..." {
177 t.Errorf("Subject = %q, want command subject", got.Subject)
178 }
179 if string(got.ToolArgs) != string(args) {
180 t.Errorf("ToolArgs = %s, want %s", got.ToolArgs, args)
181 }
182 }
183
184 func TestRunnerPermissionRequestWarnOnly(t *testing.T) {
185 hooks := []ResolvedHook{
186 {HookConfig: HookConfig{Command: "warn"}, Event: PermissionRequest},
187 }
188 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
189 return SpawnResult{ExitCode: 2, Stderr: "notification failed"}
190 }
191 var notified string
192 r := NewRunner(hooks, "/tmp", spawner, func(msg string) { notified = msg })
193 decision, _ := r.PermissionRequest(context.Background(), "bash", "go test", nil)
194 if decision != nil {
195 t.Errorf("native PermissionRequest hook must stay advisory-only, got decision=%v", *decision)
196 }
197 if notified == "" {
198 t.Error("PermissionRequest warn should notify")
199 }
200 }
201
202 func TestRunnerPermissionRequestClaudeDecisions(t *testing.T) {
203 claudeHooks := []ResolvedHook{{HookConfig: HookConfig{Command: "guard", PayloadFormat: "claude"}, Event: PermissionRequest}}
204 spawnerReturning := func(stdout string) Spawner {
205 return func(_ context.Context, in SpawnInput) SpawnResult { return SpawnResult{ExitCode: 0, Stdout: stdout} }
206 }
207
208 denyJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"deny"}}}`
209 r := NewRunner(claudeHooks, "/tmp", spawnerReturning(denyJSON), nil)
210 decision, _ := r.PermissionRequest(context.Background(), "bash", "rm -rf /", nil)
211 if decision == nil || *decision != false {
212 t.Fatalf("Claude deny decision = %v, want false", decision)
213 }
214
215 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
216 r = NewRunner(claudeHooks, "/tmp", spawnerReturning(allowJSON), nil)
217 decision, _ = r.PermissionRequest(context.Background(), "bash", "go test", nil)
218 if decision == nil || *decision != true {
219 t.Fatalf("Claude allow decision = %v, want true", decision)
220 }
221
222 r = NewRunner(claudeHooks, "/tmp", spawnerReturning(""), nil)
223 decision, _ = r.PermissionRequest(context.Background(), "bash", "go test", nil)
224 if decision != nil {
225 t.Fatalf("no opinion from the hook should return a nil decision, got %v", *decision)
226 }
227 }
228
229 // --- Runner.PromptSubmit ---
230
231 func TestRunnerPromptSubmitBlock(t *testing.T) {
232 hooks := []ResolvedHook{
233 {HookConfig: HookConfig{Command: "gate"}, Event: UserPromptSubmit},
234 }
235 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
236 return SpawnResult{ExitCode: 2, Stderr: "not allowed"}
237 }
238 r := NewRunner(hooks, "/tmp", spawner, nil)
239 block, _ := r.PromptSubmit(context.Background(), "bad input", 1)
240 if !block {
241 t.Error("exit 2 on UserPromptSubmit should block")
242 }
243 }
244
245 // --- Runner.Stop ---
246
247 func TestRunnerStopNoHooks(t *testing.T) {
248 r := NewRunner(nil, "/tmp", nil, nil)
249 // Should not panic.
250 r.Stop(context.Background(), "last answer", 1)
251 }
252
253 func TestRunnerStopWithHooks(t *testing.T) {
254 hooks := []ResolvedHook{
255 {HookConfig: HookConfig{Command: "log"}, Event: Stop},
256 }
257 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
258 return SpawnResult{ExitCode: 0}
259 }
260 r := NewRunner(hooks, "/tmp", spawner, nil)
261 r.Stop(context.Background(), "done", 1)
262 }
263
264 func TestRunnerStopResultPreservesNativeStopObserver(t *testing.T) {
265 hooks := []ResolvedHook{
266 {HookConfig: HookConfig{Command: "claude-stop-failure", PayloadFormat: "claude"}, Event: StopFailure},
267 {HookConfig: HookConfig{Command: "native-stop"}, Event: Stop},
268 }
269 var commands []string
270 r := NewRunner(hooks, "/tmp", func(_ context.Context, in SpawnInput) SpawnResult {
271 commands = append(commands, in.Command)
272 return SpawnResult{ExitCode: 0}
273 }, nil)
274 r.StopResult(context.Background(), "partial", 1, errors.New("turn failed"))
275 if got := strings.Join(commands, ","); got != "claude-stop-failure,native-stop" {
276 t.Fatalf("stop failure observers = %q", got)
277 }
278 }
279
280 func TestRunnerSessionStartReturnsAdditionalContexts(t *testing.T) {
281 hooks := []ResolvedHook{
282 {HookConfig: HookConfig{Command: "plain"}, Event: SessionStart},
283 {HookConfig: HookConfig{Command: "json"}, Event: SessionStart},
284 }
285 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
286 switch in.Command {
287 case "plain":
288 return SpawnResult{ExitCode: 0, Stdout: "Load notes."}
289 case "json":
290 return SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":{"hookEventName":"SessionStart","additionalContext":"Use Superpowers."}}`}
291 default:
292 return SpawnResult{ExitCode: 1, Stderr: "unexpected"}
293 }
294 }
295 r := NewRunner(hooks, "/tmp", spawner, nil)
296 got := r.SessionStart(context.Background())
297 if len(got) != 2 || got[0] != "Load notes." || got[1] != "Use Superpowers." {
298 t.Fatalf("SessionStart contexts = %#v", got)
299 }
300 }
301
302 func TestRunnerSessionStartReadsContextFile(t *testing.T) {
303 dir := t.TempDir()
304 contextPath := filepath.Join(dir, "CLAUDE.md")
305 if err := os.WriteFile(contextPath, []byte("Use the packaged workflow."), 0o644); err != nil {
306 t.Fatal(err)
307 }
308 calledSpawner := false
309 r := NewRunner([]ResolvedHook{{
310 HookConfig: HookConfig{ContextFile: contextPath, Description: "Plugin CLAUDE.md"},
311 Event: SessionStart,
312 Scope: ScopePlugin,
313 }}, dir, func(context.Context, SpawnInput) SpawnResult {
314 calledSpawner = true
315 return SpawnResult{ExitCode: 1}
316 }, nil)
317
318 got := r.SessionStart(context.Background())
319 if calledSpawner {
320 t.Fatal("context file hook should not invoke shell spawner")
321 }
322 if len(got) != 1 || got[0] != "Use the packaged workflow." {
323 t.Fatalf("SessionStart contexts = %#v", got)
324 }
325 }
326
327 func TestRunnerSessionStartWarnsOnInvalidJSON(t *testing.T) {
328 hooks := []ResolvedHook{{HookConfig: HookConfig{Command: "bad-json"}, Event: SessionStart}}
329 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
330 return SpawnResult{ExitCode: 0, Stdout: `{"hookSpecificOutput":`}
331 }
332 var notified string
333 r := NewRunner(hooks, "/tmp", spawner, func(msg string) { notified = msg })
334 if got := r.SessionStart(context.Background()); len(got) != 0 {
335 t.Fatalf("SessionStart contexts = %#v, want none", got)
336 }
337 if !contains(notified, "invalid JSON") {
338 t.Fatalf("notify = %q, want invalid JSON warning", notified)
339 }
340 }
341
342 func TestRunnerClaudeLifecyclePayloadsShareSessionID(t *testing.T) {
343 events := []Event{SessionStart, PreCompact, Notification, SessionEnd}
344 hooks := make([]ResolvedHook, 0, len(events))
345 for _, event := range events {
346 hooks = append(hooks, ResolvedHook{
347 HookConfig: HookConfig{Command: string(event), PayloadFormat: "claude"},
348 Event: event,
349 })
350 }
351 seen := map[Event]map[string]any{}
352 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
353 var payload map[string]any
354 if err := json.Unmarshal([]byte(in.Stdin), &payload); err != nil {
355 t.Fatalf("payload JSON: %v", err)
356 }
357 seen[Event(payload["hook_event_name"].(string))] = payload
358 return SpawnResult{ExitCode: 0}
359 }
360 r := NewRunner(hooks, "/workspace", spawner, nil)
361 r.SetSessionID("session-42")
362 r.SessionStart(context.Background(), "resume")
363 r.PreCompact(context.Background(), "manual")
364 r.Notification(context.Background(), "approval needed", "permission_prompt")
365 r.SessionEnd(context.Background(), "clear")
366
367 for _, event := range events {
368 if seen[event]["session_id"] != "session-42" {
369 t.Fatalf("%s session_id = %#v", event, seen[event]["session_id"])
370 }
371 }
372 if seen[SessionStart]["source"] != "resume" || seen[PreCompact]["trigger"] != "manual" {
373 t.Fatalf("lifecycle details = %#v / %#v", seen[SessionStart], seen[PreCompact])
374 }
375 if seen[Notification]["notification_type"] != "permission_prompt" || seen[Notification]["message"] != "approval needed" {
376 t.Fatalf("notification payload = %#v", seen[Notification])
377 }
378 if seen[SessionEnd]["reason"] != "clear" {
379 t.Fatalf("session end payload = %#v", seen[SessionEnd])
380 }
381 }
382
383 // --- Runner.PostLLMCall ---
384
385 func TestRunnerHasPostLLMCall(t *testing.T) {
386 with := NewRunner([]ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, "/tmp", nil, nil)
387 if !with.HasPostLLMCall() {
388 t.Error("a configured PostLLMCall hook should report HasPostLLMCall")
389 }
390 without := NewRunner([]ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: Stop}}, "/tmp", nil, nil)
391 if without.HasPostLLMCall() {
392 t.Error("only a Stop hook should not report HasPostLLMCall")
393 }
394 if (*Runner)(nil).HasPostLLMCall() {
395 t.Error("nil runner should report no PostLLMCall hook")
396 }
397 }
398
399 func TestRunnerPostLLMCallReplacesReasoning(t *testing.T) {
400 hooks := []ResolvedHook{{HookConfig: HookConfig{Command: "translate"}, Event: PostLLMCall}}
401 spawner := func(_ context.Context, in SpawnInput) SpawnResult {
402 return SpawnResult{ExitCode: 0, Stdout: " 译文 "}
403 }
404 r := NewRunner(hooks, "/tmp", spawner, nil)
405 if got := r.PostLLMCall(context.Background(), "raw reasoning", 2); got != "译文" {
406 t.Fatalf("PostLLMCall = %q, want trimmed hook stdout", got)
407 }
408 }
409
410 func TestRunnerPostLLMCallKeepsOriginal(t *testing.T) {
411 cases := []struct {
412 name string
413 hooks []ResolvedHook
414 spawn SpawnResult
415 }{
416 {"no PostLLMCall hook", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: Stop}}, SpawnResult{ExitCode: 0, Stdout: "ignored"}},
417 {"empty stdout", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, SpawnResult{ExitCode: 0, Stdout: " "}},
418 {"non-zero exit", []ResolvedHook{{HookConfig: HookConfig{Command: "x"}, Event: PostLLMCall}}, SpawnResult{ExitCode: 1, Stdout: "should be ignored"}},
419 }
420 for _, tc := range cases {
421 t.Run(tc.name, func(t *testing.T) {
422 r := NewRunner(tc.hooks, "/tmp", func(context.Context, SpawnInput) SpawnResult { return tc.spawn }, nil)
423 if got := r.PostLLMCall(context.Background(), "raw", 1); got != "raw" {
424 t.Fatalf("PostLLMCall = %q, want original reasoning preserved", got)
425 }
426 })
427 }
428 }
429
430 // --- FormatOutcome ---
431
432 func TestFormatOutcomePass(t *testing.T) {
433 o := Outcome{
434 Hook: ResolvedHook{HookConfig: HookConfig{Command: "echo hi"}, Event: PreToolUse, Scope: ScopeProject},
435 Decision: DecisionPass,
436 }
437 msg := FormatOutcome(o)
438 if msg == "" {
439 t.Error("FormatOutcome should not be empty")
440 }
441 }
442
443 func TestFormatOutcomeWithDetail(t *testing.T) {
444 o := Outcome{
445 Hook: ResolvedHook{HookConfig: HookConfig{Command: "check"}, Event: PreToolUse, Scope: ScopeGlobal},
446 Decision: DecisionBlock,
447 Stderr: "forbidden",
448 Truncated: true,
449 }
450 msg := FormatOutcome(o)
451 if !contains(msg, "forbidden") {
452 t.Errorf("should include stderr: %s", msg)
453 }
454 if !contains(msg, "truncated") {
455 t.Errorf("should mention truncation: %s", msg)
456 }
457 }
458
459 // --- clipRunes ---
460
461 func TestClipRunes(t *testing.T) {
462 if got := clipRunes("short", 10); got != "short" {
463 t.Errorf("clipRunes short = %q", got)
464 }
465 if got := clipRunes("hello world", 5); got != "hello…" {
466 t.Errorf("clipRunes = %q", got)
467 }
468 if got := clipRunes("", 5); got != "" {
469 t.Errorf("clipRunes empty = %q", got)
470 }
471 if got := clipRunes("abc", 0); got != "" {
472 t.Errorf("clipRunes max=0 = %q", got)
473 }
474 }
475
476 // --- payload JSON ---
477
478 func TestPayloadJSON(t *testing.T) {
479 args := json.RawMessage(`{"command":"echo hi"}`)
480 p := Payload{
481 Event: PreToolUse,
482 Cwd: "/tmp",
483 ToolName: "bash",
484 ToolArgs: args,
485 Turn: 1,
486 }
487 b, err := json.Marshal(p)
488 if err != nil {
489 t.Fatalf("marshal: %v", err)
490 }
491 var decoded Payload
492 if err := json.Unmarshal(b, &decoded); err != nil {
493 t.Fatalf("unmarshal: %v", err)
494 }
495 if decoded.Event != PreToolUse {
496 t.Errorf("Event = %q", decoded.Event)
497 }
498 if decoded.ToolName != "bash" {
499 t.Errorf("ToolName = %q", decoded.ToolName)
500 }
501 if decoded.Turn != 1 {
502 t.Errorf("Turn = %d", decoded.Turn)
503 }
504 }
505
506 // --- capping behavior ---
507
508 func TestCappedBuffer(t *testing.T) {
509 var cb cappedBuffer
510 // Write within cap.
511 n, err := cb.Write([]byte("hello"))
512 if err != nil || n != 5 {
513 t.Errorf("small write: n=%d err=%v", n, err)
514 }
515 if cb.truncated {
516 t.Error("should not be truncated yet")
517 }
518 if cb.String() != "hello" {
519 t.Errorf("String() = %q", cb.String())
520 }
521
522 // Write beyond cap.
523 big := make([]byte, outputCapBytes+1000)
524 for i := range big {
525 big[i] = 'x'
526 }
527 n, err = cb.Write(big)
528 if err != nil || n != len(big) {
529 t.Errorf("big write: n=%d err=%v", n, err)
530 }
531 if !cb.truncated {
532 t.Error("should be truncated after exceeding cap")
533 }
534 }
535
536 // --- IsBlocking ---
537
538 func TestIsBlocking(t *testing.T) {
539 if !IsBlocking(PreToolUse) {
540 t.Error("PreToolUse should be blocking")
541 }
542 if !IsBlocking(UserPromptSubmit) {
543 t.Error("UserPromptSubmit should be blocking")
544 }
545 if IsBlocking(PostToolUse) {
546 t.Error("PostToolUse should not be blocking")
547 }
548 if IsBlocking(Stop) {
549 t.Error("Stop should not be blocking")
550 }
551 }
552
553 // --- defaultTimeout ---
554
555 func TestDefaultTimeout(t *testing.T) {
556 if defaultTimeout(PreToolUse) != 5*time.Second {
557 t.Errorf("PreToolUse timeout = %v", defaultTimeout(PreToolUse))
558 }
559 if defaultTimeout(PermissionRequest) != 5*time.Second {
560 t.Errorf("PermissionRequest timeout = %v", defaultTimeout(PermissionRequest))
561 }
562 if defaultTimeout(PostToolUse) != 30*time.Second {
563 t.Errorf("PostToolUse timeout = %v", defaultTimeout(PostToolUse))
564 }
565 }
566
567 // helper
568 func contains(s, sub string) bool {
569 for i := 0; i <= len(s)-len(sub); i++ {
570 if s[i:i+len(sub)] == sub {
571 return true
572 }
573 }
574 return false
575 }
576
576 lines GO