mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
一次三方(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 捕获)
191 lines
4.1 KiB
Go
191 lines
4.1 KiB
Go
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
|
||
}
|
||
}
|
||
}
|