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