Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions internal/runtime/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,29 @@ type RepositoryContextUnavailablePayload struct {
Reason string `json:"reason"`
}

// HookEventPayload 描述 hook 生命周期事件负载。
type HookEventPayload struct {
HookID string `json:"hook_id"`
Point string `json:"point"`
Scope string `json:"scope"`
Kind string `json:"kind"`
Mode string `json:"mode"`
Status string `json:"status,omitempty"`
StartedAt time.Time `json:"started_at,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
Error string `json:"error,omitempty"`
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@xgopilot 分析hook的生命周期定义放在events.go里是否合适

Copy link
Copy Markdown

@fennoai fennoai Bot Apr 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论

放在 internal/runtime/events.go 总体是合适的,前提是你把它视为「runtime 对外事件契约层」而不是 hook 领域模型层。

理由:

  • events.go 本身就是 runtime 对外统一事件协议(RuntimeEvent + EventType + payload)定义处,hook 生命周期事件在 runtime 总线上对 TUI/gateway 可见,属于该层职责。
  • 真正的 hook 领域定义仍在 internal/runtime/hooks(如 hooks/events.gohooks/types.go),runtime 只是桥接并外发。

边界风险(建议关注)

  1. hooksruntime 存在事件字符串双份定义,漂移风险真实存在。

    • hook 侧:internal/runtime/hooks/events.gohook_started/finished/failed
    • runtime 侧:internal/runtime/events.goEventHookStarted/Finished/Failed
    • 桥接处当前是字符串直转:internal/runtime/hooks_integration.goEventType(strings.TrimSpace(string(event.Type)))
  2. EventHookBlocked 只在 runtime 层定义是合理的。
    这是 runtime 执行语义(before_tool_call 是否 enforced)产生的事件,不是 hooks executor 原生生命周期事件。

建议落地

  • 保持当前放置位置不变(不建议把 runtime 对外事件再下沉回 hooks 包)。
  • 为降低漂移,建议补一个显式映射或守卫测试:
    • 映射:HookEventStarted -> EventHookStarted 等,避免隐式字符串 cast。
    • 测试:断言 hooks 事件全集都能被 runtime 桥接成预期 EventType
  • 若你担心 events.go 体积继续增长,可仅做文件级拆分(如 events_hook.go),包内职责不变。

本次未做

  • 未改代码,仅按你的问题完成职责边界分析。

View job run


// HookBlockedPayload 描述 hook 阻断事件负载。
type HookBlockedPayload struct {
HookID string `json:"hook_id"`
Point string `json:"point"`
ToolCallID string `json:"tool_call_id,omitempty"`
ToolName string `json:"tool_name,omitempty"`
Reason string `json:"reason,omitempty"`
Enforced bool `json:"enforced"`
}

const (
// EventUserMessage 表示用户消息已写入会话。
EventUserMessage EventType = "user_message"
Expand Down Expand Up @@ -302,6 +325,14 @@ const (
EventAssetSaveFailed EventType = "asset_save_failed"
// EventRepositoryContextUnavailable 表示本轮 repository 事实本应获取但失败,已降级为空上下文。
EventRepositoryContextUnavailable EventType = "repository_context_unavailable"
// EventHookStarted 表示 hook 执行开始。
EventHookStarted EventType = "hook_started"
// EventHookFinished 表示 hook 执行结束。
EventHookFinished EventType = "hook_finished"
// EventHookFailed 表示 hook 执行失败。
EventHookFailed EventType = "hook_failed"
// EventHookBlocked 表示某个 hook 返回 block(是否生效由 payload.enforced 决定)。
EventHookBlocked EventType = "hook_blocked"
)

// TokenUsagePayload 承载单轮 token 用量统计。
Expand Down
170 changes: 170 additions & 0 deletions internal/runtime/hooks_integration.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package runtime

import (
"context"
"strings"

runtimehooks "neo-code/internal/runtime/hooks"
)

const (
// hookErrorClassBlocked 标识由 before_tool_call hook 拦截产生的工具错误分类。
hookErrorClassBlocked = "hook_blocked"
)

type hookContextKey string

const hookRuntimeEnvelopeKey hookContextKey = "runtime_hook_envelope"

type hookRuntimeEnvelope struct {
RunID string
SessionID string
Turn int
Phase string
}

// HookExecutor 定义 runtime 调用 hook 的最小执行契约。
type HookExecutor interface {
Run(ctx context.Context, point runtimehooks.HookPoint, input runtimehooks.HookContext) runtimehooks.RunOutput
}

type hookRuntimeEventEmitter struct {
service *Service
}

func newHookRuntimeEventEmitter(service *Service) *hookRuntimeEventEmitter {
return &hookRuntimeEventEmitter{service: service}
}

// EmitHookEvent 将 hooks 包内事件桥接为 runtime 事件,供 TUI 与日志统一消费。
func (e *hookRuntimeEventEmitter) EmitHookEvent(ctx context.Context, event runtimehooks.HookEvent) error {
if e == nil || e.service == nil {
return nil
}
envelope, _ := runtimeHookEnvelopeFromContext(ctx)
kind := EventType(strings.TrimSpace(string(event.Type)))
if kind == "" {
return nil
}
return e.service.emitWithEnvelope(ctx, RuntimeEvent{
Type: kind,
RunID: envelope.RunID,
SessionID: envelope.SessionID,
Turn: envelope.Turn,
Phase: envelope.Phase,
PayloadVersion: 0,
Payload: HookEventPayload{
HookID: event.HookID,
Point: string(event.Point),
Scope: string(event.Scope),
Kind: string(event.Kind),
Mode: string(event.Mode),
Status: string(event.Status),
StartedAt: event.StartedAt,
DurationMS: event.DurationMS,
Error: event.Error,
},
})
}

// runHookPoint 在指定运行态上下文执行一个 hook 点,并自动注入 run/session 元数据。
func (s *Service) runHookPoint(
ctx context.Context,
state *runState,
point runtimehooks.HookPoint,
input runtimehooks.HookContext,
) runtimehooks.RunOutput {
if s == nil || s.hookExecutor == nil {
return runtimehooks.RunOutput{}
}
input.RunID = firstNonBlank(input.RunID, hookRunIDFromState(state))
input.SessionID = firstNonBlank(input.SessionID, hookSessionIDFromState(state))
scopedCtx := withRuntimeHookEnvelope(ctx, hookRuntimeEnvelope{
RunID: hookRunIDFromState(state),
SessionID: hookSessionIDFromState(state),
Turn: hookTurnFromState(state),
Phase: hookPhaseFromState(state),
})
return s.hookExecutor.Run(scopedCtx, point, input)
}

func withRuntimeHookEnvelope(ctx context.Context, envelope hookRuntimeEnvelope) context.Context {
if ctx == nil {
ctx = context.Background()
}
return context.WithValue(ctx, hookRuntimeEnvelopeKey, envelope)
}

func runtimeHookEnvelopeFromContext(ctx context.Context) (hookRuntimeEnvelope, bool) {
if ctx == nil {
return hookRuntimeEnvelope{}, false
}
raw := ctx.Value(hookRuntimeEnvelopeKey)
envelope, ok := raw.(hookRuntimeEnvelope)
return envelope, ok
}

func hookRunIDFromState(state *runState) string {
if state == nil {
return ""
}
return strings.TrimSpace(state.runID)
}

func hookSessionIDFromState(state *runState) string {
if state == nil {
return ""
}
return strings.TrimSpace(state.session.ID)
}

func hookTurnFromState(state *runState) int {
if state == nil {
return turnUnspecified
}
return state.turn
}

func hookPhaseFromState(state *runState) string {
if state == nil {
return ""
}
if state.lifecycle == "" {
return ""
}
return string(state.lifecycle)
}

func firstNonBlank(values ...string) string {
for _, value := range values {
trimmed := strings.TrimSpace(value)
if trimmed != "" {
return trimmed
}
}
return ""
}

func findHookBlockMessage(output runtimehooks.RunOutput) string {
if !output.Blocked {
return ""
}
for _, result := range output.Results {
if !strings.EqualFold(strings.TrimSpace(result.HookID), strings.TrimSpace(output.BlockedBy)) {
continue
}
message := strings.TrimSpace(result.Message)
if message != "" {
return message
}
errText := strings.TrimSpace(result.Error)
if errText != "" {
return errText
}
break
}
if blockedBy := strings.TrimSpace(output.BlockedBy); blockedBy != "" {
return "hook blocked by " + blockedBy
}
return "hook blocked"
}
Loading
Loading