Files
HomeAgent/cmd/waiter/state.go
JianFeeeee 50ef27891f 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 捕获)
2026-10-13 16:20:28 +08:00

191 lines
4.1 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"context"
"encoding/json"
"fmt"
"sync"
)
type State struct {
conn Conn
mu sync.Mutex
}
func (s *State) Connect(cfg *Config) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
s.conn.Close()
}
c, err := dial(cfg)
if err != nil {
return err
}
s.conn = c
return nil
}
func (s *State) Disconnect() {
s.mu.Lock()
defer s.mu.Unlock()
if s.conn != nil {
s.conn.Close()
s.conn = nil
}
}
func (s *State) Connected() bool {
s.mu.Lock()
defer s.mu.Unlock()
return s.conn != nil
}
func (s *State) RemoteConn() *remoteConn {
s.mu.Lock()
defer s.mu.Unlock()
if rc, ok := s.conn.(*remoteConn); ok {
return rc
}
return nil
}
func (s *State) Send(line string) error {
s.mu.Lock()
c := s.conn
s.mu.Unlock()
if c == nil {
return fmt.Errorf("not connected")
}
return c.Send(line)
}
func (s *State) SendChat(msg string) (string, error) {
return s.SendChatStream(msg, nil)
}
// SendChatStream 发送一条对话消息并循环读取响应行直至终结帧。
// onEvent 回调在每收到一个过程帧(reasoning/tool_call)时被调用,
// 可为 nil;返回值为最终响应内容或错误。
func (s *State) SendChatStream(msg string, onEvent func(respLine)) (string, error) {
if err := s.Send(msg); err != nil {
return "", err
}
for {
line, err := s.readLine()
if err != nil {
return "", err
}
rl := parseRespLineStruct(line)
switch rl.Type {
case "response":
return rl.Content, nil
case "error":
if rl.Error == "" {
rl.Error = line
}
return "", fmt.Errorf("%s", rl.Error)
default:
// 过程帧:reasoning / tool_call / 旧版服务器的普通文本
if rl.Type == "" && onEvent == nil && rl.Content == "" && rl.Error == "" {
// 非JSON旧行且无回调:直接当最终输出(向后兼容旧服务器)
return line, nil
}
if onEvent != nil {
onEvent(rl)
}
}
}
}
func (s *State) SendBuiltin(cmd string) (string, error) {
if err := s.Send(cmd); err != nil {
return "", err
}
line, err := s.readLine()
if err != nil {
return "", err
}
return parseRespLine(line)
}
func (s *State) readLine() (string, error) {
s.mu.Lock()
c := s.conn
s.mu.Unlock()
if c == nil {
return "", fmt.Errorf("not connected")
}
return c.ReadLine()
}
type respLine struct {
Type string `json:"type"`
Content string `json:"content"`
Error string `json:"error"`
Tool string `json:"tool"`
Status string `json:"status"`
Result string `json:"result"`
// Phase 是 stage 帧的阶段名(on_input / pre_action / post_action /
// toolcall / before_output / after_output …)。
//
// 为何要加(2026-10-13 协议审计):CLI 此前是唯一看不到阶段进展的客户端,
// 服务端补发 stage 帧后,客户端必须有字段承载它。
Phase string `json:"phase,omitempty"`
// Event / Plugin 是 system 帧的字段(目前只有 plugin_crashed)。
//
// 为何要加(2026-10-13 协议审计):system 是内核级状态事件,
// 而 CLI 此前既不订阅它、也没有字段承载 —— 插件崩溃对终端用户不可见。
Event string `json:"event,omitempty"`
Plugin string `json:"plugin,omitempty"`
Reset bool `json:"reset,omitempty"` // delta 帧:服务端轮次作废,清空累积
}
// parseRespLineStruct 解析一行 JSON 响应帧,解析失败时将原文放入 Content。
func parseRespLineStruct(line string) respLine {
var rl respLine
if err := json.Unmarshal([]byte(line), &rl); err != nil {
return respLine{Content: line}
}
return rl
}
func parseRespLine(line string) (string, error) {
rl := parseRespLineStruct(line)
switch rl.Type {
case "response":
return rl.Content, nil
case "error":
return "", fmt.Errorf("%s", rl.Error)
default:
return line, nil
}
}
func (s *State) ReadLoop(ctx context.Context, cb func(string)) {
for {
s.mu.Lock()
c := s.conn
s.mu.Unlock()
if c == nil {
return
}
done := make(chan struct{})
var line string
var readErr error
go func() {
line, readErr = c.ReadLine()
close(done)
}()
select {
case <-done:
if readErr != nil {
return
}
cb(line)
case <-ctx.Done():
return
}
}
}