From dee0fda330005e91c904bd30a4af72001f6fac17 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Tue, 21 Apr 2026 21:20:55 +0200 Subject: [PATCH] agent: emit hook timeout/start-failure warnings as info events - Add Warning field to HookResult - runHookCmd sets Warning on timeout and start failure - Hooks.Run collects warnings across commands - All hook call sites emit warnings via infoEvent - Test verifies timeout warning is surfaced --- pkg/agent/core.go | 21 +++++++++++++++++++++ pkg/agent/core_test.go | 14 ++++++++++++-- pkg/agent/hooks.go | 15 +++++++++++---- 3 files changed, 44 insertions(+), 6 deletions(-) diff --git a/pkg/agent/core.go b/pkg/agent/core.go index b9e3416..ba0206c 100644 --- a/pkg/agent/core.go +++ b/pkg/agent/core.go @@ -500,6 +500,9 @@ func (s *agentCore) fireAgentSpawn(ctx context.Context, handler EventHandler) { if result.Ran { handler(infoEvent(hooksRan(1))) } + if result.Warning != "" { + handler(infoEvent(result.Warning)) + } if result.Context != "" { s.loopcfg.systemPrompt += "\n\n---\n\n" + result.Context } @@ -689,6 +692,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event handler(infoEvent("hook blocked prompt")) return "" } + if hookResult.Warning != "" { + handler(infoEvent(hookResult.Warning)) + } if hookResult.Context != "" { input += "\n" + hookResult.Context } @@ -744,6 +750,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event if pre.Ran { handler(infoEvent(hooksRan(1))) } + if pre.Warning != "" { + handler(infoEvent(pre.Warning)) + } if !pre.Blocked { if pre.Context != "" { s.session.appendUserMessage(pre.Context) @@ -758,6 +767,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event if post.Ran { handler(infoEvent(hooksRan(1))) } + if post.Warning != "" { + handler(infoEvent(post.Warning)) + } if post.Context != "" { s.session.appendUserMessage(post.Context) } @@ -783,6 +795,9 @@ func (s *agentCore) executeTurn(ctx context.Context, input string, handler Event "session_id": s.sessionID, "cwd": s.CWD(), }, s.log) + if stopResult.Warning != "" { + handler(infoEvent(stopResult.Warning)) + } s.saveSession() @@ -1007,6 +1022,9 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve if pre.Ran { handler(infoEvent(hooksRan(1))) } + if pre.Warning != "" { + handler(infoEvent(pre.Warning)) + } if pre.Blocked { handler(infoEvent("compact cancelled by hook")) return @@ -1022,6 +1040,9 @@ func (s *agentCore) handleCommand(ctx context.Context, input string, handler Eve if post.Ran { handler(infoEvent(hooksRan(1))) } + if post.Warning != "" { + handler(infoEvent(post.Warning)) + } if post.Context != "" { s.session.appendUserMessage(post.Context) } diff --git a/pkg/agent/core_test.go b/pkg/agent/core_test.go index db4ed05..fb2a497 100644 --- a/pkg/agent/core_test.go +++ b/pkg/agent/core_test.go @@ -1893,8 +1893,7 @@ func TestHookTimeout_Branch(t *testing.T) { t.Cleanup(func() { hookTimeout = old }) // A hook that sleeps longer than the timeout. The timeout branch kills the - // process and returns HookResult{} (Ran=false), so the turn is NOT blocked - // and the backend runs normally. + // process and returns a warning, but does not block the turn. hooks := Hooks{HookPreTurn: []string{"sleep 10"}} c := newCore(t, nil, hooks) evs := collectEvents(context.Background(), c, "hello") @@ -1906,6 +1905,17 @@ func TestHookTimeout_Branch(t *testing.T) { if c.State() != "idle" { t.Errorf("State() = %q after timeout hook; want idle", c.State()) } + // A timeout warning should have been emitted. + infos := byRole(evs, "info") + found := false + for _, s := range infos { + if strings.Contains(s, "timed out") { + found = true + } + } + if !found { + t.Errorf("expected 'timed out' warning in info events; got %v", infos) + } } // --- /backend with injected newBackend --- diff --git a/pkg/agent/hooks.go b/pkg/agent/hooks.go index 5a43e05..9a59b37 100644 --- a/pkg/agent/hooks.go +++ b/pkg/agent/hooks.go @@ -39,6 +39,9 @@ type HookResult struct { Blocked bool // Context is stdout from the hook, injected into the conversation. Context string + // Warning, if non-empty, is a message about hook execution problems + // (e.g. timeout, start failure) that should be surfaced to the user. + Warning string } // Run executes all commands for the named hook in order, sending payload as @@ -61,20 +64,24 @@ func (h Hooks) Run(ctx context.Context, name string, payload any, log *olog.Logg } var contextParts []string + var warnings []string for _, cmdStr := range cmds { log.Debug("hook %s: cmd=%q", name, cmdStr) result := runHookCmd(ctx, name, cmdStr, payloadJSON, cwd, log) + if result.Warning != "" { + warnings = append(warnings, result.Warning) + } if !result.Ran { continue } if result.Blocked { - return HookResult{Ran: true, Blocked: true, Context: result.Context} + return HookResult{Ran: true, Blocked: true, Context: result.Context, Warning: strings.Join(warnings, "; ")} } if result.Context != "" { contextParts = append(contextParts, result.Context) } } - return HookResult{Ran: true, Context: strings.Join(contextParts, "\n")} + return HookResult{Ran: true, Context: strings.Join(contextParts, "\n"), Warning: strings.Join(warnings, "; ")} } func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cwd string, log *olog.Logger) HookResult { @@ -91,7 +98,7 @@ func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cw done := make(chan error, 1) if err := cmd.Start(); err != nil { log.Debug("hook %s: start error: %v", name, err) - return HookResult{} + return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: failed to start: %v", name, err)} } go func() { done <- cmd.Wait() }() @@ -130,7 +137,7 @@ func runHookCmd(ctx context.Context, name, cmdStr string, payloadJSON []byte, cw cmd.Process.Kill() //nolint:errcheck <-done log.Debug("hook %s: timed out after %ds", name, hookTimeout) - return HookResult{} + return HookResult{Ran: true, Warning: fmt.Sprintf("hook %s: timed out after %ds", name, hookTimeout)} } }