fix(clients): 三方客户端与内核的 SSE 协议一致性 —— 幽灵类型、发了没人收、客户端漏接

一次三方(CLI/GUI/鸿蒙)与内核的协议审计,发现四处**全部静默**的不一致
(无任何测试或日志会报),本次一并修复并把它们变成可执行判据。

## 四处不一致

1. `agent_error` 是**幽灵类型**:SSE 白名单订阅了它、鸿蒙还写了处理分支
   (显示「本轮处理失败」),但全仓**无任何发布者** —— events 包与 SDK 都
   没有这个常量。于是那段分支永远不可达,而真正的失败帧叫 `error`
   (webui/cli 都发 `{type:"error"}`),鸿蒙从未接住。

2. `system` **发了没人收**:internal/plugin/dynamic_proc.go 一直在发插件崩溃
   (注释还写着「webui/诊断插件可订阅」),却不在 SSE 白名单里 ⇒ 事件发出来
   就掉地上,插件崩溃对用户完全不可见。

3. CLI 缺 `stage`/`system`:CLI 是唯一看不到阶段进展的客户端,agent 跑长任务
   时终端长时间静默,用户分不清「在干活」还是「卡住了」。

4. 鸿蒙设备桥**静默丢弃**:只处理 bind_ack 与 cmd,服务端下行的
   push / cmd_speech_start / cmd_speech_end / hello_ack 直接 return ——
   「收到了但不支持」与「根本没收到」在日志上完全一样,无从排查。

## 修法(注意:三方客户端的通信模型不同,清单不可一刀切)

CLI 走 InjectTextSync **同步拿最终回复**(response 帧)+ 事件订阅做过程输出,
**不做 SSE 重连** ⇒ 它需要 stage/tool_call/reasoning/delta,
但**不需要** agent_output(正文来自同步回执)与 sync_required(那是 SSE
断线重连专用的补拉信号)。GUI/鸿蒙是真 SSE 客户端,两者都需要全量对话事件。
第一版判据按同一份清单要求三者,误报了 CLI —— 那是判据的错,不是代码的错。

- handler_chat.go:subTypes 删 `agent_error`、补 `system`
- dashboard.js / ChatSse.ets:补 `system` 消费(WebUI 用 toast,鸿蒙用 setStage)
- ChatSse.ets:幽灵分支 `agent_error` 改为真实类型 `error`
- cli/plugin.go:补 `stage`(过滤 `_consolidation_`)与 `system` 订阅
- cmd/waiter:补 Phase/Event/Plugin 字段与 stage/system 渲染
- DeviceBridge.ets:静默丢弃改为带 hilog 日志(区分「不支持」与「没收到」)

## 判据(internal/plugins/webui/handler_sse_protocol_test.go,7 条)

TestSSESubTypesAreRealEventTypes(幽灵类型)/
TestEveryPublishedEventHasAnOutwardPath(发了没人收,例外须显式声明理由)/
TestClientsRecognizeServerEventTypes(按客户端分档)/
TestNoPhantomFrameTypes / TestStageFrameFieldsMatchServer /
TestSseWhitelistParsesToExpectedSet(**解析器反向自检** —— 解析器失效会让整组
判据静默变成空集并全绿,是最危险的假绿)/
TestDeviceBridgeOpCoverage。已接入 `make check-client-protocol`(进 make test)。

变异 M1–M8 全部验证。其中 M5 首轮未报红:判据只查前缀 `json:"phase`,
把 tag 改成 `phaseX` 仍匹配 ⇒ 改为匹配**完整 tag** 后才咬住。

## 端到端实测(不只看测试绿)

- 真实 SSE 流收到 stage×15 / content_delta×111 / memory_access×3 /
  agent_llm_chain / tool_call×2,**agent_error 0 次**(幽灵已清)
- kill 一个 plugin.bin → SSE 收到 `event: system`
  `{"event":"plugin_crashed","plugin":"a2a"}`,日志确认「自动重启 a2a」
- WebUI 真弹出 toast「插件 acp 异常退出,内核正在重启它」(MutationObserver 捕获)
This commit is contained in:
JianFeeeee
2026-10-13 16:20:28 +08:00
parent ec035951cc
commit 50ef27891f
8 changed files with 729 additions and 43 deletions

View File

@ -244,10 +244,59 @@ func (p *Plugin) handleChat(w *connWriter, line string, s *sdk.PluginSDK) {
}
w.writeLine(map[string]interface{}{"type": "content_delta", "content": content})
})
// stage:阶段管道(on_input / pre_action / post_action / toolcall / output …)。
//
// ★ 为何要补(2026-10-13 协议审计):CLI 是**唯一**看不到阶段进展的客户端
// —— WebUI / GUI / 鸿蒙 都消费了 stage。后果具象:agent 跑长任务时
// 终端长时间静默,用户分不清“在干活”还是“卡住了”。
//
// 为何单独发一帧而不是当普通文本:stage 是**过程信息**,与最终回复不同
// 层级;客户端已有 [思考]/[工具] 前缀的渲染惯例,这里沿用同一约定。
//
// 过滤 _consolidation_:那是内核内部任务(记忆合并/归档),不属于用户
// 这一轮对话;WebUI 也做了同样的过滤(见 dashboard.js 的 stage 处理)。
unsubStage := s.Subscribe(sdk.EventStage, func(evt *sdk.Event) {
ch, _ := evt.Payload["channel"].(string)
if ch != cliChannel || ch == "_consolidation_" {
return
}
phase, _ := evt.Payload["phase"].(string)
if phase == "" {
return
}
tool, _ := evt.Payload["tool"].(string)
w.writeLine(map[string]interface{}{
"type": "stage",
"phase": phase,
"tool": tool,
})
})
// system:内核级状态事件(目前唯一发布者是 plugin_crashed)。
//
// ★ 为何要补(2026-10-13 协议审计):该事件一直在发(dynamic_proc.go:175),
// 注释还写着「webui/诊断插件可订阅」,但 CLI 完全接不到 —— 插件崩溃
// 对终端用户不可见,只能去翻 journal。
//
// ★ 不过滤 channel:system 是**内核级**事件,不由某条输入通道产生,
// 所以没有 channel 字段可比(这是它与其他事件的区别)。
unsubSystem := s.Subscribe(sdk.EventSystem, func(evt *sdk.Event) {
evtName, _ := evt.Payload["event"].(string)
if evtName == "" {
return
}
plugin, _ := evt.Payload["plugin"].(string)
w.writeLine(map[string]interface{}{
"type": "system",
"event": evtName,
"plugin": plugin,
})
})
defer unsubReasoning()
defer unsubToolCall()
defer unsubReasoningDelta()
defer unsubContentDelta()
defer unsubStage()
defer unsubSystem()
resp := s.InjectTextSync(cliSource, cliChannel, line)
if resp != nil {