| 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 |