Skip to content

Commit 5c8c3cd

Browse files
authored
Merge pull request #693 from Cai-Tang-www/feat/runtime-event-contract-checker-682
feat(runtime): add event contract registry and CI consistency checker (#682)
2 parents 2401fb4 + c588009 commit 5c8c3cd

3 files changed

Lines changed: 863 additions & 0 deletions

File tree

docs/runtime-hooks-design.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,75 @@ user/repo hook 的 `message` 会进入 runtime 的 annotation buffer(运行态
173173
- `fail_closed` -> `fail_closed`
174174

175175
其中 `warn_only/fail_open` 不阻断主链,仅记录失败;`fail_closed` 触发阻断。
176+
177+
## Runtime 事件契约
178+
179+
runtime 事件在三端之间传递,任一端遗漏不会触发编译错误,仅在运行时表现为"事件丢失"或"未知事件被透传"。契约检查器通过 CI 测试强制三端一致性。
180+
181+
### 事件流转路径
182+
183+
```text
184+
runtime (events.go) → gateway protocol encode → gateway_stream_client decode → TUI update handler consume
185+
```
186+
187+
### 新增 runtime event 三步清单
188+
189+
当新增一个 runtime event 时,必须完成以下三步:
190+
191+
**Step 1:定义事件常量与 payload**
192+
193+
`internal/runtime/events.go`(或 `events_subagent.go`)中添加 `Event*` 常量和对应的 payload 结构体。
194+
195+
```go
196+
// events.go
197+
const EventMyNewEvent EventType = "my_new_event"
198+
199+
type MyNewEventPayload struct {
200+
Field string `json:"field"`
201+
}
202+
```
203+
204+
**Step 2:添加 gateway decode 分支**
205+
206+
`internal/tui/services/gateway_stream_client.go``restoreRuntimePayload` 函数中添加对应的 case 分支:
207+
208+
```go
209+
case EventMyNewEvent:
210+
return decodeRuntimePayload[MyNewEventPayload](payload)
211+
```
212+
213+
同时在 `internal/tui/services/runtime_contract.go` 中:
214+
- 添加 `EventMyNewEvent` 常量定义
215+
-`contractRegistry` 中注册,设置 `RequireConsumer``true`(需要 TUI 消费)或 `false`(透传安全)
216+
217+
```go
218+
// runtime_contract.go
219+
const EventMyNewEvent EventType = "my_new_event"
220+
221+
// contractRegistry 中添加:
222+
EventMyNewEvent: {RequireConsumer: true},
223+
```
224+
225+
**Step 3:添加 TUI 消费者**
226+
227+
`internal/tui/core/app/update.go``runtimeEventHandlerRegistry` 中添加对应 handler:
228+
229+
```go
230+
// update.go - runtimeEventHandlerRegistry 中添加:
231+
tuiservices.EventMyNewEvent: runtimeEventMyNewEventHandler,
232+
```
233+
234+
### CI 契约检查
235+
236+
以下测试用例在 CI 中强制执行事件契约一致性:
237+
238+
- `TestRuntimeEventContractConsistency`:扫描 runtime 事件常量,未注册且不在 `legacyPassthroughEvents` 中的事件会导致 CI 失败
239+
- `TestGatewayDecodeBranchConsistency`:验证 gateway decode 分支中的事件都在 contractRegistry 中注册
240+
- `TestRequireConsumerMustHaveDecodeBranch`:验证 `RequireConsumer=true` 的事件必须有 gateway decode 分支
241+
- `TestRequireConsumerMustHaveTUIConsumer`:验证 `RequireConsumer=true` 的事件必须在 `runtimeEventHandlerRegistry` 中有 handler
242+
243+
若 CI 失败,检查以上三步是否遗漏。
244+
245+
### 遗留透传事件
246+
247+
`legacyPassthroughEvents` 是已知的遗留透传事件允许列表,这些事件在 contractRegistry 建立之前已存在,允许不注册。新增的 runtime Event* 常量必须显式注册到 contractRegistry,否则 CI 失败。

internal/tui/services/runtime_contract.go

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

33
import (
44
"context"
5+
"sort"
56
"time"
67

78
providertypes "neo-code/internal/provider/types"
@@ -724,3 +725,116 @@ const (
724725
EventDecisionMade EventType = "decision_made"
725726
EventTodoSnapshotUpdated EventType = "todo_snapshot_updated"
726727
)
728+
729+
// contractEntry 描述单个事件类型的契约声明。
730+
type contractEntry struct {
731+
RequireConsumer bool
732+
}
733+
734+
// contractRegistry 声明 TUI 侧已知的事件类型及其消费者要求。
735+
// RequireConsumer=true 表示该事件必须有对应的 gateway decode 分支与 TUI 消费者;
736+
// RequireConsumer=false 表示该事件允许透传(passthrough),不要求显式消费。
737+
var contractRegistry = map[EventType]contractEntry{
738+
// --- 已有 decode 分支的事件(RequireConsumer=true)---
739+
EventUserMessage: {RequireConsumer: true},
740+
EventAgentDone: {RequireConsumer: true},
741+
EventToolStart: {RequireConsumer: true},
742+
EventToolResult: {RequireConsumer: true},
743+
EventPermissionRequested: {RequireConsumer: true},
744+
EventPermissionResolved: {RequireConsumer: true},
745+
EventUserQuestionRequested: {RequireConsumer: true},
746+
EventUserQuestionAnswered: {RequireConsumer: true},
747+
EventUserQuestionTimeout: {RequireConsumer: true},
748+
EventUserQuestionSkipped: {RequireConsumer: true},
749+
EventCompactApplied: {RequireConsumer: true},
750+
EventCompactError: {RequireConsumer: true},
751+
EventTokenUsage: {RequireConsumer: true},
752+
EventPhaseChanged: {RequireConsumer: true},
753+
EventStopReasonDecided: {RequireConsumer: true},
754+
EventVerificationStarted: {RequireConsumer: true},
755+
EventVerificationStageFinished: {RequireConsumer: true},
756+
EventVerificationFinished: {RequireConsumer: true},
757+
EventVerificationCompleted: {RequireConsumer: true},
758+
EventVerificationFailed: {RequireConsumer: true},
759+
EventAcceptanceDecided: {RequireConsumer: true},
760+
EventInputNormalized: {RequireConsumer: true},
761+
EventAssetSaved: {RequireConsumer: true},
762+
EventAssetSaveFailed: {RequireConsumer: true},
763+
EventHookStarted: {RequireConsumer: true},
764+
EventHookFinished: {RequireConsumer: true},
765+
EventHookFailed: {RequireConsumer: true},
766+
EventHookBlocked: {RequireConsumer: true},
767+
EventHookNotification: {RequireConsumer: true},
768+
EventRepoHooksDiscovered: {RequireConsumer: true},
769+
EventRepoHooksLoaded: {RequireConsumer: true},
770+
EventRepoHooksSkippedUntrusted: {RequireConsumer: true},
771+
EventRepoHooksTrustStoreInvalid: {RequireConsumer: true},
772+
EventCheckpointCreated: {RequireConsumer: true},
773+
EventCheckpointWarning: {RequireConsumer: true},
774+
EventCheckpointRestored: {RequireConsumer: true},
775+
EventCheckpointUndoRestore: {RequireConsumer: true},
776+
EventToolDiff: {RequireConsumer: true},
777+
EventBashSideEffect: {RequireConsumer: true},
778+
EventTodoUpdated: {RequireConsumer: true},
779+
EventTodoConflict: {RequireConsumer: true},
780+
EventTodoSnapshotUpdated: {RequireConsumer: true},
781+
EventSubAgentStarted: {RequireConsumer: true},
782+
EventSubAgentProgress: {RequireConsumer: true},
783+
EventSubAgentRetried: {RequireConsumer: true},
784+
EventSubAgentBlocked: {RequireConsumer: true},
785+
EventSubAgentCompleted: {RequireConsumer: true},
786+
EventSubAgentFailed: {RequireConsumer: true},
787+
EventSubAgentCanceled: {RequireConsumer: true},
788+
EventSubAgentFinished: {RequireConsumer: true},
789+
EventSubAgentToolCallStarted: {RequireConsumer: true},
790+
EventSubAgentToolCallResult: {RequireConsumer: true},
791+
EventSubAgentToolCallDenied: {RequireConsumer: true},
792+
EventRuntimeSnapshotUpdated: {RequireConsumer: true},
793+
EventSubAgentSnapshotUpdated: {RequireConsumer: true},
794+
EventDecisionMade: {RequireConsumer: true},
795+
796+
// --- 字符串类 payload 事件(有 decode 分支,透传字符串)---
797+
EventAgentChunk: {RequireConsumer: true},
798+
EventToolChunk: {RequireConsumer: true},
799+
EventError: {RequireConsumer: true},
800+
EventToolCallThinking: {RequireConsumer: true},
801+
EventCompactStart: {RequireConsumer: true},
802+
803+
// --- 显式声明为透传安全(passthrough-safe)的事件 ---
804+
// 这些事件在 runtime 侧产生但不要求 TUI 显式消费,
805+
// 未在 gateway decode 中处理时会以原始 payload 透传。
806+
EventRunCanceled: {RequireConsumer: false},
807+
EventSkillActivated: {RequireConsumer: false},
808+
EventSkillDeactivated: {RequireConsumer: false},
809+
EventSkillMissing: {RequireConsumer: false},
810+
EventProgressEvaluated: {RequireConsumer: false},
811+
EventTodoSummaryInjected: {RequireConsumer: false},
812+
}
813+
814+
// RegisteredEventTypes 返回所有已注册的契约事件类型(排序后)。
815+
func RegisteredEventTypes() []EventType {
816+
types := make([]EventType, 0, len(contractRegistry))
817+
for eventType := range contractRegistry {
818+
types = append(types, eventType)
819+
}
820+
sort.Slice(types, func(i, j int) bool {
821+
return types[i] < types[j]
822+
})
823+
return types
824+
}
825+
826+
// RequireConsumer 返回指定事件类型是否要求显式消费者。
827+
// 若事件类型未注册,返回 false(允许透传)。
828+
func RequireConsumer(eventType EventType) bool {
829+
entry, ok := contractRegistry[eventType]
830+
if !ok {
831+
return false
832+
}
833+
return entry.RequireConsumer
834+
}
835+
836+
// IsRegisteredEventType 返回指定事件类型是否已注册到契约中。
837+
func IsRegisteredEventType(eventType EventType) bool {
838+
_, ok := contractRegistry[eventType]
839+
return ok
840+
}

0 commit comments

Comments
 (0)