Skip to content

Commit d27bb33

Browse files
committed
refactor(hooks): tighten executor types and dispatch test
Five small, related cleanups now that Config/Hook/MatcherConfig are plain aliases for the persisted types: - Drop matcher.raw. The "" and "*" matchers already short-circuit to a nil regexp, and so do flat events; matches() collapses to a single return statement and the struct loses an unused field. - Merge hookResult and HandlerResult. They were near-duplicates with parallel field names; hookResult now embeds HandlerResult and only adds err. runHook stops copying field-by-field, the timeout/exec error paths return a fresh hookResult{err: ...} instead of zeroing three fields by hand, and aggregate reads the same Stdout/Stderr/ ExitCode/Output names the rest of the package already uses. - Pull the JSON-on-stdout fallback out of runHook into parseStdoutJSON. Three nested ifs for one straight-line decode is easier to read end-to-end. - Replace the contextEvents map with EventType.consumesContext(). Behaviour is identical (same four events) but the call site reads as a question about the event rather than a map lookup, and the knowledge moves next to the EventType definitions. - Move HookType + HookTypeCommand/HookTypeBuiltin into config.go, next to the Hook alias they describe. Types.go is now strictly about events, decisions, and the Input/Output payloads. - Compress dispatch_test.go's per-event fixtures into an onlyHooks map literal, iterated twice. The 12-event listing now lives in exactly one place in the test file (down from three), matching the one place it lives in compileEvents. Assisted-By: docker-agent
1 parent e183c74 commit d27bb33

4 files changed

Lines changed: 117 additions & 107 deletions

File tree

