Skip to content

Commit 2401fb4

Browse files
authored
Merge pull request #692 from Cai-Tang-www/feat/hook-point-single-source-681
refactor(hooks): consolidate hook point definitions to single source
2 parents 15f7bdb + 066b512 commit 2401fb4

7 files changed

Lines changed: 490 additions & 85 deletions

File tree

docs/runtime-hooks-design.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ P2 仅支持:
2626
- `kind=builtin`
2727
- `mode=sync`
2828
- 挂载点:与 `HookPointCapability``UserAllowed=true` 的点位一致,当前包括:
29-
`before_tool_call``after_tool_result``before_completion_decision``after_tool_failure`
29+
`before_tool_call``after_tool_result``before_completion_decision``accept_gate``after_tool_failure`
3030
`session_start``session_end``user_prompt_submit``post_compact``subagent_stop`
3131
- handler:`require_file_exists``warn_on_tool_call``add_context_note`
3232
- `kind=http + mode=observe`:允许发送 HTTP 观测回调(不支持 block)
@@ -91,6 +91,7 @@ runtime 内置 `HookPointCapability` 作为唯一真源,定义每个点位是
9191
- `before_tool_call`
9292
- `after_tool_result`
9393
- `before_completion_decision`
94+
- `accept_gate`
9495
- `before_permission_decision`
9596
- `after_tool_failure`
9697
- `session_start`

internal/config/runtime_hooks.go

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"net"
66
"net/url"
77
"strings"
8+
9+
"neo-code/internal/runtime/hooks"
810
)
911

1012
const (
@@ -38,22 +40,6 @@ var runtimeHookExternalKinds = map[string]struct{}{
3840
"agent": {},
3941
}
4042

41-
const (
42-
runtimeHookPointBeforeToolCall = "before_tool_call"
43-
runtimeHookPointAfterToolResult = "after_tool_result"
44-
runtimeHookPointBeforeCompletionDecision = "before_completion_decision"
45-
runtimeHookPointAcceptGate = "accept_gate"
46-
runtimeHookPointBeforePermissionDecision = "before_permission_decision"
47-
runtimeHookPointAfterToolFailure = "after_tool_failure"
48-
runtimeHookPointSessionStart = "session_start"
49-
runtimeHookPointSessionEnd = "session_end"
50-
runtimeHookPointUserPromptSubmit = "user_prompt_submit"
51-
runtimeHookPointPreCompact = "pre_compact"
52-
runtimeHookPointPostCompact = "post_compact"
53-
runtimeHookPointSubAgentStart = "subagent_start"
54-
runtimeHookPointSubAgentStop = "subagent_stop"
55-
)
56-
5743
const (
5844
runtimeHookHandlerRequireFileExists = "require_file_exists"
5945
runtimeHookHandlerWarnOnToolCall = "warn_on_tool_call"
@@ -246,25 +232,11 @@ func (c RuntimeHookItemConfig) Validate(defaultFailurePolicy string) error {
246232
if strings.TrimSpace(c.ID) == "" {
247233
return fmt.Errorf("id is required")
248234
}
249-
point := strings.TrimSpace(c.Point)
250-
switch point {
251-
case runtimeHookPointBeforeToolCall,
252-
runtimeHookPointAfterToolResult,
253-
runtimeHookPointBeforeCompletionDecision,
254-
runtimeHookPointAcceptGate,
255-
runtimeHookPointBeforePermissionDecision,
256-
runtimeHookPointAfterToolFailure,
257-
runtimeHookPointSessionStart,
258-
runtimeHookPointSessionEnd,
259-
runtimeHookPointUserPromptSubmit,
260-
runtimeHookPointPreCompact,
261-
runtimeHookPointPostCompact,
262-
runtimeHookPointSubAgentStart,
263-
runtimeHookPointSubAgentStop:
264-
default:
235+
point := hooks.HookPoint(strings.TrimSpace(c.Point))
236+
if _, ok := hooks.HookPointCapabilities(point); !ok {
265237
return fmt.Errorf("point %q is not supported", c.Point)
266238
}
267-
if !runtimeHookPointUserAllowed(point) {
239+
if !hooks.IsUserAllowed(point) {
268240
return fmt.Errorf("point %q does not allow user hooks", c.Point)
269241
}
270242

@@ -472,11 +444,3 @@ func readRuntimeHookParamString(params map[string]any, key string) string {
472444
}
473445
}
474446

475-
func runtimeHookPointUserAllowed(point string) bool {
476-
switch strings.ToLower(strings.TrimSpace(point)) {
477-
case runtimeHookPointBeforePermissionDecision, runtimeHookPointPreCompact, runtimeHookPointSubAgentStart:
478-
return false
479-
default:
480-
return true
481-
}
482-
}

internal/config/runtime_hooks_test.go

Lines changed: 84 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ package config
33
import (
44
"strings"
55
"testing"
6+
7+
"neo-code/internal/runtime/hooks"
68
)
79

810
func TestRuntimeHooksConfigApplyDefaultsAndValidate(t *testing.T) {
@@ -46,31 +48,31 @@ func TestRuntimeHooksConfigValidateUnsupportedFields(t *testing.T) {
4648
tests := []RuntimeHookItemConfig{
4749
{
4850
ID: "bad-scope",
49-
Point: runtimeHookPointBeforeToolCall,
51+
Point: string(hooks.HookPointBeforeToolCall),
5052
Scope: "repo",
5153
Kind: runtimeHookKindBuiltIn,
5254
Mode: runtimeHookModeSync,
5355
Handler: runtimeHookHandlerWarnOnToolCall,
5456
},
5557
{
5658
ID: "bad-kind",
57-
Point: runtimeHookPointBeforeToolCall,
59+
Point: string(hooks.HookPointBeforeToolCall),
5860
Scope: runtimeHookScopeUser,
5961
Kind: "command",
6062
Mode: runtimeHookModeSync,
6163
Handler: runtimeHookHandlerWarnOnToolCall,
6264
},
6365
{
6466
ID: "bad-mode",
65-
Point: runtimeHookPointBeforeToolCall,
67+
Point: string(hooks.HookPointBeforeToolCall),
6668
Scope: runtimeHookScopeUser,
6769
Kind: runtimeHookKindBuiltIn,
6870
Mode: "async",
6971
Handler: runtimeHookHandlerWarnOnToolCall,
7072
},
7173
{
7274
ID: "bad-handler",
73-
Point: runtimeHookPointBeforeToolCall,
75+
Point: string(hooks.HookPointBeforeToolCall),
7476
Scope: runtimeHookScopeUser,
7577
Kind: runtimeHookKindBuiltIn,
7678
Mode: runtimeHookModeSync,
@@ -113,7 +115,7 @@ func TestRuntimeHooksConfigValidateRejectsExternalKindsWithP6LiteMessage(t *test
113115
cfg.Items = []RuntimeHookItemConfig{
114116
{
115117
ID: "external-kind",
116-
Point: runtimeHookPointBeforeToolCall,
118+
Point: string(hooks.HookPointBeforeToolCall),
117119
Scope: runtimeHookScopeUser,
118120
Kind: kind,
119121
Mode: runtimeHookModeSync,
@@ -144,7 +146,7 @@ func TestRuntimeHooksConfigValidateAllowsCommand(t *testing.T) {
144146
Items: []RuntimeHookItemConfig{
145147
{
146148
ID: "accept-command",
147-
Point: runtimeHookPointAcceptGate,
149+
Point: string(hooks.HookPointAcceptGate),
148150
Scope: runtimeHookScopeUser,
149151
Kind: runtimeHookKindCommand,
150152
Mode: runtimeHookModeSync,
@@ -170,7 +172,7 @@ func TestRuntimeHooksConfigValidateAllowsHTTPObserve(t *testing.T) {
170172
Items: []RuntimeHookItemConfig{
171173
{
172174
ID: "observe-http",
173-
Point: runtimeHookPointBeforeToolCall,
175+
Point: string(hooks.HookPointBeforeToolCall),
174176
Scope: runtimeHookScopeUser,
175177
Kind: runtimeHookKindHTTP,
176178
Params: map[string]any{
@@ -193,7 +195,7 @@ func TestRuntimeHooksConfigValidateRejectsInvalidHTTPObserveConfig(t *testing.T)
193195

194196
base := RuntimeHookItemConfig{
195197
ID: "observe-http",
196-
Point: runtimeHookPointBeforeToolCall,
198+
Point: string(hooks.HookPointBeforeToolCall),
197199
Scope: runtimeHookScopeUser,
198200
Kind: runtimeHookKindHTTP,
199201
Mode: runtimeHookModeObserve,
@@ -281,7 +283,7 @@ func TestRuntimeHooksConfigValidateRejectsDisallowedUserPoint(t *testing.T) {
281283
Items: []RuntimeHookItemConfig{
282284
{
283285
ID: "deny-pre-compact",
284-
Point: runtimeHookPointPreCompact,
286+
Point: string(hooks.HookPointPreCompact),
285287
Scope: runtimeHookScopeUser,
286288
Kind: runtimeHookKindBuiltIn,
287289
Mode: runtimeHookModeSync,
@@ -308,7 +310,7 @@ func TestRuntimeHooksConfigItemDefaultsAndClone(t *testing.T) {
308310
Items: []RuntimeHookItemConfig{
309311
{
310312
ID: "warn-bash",
311-
Point: runtimeHookPointBeforeToolCall,
313+
Point: string(hooks.HookPointBeforeToolCall),
312314
Handler: runtimeHookHandlerWarnOnToolCall,
313315
Params: map[string]any{
314316
"tool_name": "bash",
@@ -368,7 +370,7 @@ func TestRuntimeHooksConfigValidateItemFailurePolicy(t *testing.T) {
368370
Items: []RuntimeHookItemConfig{
369371
{
370372
ID: "require-readme",
371-
Point: runtimeHookPointBeforeCompletionDecision,
373+
Point: string(hooks.HookPointBeforeCompletionDecision),
372374
Scope: runtimeHookScopeUser,
373375
Kind: runtimeHookKindBuiltIn,
374376
Mode: runtimeHookModeSync,
@@ -394,7 +396,7 @@ func TestRuntimeHooksConfigValidateWarnOnToolCallRequiresTarget(t *testing.T) {
394396
Items: []RuntimeHookItemConfig{
395397
{
396398
ID: "warn-missing-target",
397-
Point: runtimeHookPointBeforeToolCall,
399+
Point: string(hooks.HookPointBeforeToolCall),
398400
Scope: runtimeHookScopeUser,
399401
Kind: runtimeHookKindBuiltIn,
400402
Mode: runtimeHookModeSync,
@@ -449,8 +451,8 @@ func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
449451
DefaultTimeoutSec: 2,
450452
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
451453
Items: []RuntimeHookItemConfig{
452-
{ID: "dup", Point: runtimeHookPointBeforeToolCall, Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
453-
{ID: " DUP ", Point: runtimeHookPointBeforeToolCall, Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
454+
{ID: "dup", Point: string(hooks.HookPointBeforeToolCall), Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
455+
{ID: " DUP ", Point: string(hooks.HookPointBeforeToolCall), Scope: runtimeHookScopeUser, Kind: runtimeHookKindBuiltIn, Mode: runtimeHookModeSync, Handler: runtimeHookHandlerWarnOnToolCall, TimeoutSec: 1, Params: map[string]any{"tool_name": "bash"}},
454456
},
455457
}
456458
if err := cfg.Validate(); err == nil {
@@ -465,7 +467,7 @@ func TestRuntimeHooksConfigEdgeBranches(t *testing.T) {
465467
}
466468
item := RuntimeHookItemConfig{
467469
ID: "x",
468-
Point: runtimeHookPointBeforeToolCall,
470+
Point: string(hooks.HookPointBeforeToolCall),
469471
Scope: runtimeHookScopeUser,
470472
Kind: runtimeHookKindBuiltIn,
471473
Mode: runtimeHookModeSync,
@@ -554,7 +556,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
554556
} {
555557
item := RuntimeHookItemConfig{
556558
ID: "observe-http",
557-
Point: runtimeHookPointBeforeToolCall,
559+
Point: string(hooks.HookPointBeforeToolCall),
558560
Scope: runtimeHookScopeUser,
559561
Kind: runtimeHookKindHTTP,
560562
Mode: runtimeHookModeObserve,
@@ -580,7 +582,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
580582
name: "invalid absolute url",
581583
item: RuntimeHookItemConfig{
582584
ID: "observe-http",
583-
Point: runtimeHookPointBeforeToolCall,
585+
Point: string(hooks.HookPointBeforeToolCall),
584586
Scope: runtimeHookScopeUser,
585587
Kind: runtimeHookKindHTTP,
586588
Mode: runtimeHookModeObserve,
@@ -593,7 +595,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
593595
name: "headers must be map",
594596
item: RuntimeHookItemConfig{
595597
ID: "observe-http",
596-
Point: runtimeHookPointBeforeToolCall,
598+
Point: string(hooks.HookPointBeforeToolCall),
597599
Scope: runtimeHookScopeUser,
598600
Kind: runtimeHookKindHTTP,
599601
Mode: runtimeHookModeObserve,
@@ -607,7 +609,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
607609
name: "empty header name",
608610
item: RuntimeHookItemConfig{
609611
ID: "observe-http",
610-
Point: runtimeHookPointBeforeToolCall,
612+
Point: string(hooks.HookPointBeforeToolCall),
611613
Scope: runtimeHookScopeUser,
612614
Kind: runtimeHookKindHTTP,
613615
Mode: runtimeHookModeObserve,
@@ -621,7 +623,7 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
621623
name: "empty header value",
622624
item: RuntimeHookItemConfig{
623625
ID: "observe-http",
624-
Point: runtimeHookPointBeforeToolCall,
626+
Point: string(hooks.HookPointBeforeToolCall),
625627
Scope: runtimeHookScopeUser,
626628
Kind: runtimeHookKindHTTP,
627629
Mode: runtimeHookModeObserve,
@@ -663,17 +665,73 @@ func TestRuntimeHTTPObserveValidationHelpers(t *testing.T) {
663665
if got := readRuntimeHookParamString(map[string]any{"x": 123}, "x"); got != "123" {
664666
t.Fatalf("readRuntimeHookParamString(non-string) = %q", got)
665667
}
666-
if !runtimeHookPointUserAllowed(runtimeHookPointBeforeToolCall) {
668+
if !hooks.IsUserAllowed(hooks.HookPointBeforeToolCall) {
667669
t.Fatal("before_tool_call should allow user hooks")
668670
}
669-
for _, point := range []string{
670-
runtimeHookPointBeforePermissionDecision,
671-
runtimeHookPointPreCompact,
672-
runtimeHookPointSubAgentStart,
671+
for _, point := range []hooks.HookPoint{
672+
hooks.HookPointBeforePermissionDecision,
673+
hooks.HookPointPreCompact,
674+
hooks.HookPointSubAgentStart,
673675
} {
674-
if runtimeHookPointUserAllowed(point) {
676+
if hooks.IsUserAllowed(point) {
675677
t.Fatalf("%s should be rejected for user hooks", point)
676678
}
677679
}
678680
})
679681
}
682+
683+
// TestHookPointSingleSourceConsistency 验证 config 侧与 runtime hooks 包的点位定义一致。
684+
// 新增 hook point 时只需修改 runtime hooks 包,config 侧自动接受。
685+
func TestHookPointSingleSourceConsistency(t *testing.T) {
686+
t.Parallel()
687+
688+
// 所有 runtime hooks 包导出的点位都应被 config 接受。
689+
allPoints := hooks.ListHookPoints()
690+
if len(allPoints) == 0 {
691+
t.Fatal("expected at least one hook point from runtime hooks package")
692+
}
693+
694+
base := RuntimeHooksConfig{
695+
Enabled: boolPtr(true),
696+
UserHooksEnabled: boolPtr(true),
697+
DefaultTimeoutSec: 2,
698+
DefaultFailurePolicy: runtimeHookFailurePolicyWarnOnly,
699+
}
700+
701+
for _, point := range allPoints {
702+
point := point
703+
t.Run(string(point), func(t *testing.T) {
704+
t.Parallel()
705+
if !hooks.IsUserAllowed(point) {
706+
// 跳过不允许 user 的点位,它们在 config 校验中会被拒绝。
707+
return
708+
}
709+
cfg := base.Clone()
710+
cfg.Items = []RuntimeHookItemConfig{
711+
{
712+
ID: "test-" + string(point),
713+
Point: string(point),
714+
Scope: runtimeHookScopeUser,
715+
Kind: runtimeHookKindBuiltIn,
716+
Mode: runtimeHookModeSync,
717+
Handler: runtimeHookHandlerAddContextNote,
718+
TimeoutSec: 2,
719+
FailurePolicy: runtimeHookFailurePolicyWarnOnly,
720+
Params: map[string]any{"note": "consistency check"},
721+
},
722+
}
723+
if err := cfg.Validate(); err != nil {
724+
t.Fatalf("config rejected point %q: %v", point, err)
725+
}
726+
})
727+
}
728+
729+
// 验证 accept_gate 在 runtime hooks 包中存在且允许 user。
730+
acceptGateCap, ok := hooks.HookPointCapabilities(hooks.HookPointAcceptGate)
731+
if !ok {
732+
t.Fatal("accept_gate not found in runtime hooks capabilities")
733+
}
734+
if !acceptGateCap.UserAllowed {
735+
t.Fatal("accept_gate should allow user hooks")
736+
}
737+
}

internal/runtime/hooks/types.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package hooks
22

33
import (
44
"context"
5+
"sort"
56
"strings"
67
"time"
78
)
@@ -224,3 +225,30 @@ func HookPointCapabilities(point HookPoint) (HookPointCapability, bool) {
224225
capability, ok := hookPointCapabilities[point]
225226
return capability, ok
226227
}
228+
229+
// ListHookPoints 返回所有已注册的 hook 点位(按字符串排序,保证确定性)。
230+
func ListHookPoints() []HookPoint {
231+
points := make([]HookPoint, 0, len(hookPointCapabilities))
232+
for point := range hookPointCapabilities {
233+
points = append(points, point)
234+
}
235+
sort.Slice(points, func(i, j int) bool {
236+
return points[i] < points[j]
237+
})
238+
return points
239+
}
240+
241+
// IsUserAllowed 返回指定点位是否允许 user scope hook 挂载。
242+
func IsUserAllowed(point HookPoint) bool {
243+
capability, ok := hookPointCapabilities[point]
244+
if !ok {
245+
return false
246+
}
247+
return capability.UserAllowed
248+
}
249+
250+
// IsRepoAllowed 返回指定点位是否允许 repo scope hook 挂载。
251+
// 当前 repo 与 user 共享相同的 allowed 策略。
252+
func IsRepoAllowed(point HookPoint) bool {
253+
return IsUserAllowed(point)
254+
}

0 commit comments

Comments
 (0)