pkg/hooks/config.go

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,7 @@ import (
1414
// short names it always used, while the single source of truth (and
1515
// the YAML/JSON tags, the validate() method, and IsEmpty) lives next
1616
// to the schema in pkg/config/latest. Adding a new event is a one-line
17-
// change on [latest.HooksConfig] plus one line in the executor's
18-
// compile loop.
17+
// change on [latest.HooksConfig] plus one line in compileEvents.
1918
type (
2019
// Config is the hooks configuration for an agent.
2120
Config = latest.HooksConfig
@@ -27,3 +26,18 @@ type (
2726
// it matches (used by EventPreToolUse and EventPostToolUse).
2827
MatcherConfig = latest.HookMatcherConfig
2928
)
29+
30+
// HookType values populate [Hook.Type]. It is an alias for string so
31+
// hooks authored in YAML round-trip through [latest.HookDefinition]
32+
// without any conversion; the executor validates the value at registry
33+
// lookup time.
34+
type HookType = string
35+
36+
const (
37+
// HookTypeCommand runs a shell command.
38+
HookTypeCommand HookType = "command"
39+
// HookTypeBuiltin dispatches to a named in-process Go function
40+
// registered via [Registry.RegisterBuiltin]. The name is stored in
41+
// [Hook.Command].
42+
HookTypeBuiltin HookType = "builtin"
43+
)

pkg/hooks/dispatch_test.go

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,60 +6,61 @@ import (
66
"github.com/stretchr/testify/assert"
77
)
88

9-
// TestExecutorHasIsGeneric exercises the generic Has API across all
10-
// configured event kinds to ensure the eventTable is wired correctly. It
11-
// guards against regressions where adding a new event to the Config
12-
// struct silently fails to surface in Has/Dispatch.
9+
// trueHook is a minimal hook reused as the body of every per-event
10+
// fixture below — the hook's identity doesn't matter to Has, only its
11+
// presence does.
12+
var trueHook = []Hook{{Type: HookTypeCommand, Command: "true"}}
13+
14+
// matcherWildcard wraps trueHook in the structure tool-scoped events
15+
// (PreToolUse, PostToolUse) require.
16+
var matcherWildcard = []MatcherConfig{{Matcher: "*", Hooks: trueHook}}
17+
18+
// onlyHooks maps each known event to a Config that lights up exactly
19+
// that event. The cross-product test below iterates this map (once for
20+
// the empty check, once for the per-event check), so adding a new
21+
// event is a one-line update here — the same one-line update
22+
// compileEvents needs.
23+
var onlyHooks = map[EventType]*Config{
24+
EventPreToolUse: {PreToolUse: matcherWildcard},
25+
EventPostToolUse: {PostToolUse: matcherWildcard},
26+
EventSessionStart: {SessionStart: trueHook},
27+
EventTurnStart: {TurnStart: trueHook},
28+
EventBeforeLLMCall: {BeforeLLMCall: trueHook},
29+
EventAfterLLMCall: {AfterLLMCall: trueHook},
30+
EventSessionEnd: {SessionEnd: trueHook},
31+
EventOnUserInput: {OnUserInput: trueHook},
32+
EventStop: {Stop: trueHook},
33+
EventNotification: {Notification: trueHook},
34+
EventOnError: {OnError: trueHook},
35+
EventOnMaxIterations: {OnMaxIterations: trueHook},
36+
}
37+
38+
// TestExecutorHasIsGeneric exercises the generic Has API across every
39+
// event kind to ensure compileEvents is wired correctly. It guards
40+
// against regressions where adding a new event to the Config struct
41+
// silently fails to surface in Has/Dispatch.
1342
func TestExecutorHasIsGeneric(t *testing.T) {
1443
t.Parallel()
1544

1645
// Empty config: no event has hooks.
1746
empty := NewExecutor(nil, "/tmp", nil)
18-
for _, ev := range []EventType{
19-
EventPreToolUse, EventPostToolUse, EventSessionStart, EventTurnStart,
20-
EventBeforeLLMCall, EventAfterLLMCall,
21-
EventSessionEnd, EventOnUserInput, EventStop, EventNotification,
22-
EventOnError, EventOnMaxIterations,
23-
} {
47+
for ev := range onlyHooks {
2448
assert.Falsef(t, empty.Has(ev), "empty executor must not report Has(%s)", ev)
2549
}
2650

27-
// Each event populated in turn — the cross-product test ensures that
28-
// configuring event X never makes Has(Y) true for Y != X.
29-
cases := []struct {
30-
event EventType
31-
config *Config
32-
}{
33-
{EventPreToolUse, &Config{PreToolUse: []MatcherConfig{{Matcher: "*", Hooks: []Hook{{Type: HookTypeCommand, Command: "true"}}}}}},
34-
{EventPostToolUse, &Config{PostToolUse: []MatcherConfig{{Matcher: "*", Hooks: []Hook{{Type: HookTypeCommand, Command: "true"}}}}}},
35-
{EventSessionStart, &Config{SessionStart: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
36-
{EventTurnStart, &Config{TurnStart: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
37-
{EventBeforeLLMCall, &Config{BeforeLLMCall: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
38-
{EventAfterLLMCall, &Config{AfterLLMCall: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
39-
{EventSessionEnd, &Config{SessionEnd: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
40-
{EventOnUserInput, &Config{OnUserInput: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
41-
{EventStop, &Config{Stop: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
42-
{EventNotification, &Config{Notification: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
43-
{EventOnError, &Config{OnError: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
44-
{EventOnMaxIterations, &Config{OnMaxIterations: []Hook{{Type: HookTypeCommand, Command: "true"}}}},
45-
}
46-
47-
for _, tc := range cases {
48-
exec := NewExecutor(tc.config, "/tmp", nil)
49-
for _, other := range []EventType{
50-
EventPreToolUse, EventPostToolUse, EventSessionStart, EventTurnStart,
51-
EventBeforeLLMCall, EventAfterLLMCall,
52-
EventSessionEnd, EventOnUserInput, EventStop, EventNotification,
53-
EventOnError, EventOnMaxIterations,
54-
} {
55-
if other == tc.event {
56-
assert.Truef(t, exec.Has(other), "configuring %s must light up Has(%s)", tc.event, other)
51+
// Cross-product: configuring event ev must light up Has(ev) and
52+
// only Has(ev).
53+
for ev, cfg := range onlyHooks {
54+
exec := NewExecutor(cfg, "/tmp", nil)
55+
for other := range onlyHooks {
56+
if other == ev {
57+
assert.Truef(t, exec.Has(other), "configuring %s must light up Has(%s)", ev, other)
5758
} else {
58-
assert.Falsef(t, exec.Has(other), "configuring %s must NOT light up Has(%s)", tc.event, other)
59+
assert.Falsef(t, exec.Has(other), "configuring %s must NOT light up Has(%s)", ev, other)
5960
}
6061
}
6162
}
6263

6364
// Unknown events fall through cleanly rather than panicking.
64-
assert.False(t, NewExecutor(nil, "/tmp", nil).Has(EventType("does-not-exist")))
65+
assert.False(t, empty.Has(EventType("does-not-exist")))
6566
}

pkg/hooks/executor.go

Lines changed: 46 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -22,12 +22,14 @@ type Executor struct {
2222
registry *Registry
2323
// events maps each event to its compiled matcher list. Flat events
2424
// (everything except pre/post_tool_use) are stored as a single
25-
// matcher with an empty pattern, unifying the dispatch path.
25+
// matcher with a nil pattern, unifying the dispatch path.
2626
events map[EventType][]matcher
2727
}
2828

29-
// matcher is the compiled form of a [MatcherConfig]: an optional regex
30-
// pattern (nil means "match all") and the hooks to fire when it matches.
29+
// matcher is the compiled form of a [MatcherConfig]: a tool-name regex
30+
// plus the hooks to fire when it matches. A nil pattern matches every
31+
// tool — both "" and "*" matchers compile to nil, as do flat events
32+
// where the tool-name dimension doesn't apply.
3133
type matcher struct {
3234
pattern *regexp.Regexp
3335
hooks []Hook
@@ -37,15 +39,6 @@ func (m *matcher) matches(toolName string) bool {
3739
return m.pattern == nil || m.pattern.MatchString(toolName)
3840
}
3941

40-
// hookResult is the outcome of a single hook invocation.
41-
type hookResult struct {
42-
output *Output
43-
stdout string
44-
stderr string
45-
exitCode int
46-
err error
47-
}
48-
4942
// NewExecutor creates a new hook executor backed by [DefaultRegistry].
5043
func NewExecutor(config *Config, workingDir string, env []string) *Executor {
5144
return NewExecutorWithRegistry(config, workingDir, env, DefaultRegistry)
@@ -188,6 +181,17 @@ func dedupKey(h Hook) string {
188181
return b.String()
189182
}
190183

184+
// hookResult is the outcome of a single hook invocation: the raw
185+
// [HandlerResult] reported by the handler plus a post-execution err
186+
// (factory failure, timeout, exec error). When err is non-nil the
187+
// handler-reported fields are reset to a uniform "did not run"
188+
// representation so [aggregate] can rely on the err alone.
189+
type hookResult struct {
190+
HandlerResult
191+
192+
err error
193+
}
194+
191195
// runHook resolves the hook's [HookType] in the registry, applies its
192196
// timeout, and returns the structured outcome. JSON-on-stdout is parsed
193197
// into [Output] when the handler didn't already provide one.
@@ -205,7 +209,7 @@ func (e *Executor) runHook(ctx context.Context, hook Hook, inputJSON []byte) hoo
205209
defer cancel()
206210

207211
res, err := handler.Run(timeoutCtx, inputJSON)
208-
r := hookResult{stdout: res.Stdout, stderr: res.Stderr, exitCode: res.ExitCode, output: res.Output}
212+
r := hookResult{HandlerResult: res}
209213

210214
// Normalize timeout/cancellation: handler error types vary, so we
211215
// rewrite to a uniform error so PreToolUse fails closed cleanly.
@@ -214,40 +218,33 @@ func (e *Executor) runHook(ctx context.Context, hook Hook, inputJSON []byte) hoo
214218
if errors.Is(ctxErr, context.DeadlineExceeded) {
215219
reason = fmt.Sprintf("timed out after %s", hook.GetTimeout())
216220
}
217-
r.err = fmt.Errorf("hook %q %s: %w", hook.Command, reason, ctxErr)
218-
r.exitCode = -1
219-
r.output = nil
220-
return r
221+
return hookResult{err: fmt.Errorf("hook %q %s: %w", hook.Command, reason, ctxErr)}
221222
}
222223
if err != nil {
223-
r.err = err
224-
r.exitCode = -1
225-
r.output = nil
226-
return r
224+
return hookResult{err: err}
227225
}
228226

229227
// Fall back to the legacy "parse JSON from stdout" protocol.
230-
if r.output == nil && r.exitCode == 0 && r.stdout != "" {
231-
s := strings.TrimSpace(r.stdout)
232-
if strings.HasPrefix(s, "{") {
233-
var parsed Output
234-
if jerr := json.Unmarshal([]byte(s), &parsed); jerr == nil {
235-
r.output = &parsed
236-
}
237-
}
228+
if r.Output == nil && r.ExitCode == 0 {
229+
r.Output = parseStdoutJSON(r.Stdout)
238230
}
239231
return r
240232
}
241233

242-
// contextEvents are the events whose runtime emit sites consume
243-
// Result.AdditionalContext. Plain stdout from a hook is routed there
244-
// for these events; for observational events it is silently dropped to
245-
// avoid the impression that it mattered.
246-
var contextEvents = map[EventType]bool{
247-
EventSessionStart: true,
248-
EventTurnStart: true,
249-
EventPostToolUse: true,
250-
EventStop: true,
234+
// parseStdoutJSON returns a parsed [Output] when stdout begins with '{'
235+
// and decodes cleanly, or nil otherwise. Used for the legacy "JSON on
236+
// stdout" hook protocol where handlers don't pre-populate
237+
// [HandlerResult.Output].
238+
func parseStdoutJSON(stdout string) *Output {
239+
s := strings.TrimSpace(stdout)
240+
if !strings.HasPrefix(s, "{") {
241+
return nil
242+
}
243+
var parsed Output
244+
if err := json.Unmarshal([]byte(s), &parsed); err != nil {
245+
return nil
246+
}
247+
return &parsed
251248
}
252249

253250
// aggregate combines per-hook results into a single [Result].
@@ -263,36 +260,36 @@ func aggregate(results []hookResult, event EventType) *Result {
263260
slog.Warn("PreToolUse hook failed to execute; denying tool call", "error", r.err)
264261
final.Allowed = false
265262
final.ExitCode = -1
266-
final.Stderr = r.stderr
263+
final.Stderr = r.Stderr
267264
messages = append(messages, fmt.Sprintf("PreToolUse hook failed to execute: %v", r.err))
268265
} else {
269266
slog.Warn("Hook execution error", "error", r.err)
270267
}
271268
continue
272269

273-
case r.exitCode == 2:
270+
case r.ExitCode == 2:
274271
final.Allowed = false
275272
final.ExitCode = 2
276-
if r.stderr != "" {
277-
final.Stderr = r.stderr
278-
messages = append(messages, strings.TrimSpace(r.stderr))
273+
if r.Stderr != "" {
274+
final.Stderr = r.Stderr
275+
messages = append(messages, strings.TrimSpace(r.Stderr))
279276
}
280277
continue
281278

282-
case r.exitCode != 0:
283-
slog.Debug("Hook returned non-zero exit code", "exit_code", r.exitCode, "stderr", r.stderr)
279+
case r.ExitCode != 0:
280+
slog.Debug("Hook returned non-zero exit code", "exit_code", r.ExitCode, "stderr", r.Stderr)
284281
continue
285282

286-
case r.output == nil:
283+
case r.Output == nil:
287284
// Plain stdout becomes AdditionalContext only for events
288285
// whose runtime consumes it.
289-
if r.stdout != "" && contextEvents[event] {
290-
contexts = append(contexts, strings.TrimSpace(r.stdout))
286+
if r.Stdout != "" && event.consumesContext() {
287+
contexts = append(contexts, strings.TrimSpace(r.Stdout))
291288
}
292289
continue
293290
}
294291

295-
out := r.output
292+
out := r.Output
296293
if !out.ShouldContinue() {
297294
final.Allowed = false
298295
if out.StopReason != "" {

pkg/hooks/types.go

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -41,20 +41,18 @@ const (
4141
EventOnMaxIterations EventType = "on_max_iterations"
4242
)
4343

44-
// HookType values populate [Hook.Type]. It is an alias for string so a
45-
// hook authored in YAML round-trips through [latest.HookDefinition]
46-
// without any conversion; the executor validates the value at registry
47-
// lookup time.
48-
type HookType = string
49-
50-
const (
51-
// HookTypeCommand runs a shell command.
52-
HookTypeCommand HookType = "command"
53-
// HookTypeBuiltin dispatches to a named in-process Go function
54-
// registered via [Registry.RegisterBuiltin]. The name is stored in
55-
// [Hook.Command].
56-
HookTypeBuiltin HookType = "builtin"
57-
)
44+
// consumesContext reports whether the runtime emit site for e routes
45+
// [Result.AdditionalContext] somewhere meaningful (a system message, a
46+
// transient turn_start prompt, ...). For observational events it is
47+
// silently dropped, so plain stdout from a hook is also discarded for
48+
// those.
49+
func (e EventType) consumesContext() bool {
50+
switch e {
51+
case EventSessionStart, EventTurnStart, EventPostToolUse, EventStop:
52+
return true
53+
}
54+
return false
55+
}
5856

5957
// Input is the JSON-serializable payload passed to hooks via stdin.
6058
type Input struct {

0 commit comments

Comments
 (0)