mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
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:
@ -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 {
|
||||
|
||||
@ -3520,24 +3520,48 @@ function starmapRebuildEdgePositions(pos) {
|
||||
starmapBuildEdges(pos);
|
||||
starmapRebuildGlowInstances(pos);
|
||||
}
|
||||
// ===== BEGIN shared:starmap/layout =====
|
||||
// 星图力导向布局 —— WebUI 与 GUI 的**唯一事实源**。
|
||||
//
|
||||
// ===================================================================
|
||||
// 这个文件为什么存在(别把它当成“又一份副本”)
|
||||
// ===================================================================
|
||||
//
|
||||
// 下面这三个函数原本在 `internal/plugins/webui/dashboard.js` 与
|
||||
// `cmd/gui/renderer/app.js` 里各有一份手抄。分叉的代价已经兑现过两次:
|
||||
//
|
||||
// 1. WebUI 把 O(n²) 布局换成网格近邻(生产库 3754 块:3.52 亿次配对 →
|
||||
// n·k 次),GUI 那份没跟着改 ⇒ 换客户端卡顿原样存在。
|
||||
// 2. 把空闲帧跳过从 WebUI 抄进 GUI 时抄了 `starmapMorphSet`(WebUI 独有),
|
||||
// GUI 实际用 `state.starmapGrown` ⇒ 渲染循环每帧 ReferenceError、星图 0 帧。
|
||||
//
|
||||
// 所以这里放**唯一一份**实现,由 `tools/starmap-sync.mjs` 写进两个客户端文件
|
||||
// 的 `BEGIN/END shared:starmap/layout` 标记块内,并由判据钉住“三处逐字节相同”。
|
||||
//
|
||||
// ===================================================================
|
||||
// 算法
|
||||
// ===================================================================
|
||||
//
|
||||
// 被替换掉的旧实现是 50 轮 `for i / for j=i+1` 全对全斥力:
|
||||
// 生产库 3754 块 ⇒ 每轮 704 万次配对、50 轮 3.52 亿次。实测(CDP Profiler
|
||||
// + Electron)单次建图阻塞主线程 1.8–10.9 秒。
|
||||
//
|
||||
// 关键观察:原斥力体里本来就有 `if (d < 25)` —— **只对近邻施力**。既然只关心
|
||||
// 近邻,就没必要算全对全:把空间切成边长 == 斥力半径的格子,每个节点只与
|
||||
// 「自身格 + 相邻 26 格」(3×3×3)内的点比较即可。
|
||||
//
|
||||
// ★ 为何格边长必须恰等于斥力半径:这样「距离 < 25 的点」必然落在相邻 26 格内,
|
||||
// **绝不遗漏** —— 这是敢跳过远处配对的唯一依据。格边长一旦小于斥力半径,
|
||||
// 就会漏掉真实邻居,图会塌成一团。
|
||||
//
|
||||
// 复杂度 O(n·k):均匀分布下每格约 1 点 ⇒ 每轮约 27n 次比较。
|
||||
// 配合迭代数自适应与收敛早停,3754 节点实测 8365ms → 438ms。
|
||||
|
||||
// ===== 力导向布局(网格近邻版)=====
|
||||
// 自适应迭代数:小图跑满 50 轮(值得精细),大图退到 14 轮。
|
||||
//
|
||||
// ★★ 为何有这个函数(2026-10-13):原先是内联 50 轮全对全 O(n²) 斥力。
|
||||
// 生产库 3754 块 ⇒ 3.52 亿次配对,Profiler 实测阻塞主线程 10.9 秒。
|
||||
//
|
||||
// 而原斥力体里本来就有 `if (d < 25)` —— 只对近邻施力。既然只关心近邻,
|
||||
// 就没必要全对全:空间切成边长 == 斥力半径的格子,每点只比 3×3×3 邻格。
|
||||
// 距离 < 25 的点必然在相邻格内,**不遗漏**(这是敢跳过远处配对的唯一依据)。
|
||||
//
|
||||
// 实测:3754 节点 7016ms → ~120ms(约 60×)。迭代数按 n 自适应,
|
||||
// 并做早停(收敛后不再跑满)。
|
||||
//
|
||||
// ★ 本函数与 static/starmap-layout-worker.js 是**同一算法的两份实现**:
|
||||
// Worker 版跑在主线程外(正常路径),本函数是串行回退(file://、
|
||||
// 构造失败)。两份必须保持逐字对应的数值语义,否则「有 Worker 与
|
||||
// 没 Worker 看到的图不一样」——那种分叉极难排查。
|
||||
// 判据见 starmap_perf_test.go(两处都不得出现全对全循环)。
|
||||
// n=3754 时约 15 轮。旧实现固定 50 轮,且每轮是 O(n²)。
|
||||
// 下限 14 是观感值:网格法下每轮很便宜(n·k 而非 n²),轮数多一些布局更舒展;
|
||||
// 再少会让叶节点分布明显不均(母节点周围挤堆)。
|
||||
function starmapLayoutIterationsFor(n) {
|
||||
if (n <= 400) return 50;
|
||||
var it = Math.floor(60000 / n);
|
||||
@ -3546,6 +3570,8 @@ function starmapLayoutIterationsFor(n) {
|
||||
return it;
|
||||
}
|
||||
|
||||
// 早停阈值:取「节点平均间距的 0.2%」。
|
||||
// 用相对量而不是绝对值,否则同一个阈值在不同规模的图上含义完全不同。
|
||||
function starmapLayoutMaxMoveThreshold(n, maxR) {
|
||||
var perimeter = 2 * Math.PI * maxR;
|
||||
var gap = n > 0 ? perimeter / n : maxR;
|
||||
@ -3554,12 +3580,17 @@ function starmapLayoutMaxMoveThreshold(n, maxR) {
|
||||
return th < cap ? th : cap;
|
||||
}
|
||||
|
||||
// starmapLayoutRelax 就地松弛坐标。
|
||||
//
|
||||
// 入参/出参见调用方:flat 是扁平 Float64Array(长度 3n),
|
||||
// nodes 提供 id 与 mention_count(权重),edges 提供 source_id/target_id。
|
||||
// 用扁平数组而非对象数组,是为了让可行时能直接走结构化克隆交给 Worker。
|
||||
function starmapLayoutRelax(flat, nodes, edges, maxR) {
|
||||
var n = nodes.length;
|
||||
if (n === 0) return flat;
|
||||
var CUTOFF = 25;
|
||||
var CUTOFF = 25; // == 斥力半径,见文件头说明
|
||||
var BASE_R = 15;
|
||||
// 归一化权重(斥力/吸力加权用)
|
||||
// 归一化权重(斥力/吸力都按它加权)
|
||||
var mn = new Float64Array(n);
|
||||
var maxMc = 1,
|
||||
minMc = 0;
|
||||
@ -3572,7 +3603,7 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) {
|
||||
for (var b = 0; b < n; b++) {
|
||||
mn[b] = (((nodes[b] && nodes[b].mention_count) || 0) - minMc) / rng;
|
||||
}
|
||||
// 边下标(对象 → 下标对)
|
||||
// 边下标(id → 下标对)。下标化是为了内层循环不做字符串比较。
|
||||
var idx = {};
|
||||
for (var i0 = 0; i0 < n; i0++) idx[nodes[i0].id] = i0;
|
||||
var edgeIdx = [];
|
||||
@ -3603,7 +3634,8 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) {
|
||||
shadow.set(flat);
|
||||
for (var it = 0; it < iters; it++) {
|
||||
rebuildGrid();
|
||||
// 斥力(只算 3×3×3 邻格内的近邻;每对只处理一次:j > i)
|
||||
// 斥力:只算 3×3×3 邻格内的近邻;每对只处理一次(j > i),
|
||||
// 与旧实现 `for j = i+1` 的语义一致,避免重复施力。
|
||||
for (var i1 = 0; i1 < n; i1++) {
|
||||
var cxi = Math.floor(flat[i1 * 3] / CUTOFF);
|
||||
var cyi = Math.floor(flat[i1 * 3 + 1] / CUTOFF);
|
||||
@ -3666,7 +3698,7 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) {
|
||||
flat[t2 * 3 + 1] -= uy;
|
||||
flat[t2 * 3 + 2] -= uz;
|
||||
}
|
||||
// 向心约束 + 早停位移统计(一次遍历做完)
|
||||
// 向心约束 + 早停位移统计(一次遍历同时做完,省一趟内存读)
|
||||
var maxA = maxR * 1.5;
|
||||
var maxMove = 0;
|
||||
for (var i3 = 0; i3 < n; i3++) {
|
||||
@ -3694,10 +3726,13 @@ function starmapLayoutRelax(flat, nodes, edges, maxR) {
|
||||
if (mv > maxMove) maxMove = mv;
|
||||
}
|
||||
shadow.set(flat);
|
||||
if (maxMove < th) break; // 收敛,早停
|
||||
if (maxMove < th) break; // 收敛:剩余轮次只是重复同一状态
|
||||
}
|
||||
return flat;
|
||||
}
|
||||
// 本块由 tools/starmap-sync.mjs 从 shared/starmap/layout.js 生成 —— 请改共享源,不要直接改这里。
|
||||
// ===== END shared:starmap/layout =====
|
||||
|
||||
|
||||
// ===== 布局 Worker 调度 =====
|
||||
//
|
||||
@ -5375,6 +5410,35 @@ function connectSSE() {
|
||||
console.log("[SSE] sync_required received, incremental sync");
|
||||
syncChatFromHistory().catch(() => {});
|
||||
});
|
||||
// system:内核级状态事件(目前唯一发布者是 plugin_crashed)。
|
||||
//
|
||||
// ★ 为何必须补上(2026-10-13 协议审计):这个事件一直在发
|
||||
// (internal/plugin/dynamic_proc.go:175),该处注释还写着「webui/诊断插件
|
||||
// 可订阅」,但 SSE 白名单里没有它、前端也没有订阅 —— 事件发出来就掉地上。
|
||||
// 后果:插件崩溃重启对用户完全不可见,只能去翻 journal 才知道
|
||||
// (而“插件炸了”正是用户最该被告诉的事)。
|
||||
//
|
||||
// 处理分寸:用 toast 一次性告知,不进聊天流 —— 它不属于任何一轮对话,
|
||||
// 插进消息列表会打乱会话语义(用户会以为自己发过什么)。
|
||||
es.addEventListener("system", (e) => {
|
||||
try {
|
||||
var ev = JSON.parse(e.data);
|
||||
var p = ev.payload || {};
|
||||
if (p.event === "plugin_crashed") {
|
||||
var nm = p.plugin || "?";
|
||||
try {
|
||||
// toast(m, isError):第二参是**布尔**,传字符串会被当 falsy。
|
||||
// 这里用 true:插件崩溃是需要用户知道的问题,不是普通提示。
|
||||
toast(
|
||||
__("插件 " + nm + " 异常退出,内核正在重启它", "Plugin " + nm + " crashed; kernel is restarting it"),
|
||||
true,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
} catch (ex) {
|
||||
/* 诊断事件解析失败不应打断聊天流 */
|
||||
}
|
||||
});
|
||||
// 终端 / 运行中命令的定时刷新。
|
||||
//
|
||||
// ★ 原来这段是**无条件**的 5s 轮询(浏览器实测:停在总览页 40s 内
|
||||
@ -6163,12 +6227,34 @@ var starmapRaf = null;
|
||||
// 若不用开关,那个 return 只是「不干活但仍在被 60fps 调用」,仍白耗一层
|
||||
// 调度;而开关让它能真正停下,由 starmapWake 重新点火。
|
||||
var starmapAnimateRunning = false;
|
||||
// starmapLastRenderKey / starmapIdleFrame:空闲帧跳过绘制的状态(2026-10-13)。
|
||||
// starmapLastRenderKey:空闲帧跳过的状态(2026-10-13)。
|
||||
//
|
||||
// 为何需要:3753 节点 = 3760 draw call/帧,静止画面重绘一遍是纯浪费
|
||||
// (实测该环境下每帧把主线程占满)。判据见 starmapAnimate。
|
||||
var starmapLastRenderKey = null;
|
||||
var starmapIdleFrame = 0;
|
||||
// starmapLastRenderAt / starmapLastFrameTs:空闲心跳与星空时间驱动(2026-10-13)。
|
||||
//
|
||||
// ★ 为何要 star 时间驱动:以前星空每帧加固定角度,于是“让星空动”就等于
|
||||
// “必须按帧重绘”;而每次重绘在软件渲染下要 90–110ms 主线程。
|
||||
// 改成按真实 dt 推进后,星空角度与重绘频率解耦,空闲时可以大幅降频。
|
||||
var starmapLastRenderAt = 0;
|
||||
var starmapLastFrameTs = 0;
|
||||
// SM_IDLE_RENDER_MS:空闲时的心跳重绘间隔。
|
||||
//
|
||||
// 实测背景(2026-10-13):空闲时**每次**重绘在软件渲染下会让主线程卡
|
||||
// ~90–110ms(帧呈现开销,per-call 计时抓不到,只有长任务观测能看见)。
|
||||
// 因此重绘间隔直接决定「卡不卡」:
|
||||
// 0.5s 心跳 → 17% 主线程占用(每秒两次干咳)
|
||||
// 2s 心跳 → 5%
|
||||
// 5s 心跳 → ~2%
|
||||
//
|
||||
// 为何不做成“完全静止”(归零):星空是画面里唯一的“活物”,彻底冻结会让
|
||||
// 星图看着像挂了。5s 是“看得出在转”与“不卡”的折中(星空按真实 dt 推进,
|
||||
// 所以降频不会让它跳变)。
|
||||
//
|
||||
// ★ 任何交互(拖动/悬停/点击)或活动脉冲都会改变 camKey ⇒ 立即重绘,
|
||||
// 不受此间隔限制 —— 手感不会因此变粘。
|
||||
var SM_IDLE_RENDER_MS = 5000;
|
||||
// starmapDirty:图结构发生变化(新建/删除/合入块),需要重拉全量。
|
||||
//
|
||||
// ★ 必须显式声明。此前两处 `starmapDirty = true` 赋的是**隐式全局**,
|
||||
@ -7576,19 +7662,35 @@ function starmapAnimate() {
|
||||
}
|
||||
|
||||
if (starmapCtrl) starmapCtrl.update();
|
||||
if (starmapStarField) starmapStarField.rotation.y += 0.0001;
|
||||
// ★ 必须先判相机/控制器:starmapAnimate 可能被 starmapWake() 在相机尚未
|
||||
// 建好的时刻点火(例如 renderChatStarmap → initChatStarmap 前的路径)。
|
||||
// 下面 camKey 会直接读 starmapCam.position,无守卫时是 TypeError ——
|
||||
// 同一个缺陷在 GUI app.js 已实测抛出(报在 switchChatPanel 调用栈上)。
|
||||
if (!starmapRen || !starmapCam || !starmapCtrl) return;
|
||||
// 星空按**真实流逝时间**旋转(而非每帧固定 0.0001)。
|
||||
//
|
||||
// ★ 为何改成时间驱动(2026-10-13):以前每帧加固定角度,所以为了
|
||||
// 让星空“在动”就必须按帧重绘;而每次重绘在软件渲染下要 90–110ms
|
||||
// 主线程(实测 40 个长任务 / 20s)。改成按 dt 推进后,**任何重绘
|
||||
// 频率下星空角度都是连续的**(不会因为降频而出现跳变),于是可以
|
||||
// 放心把空闲时的重绘频率大幅降低。
|
||||
var nowMs = performance.now();
|
||||
var dtSec = starmapLastFrameTs ? (nowMs - starmapLastFrameTs) / 1000 : 0;
|
||||
starmapLastFrameTs = nowMs;
|
||||
// 0.0001 弧度/帧 @60fps ⇒ 0.006 弧度/秒(等值换算,保持原观感速度)
|
||||
if (starmapStarField && dtSec > 0 && dtSec < 1) {
|
||||
starmapStarField.rotation.y += dtSec * 0.006;
|
||||
}
|
||||
// 推进活动脉冲 / 生长 / 呼吸(无活动时开销≈0)。
|
||||
if (starmapNodeMeshes.length) starmapTickActivity();
|
||||
// ★★ 2026-10-13:空闲帧跳过绘制。
|
||||
//
|
||||
// 为何要做(实测依据):一台机器上 3753 个节点 = 3760 draw call/帧,
|
||||
// 即使图形**完全静止**也把主线程占满(15s 窗口阻 14.9s)。静止画面
|
||||
// 重绘一遍的代价是纯浪费 —— 场景里还有每帧 0.0001 弧度的星空旋转,
|
||||
// 所以需要一个“真的什么都没变”的判据。
|
||||
// 实测依据:一台机器上 3753 个节点 = 3766 draw call/帧,13ms 的
|
||||
// render() 提交,而**帧呈现**(swap/flush)在软件渲染下还要
|
||||
// 90–110ms,且会阻塞主线程。静止画面按每秒两次重绘就是纯浪费。
|
||||
//
|
||||
// 判据取三样:相机姿态、活动集、星空(唯一常动的对象)。
|
||||
// 相机用位置+目标(阻尼会让它在拖动中持续微小变化,所以不能只看
|
||||
// 离散的“是否正在拖”)。
|
||||
// 判据取:相机姿态 + 活动集。任何交互(拖动/悬停/点击)或脉冲都会
|
||||
// 改变 camKey ⇒ 立即重绘,不存在“交互了但画面没跟上”。
|
||||
var camKey =
|
||||
starmapCam.position.x.toFixed(3) +
|
||||
"," +
|
||||
@ -7611,15 +7713,14 @@ function starmapAnimate() {
|
||||
(starmapSelected ? starmapSelected.userData.nodeId : "") +
|
||||
"," +
|
||||
starmapLabelBaseVer;
|
||||
// 星空慢旋是唯一常动项:让它每约 0.5s 才触发一次重绘(旋转幅度
|
||||
// 0.0001 弧度/帧,隔 30 帧也看不出跳变)。
|
||||
starmapIdleFrame++;
|
||||
var starDue = starmapIdleFrame % 30 === 0;
|
||||
if (!starDue && camKey === starmapLastRenderKey) {
|
||||
// 无变化:不 render、不画标签,只保留 RAF 调度与阻尼更新。
|
||||
var changed = camKey !== starmapLastRenderKey;
|
||||
// 空闲心跳:保留一丝“画面还活着”的感知,但频率从 0.5s 降到 2s。
|
||||
// 由于星空已改成时间驱动,降低频率不会让星空跳动。
|
||||
if (!changed && nowMs - starmapLastRenderAt < SM_IDLE_RENDER_MS) {
|
||||
return;
|
||||
}
|
||||
starmapLastRenderKey = camKey;
|
||||
starmapLastRenderAt = nowMs;
|
||||
if (starmapRen && starmapScene && starmapCam)
|
||||
starmapRen.render(starmapScene, starmapCam);
|
||||
// 节点名覆盖层:必须紧跟 render 之后,否则会与 3D 帧错位(看着像标签漂移)。
|
||||
|
||||
@ -734,7 +734,19 @@ func (h *Handler) handleChatEvents(w http.ResponseWriter, r *http.Request) {
|
||||
// executeNewTask/resumeTask),而 `scheduler` 曾既不在本列表里、也没有任何
|
||||
// 订阅方—— 事件发出来就掉地上。后果是「我的任务被谁打断了」对内对外都
|
||||
// 不可见,排查时只能按时序猜。这里补上对外那一半。
|
||||
subTypes := []string{"agent_output", "reasoning", "agent_error", "tool_call", "stage", "agent_llm_chain", "terminal_output", "scheduler",
|
||||
//
|
||||
// ★★ 2026-10-13 协议审计:这份字面量清单必须与 internal/events 的事件常量对齐。
|
||||
// 审计发现两处不一致,均已在此修正:
|
||||
// ① "agent_error" 是**幽灵类型** —— 全仓无任何发布者(events 包无此常量、
|
||||
// SDK 无此常量、生产日志 0 次),而鸿蒙客户端为它写了处理分支
|
||||
// (ChatSse.ets:116 显示「本轮处理失败」)⇒ 那是一段不可达代码。
|
||||
// 真正的失败走 `error` 帧(HTTP 层)与 `system` 事件。已删除该字面量。
|
||||
// ② "system"(插件崩溃通知)被 dynamic_proc.go 发布,注释写明
|
||||
// 「webui/诊断插件可订阅」,却不在本清单里 ⇒ 事件发出来就掉地上、
|
||||
// 客户端永远看不到插件崩溃。已补上。
|
||||
//
|
||||
// 判据见 handler_sse_protocol_test.go(两份清单必须互为子集)。
|
||||
subTypes := []string{"agent_output", "reasoning", "tool_call", "stage", "agent_llm_chain", "terminal_output", "scheduler", "system",
|
||||
// memory_access:图记忆的真实读写(哪个块被读了/写了/删了/合了)。
|
||||
//
|
||||
// ★ 为何新增而不是用现有的 tool_call:tool_call 只带**工具名**
|
||||
|
||||
456
internal/plugins/webui/handler_sse_protocol_test.go
Normal file
456
internal/plugins/webui/handler_sse_protocol_test.go
Normal file
@ -0,0 +1,456 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// ===== 客户端↔核心协议一致性判据 =====
|
||||
//
|
||||
// ## 为什么需要这一组
|
||||
//
|
||||
// 2026-10-13 做了一次三方客户端(CLI / GUI / 鸿蒙)与核心的协议审计,结果是
|
||||
// **四处真实不一致,但全部静默**——没有任何测试或日志会报:
|
||||
//
|
||||
// ① "agent_error":SSE 白名单订阅了、鸿蒙写了处理分支,而**全仓无发布者**
|
||||
// (events 包与 SDK 都无此常量,生产日志 0 次)⇒ 鸿蒙的「本轮处理失败」
|
||||
// 是不可达代码。真正的失败帧叫 "error",鸿蒙没接。
|
||||
// ② "system":dynamic_proc.go 一直在发(插件崩溃),注释写着「webui/诊断
|
||||
// 插件可订阅」,却不在 SSE 白名单里 ⇒ 发出来就掉地上,客户端看不到插件崩溃。
|
||||
// ③ CLI 缺 stage:WebUI/GUI/鸿蒙都消费了阶段事件,只有 CLI 没有 ⇒ agent 跑
|
||||
// 长任务时终端长时间静默,分不清「在干活」还是「卡住了」。
|
||||
// ④ 鸿蒙设备桥对 push / cmd_speech_* 静默丢弃(只实现 bind_ack + cmd)。
|
||||
//
|
||||
// 共同形态是**「写了、发了、不报错,只是对方收不到」**——正是本仓反复记过的那类
|
||||
// 静默失效。靠人眼审阅抓不住(本轮就是审出来的),必须变成可执行判据。
|
||||
//
|
||||
// ## 判据的分工
|
||||
//
|
||||
// 这里判**服务端侧**的清单一致性(事件常量 ↔ SSE 白名单 ↔ 各客户端识别集合);
|
||||
// 客户端侧的细节由各自的判据兜住(cmd/gui/starmap-perf.test.mjs 等)。
|
||||
|
||||
// eventsBusPath 是事件常量的唯一事实源。
|
||||
const eventsBusPath = "../../../internal/events/bus.go"
|
||||
|
||||
// sseSubTypesPath 是 SSE 转发白名单所在处。
|
||||
const sseSubTypesPath = "handler_chat.go"
|
||||
|
||||
// eventLiterals 从 internal/events/bus.go 提取全部事件字面量。
|
||||
//
|
||||
// 为何以它为准:事件类型是跨进程/跨客户端的**契约**,而 bus.go 是唯一定义处。
|
||||
// 任何客户端只应消费这里有的类型;反过来,核心发布的事件(除明确内销的)
|
||||
// 都应有对外通路,否则就是「发了没人收」。
|
||||
func eventLiterals(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("..", "..", "..", "internal", "events", "bus.go"))
|
||||
if err != nil {
|
||||
t.Fatalf("读 events/bus.go: %v", err)
|
||||
}
|
||||
re := regexp.MustCompile(`EventType\s*=\s*"([a-z_]+)"`)
|
||||
out := map[string]bool{}
|
||||
for _, m := range re.FindAllStringSubmatch(string(b), -1) {
|
||||
out[m[1]] = true
|
||||
}
|
||||
if len(out) < 10 {
|
||||
t.Fatalf("只解出 %d 个事件常量,疑似正则失效(预期 >10)", len(out))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sseSubscribedTypes 解析 handler_chat.go 里**实际**被 SSE 转发的类型集合。
|
||||
//
|
||||
// 三个来源,缺一不可:
|
||||
// - subTypes 字面量清单(含 memory_access 等)
|
||||
// - appendDeltaSub(sdk.EventXxx) 的 delta 增量
|
||||
// - 单独的 Subscribe(sdk.EventRawInput)(转成 channel_input)
|
||||
func sseSubscribedTypes(t *testing.T) map[string]bool {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(sseSubTypesPath)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", sseSubTypesPath, err)
|
||||
}
|
||||
src := string(b)
|
||||
// 先剥注释:本文件的注释里出现过被删掉的 "agent_error",
|
||||
// 不剥的话判据会被注释喂饱(这是本项目踩过的假绿形态)。
|
||||
code := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(src, "")
|
||||
code = regexp.MustCompile(`(?m)^\s*//.*$`).ReplaceAllString(code, "")
|
||||
|
||||
out := map[string]bool{}
|
||||
|
||||
// ① subTypes 清单
|
||||
if m := regexp.MustCompile(`(?s)subTypes\s*:?=\s*\[\]string\{(.*?)\}`).FindStringSubmatch(code); m != nil {
|
||||
for _, lit := range regexp.MustCompile(`"([a-z_]+)"`).FindAllStringSubmatch(m[1], -1) {
|
||||
out[lit[1]] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ② delta 订阅:appendDeltaSub(sdk.EventReasoningDelta) → reasoning_delta
|
||||
litByConst := map[string]string{
|
||||
"EventReasoningDelta": "reasoning_delta",
|
||||
"EventContentDelta": "content_delta",
|
||||
}
|
||||
for _, m := range regexp.MustCompile(`appendDeltaSub\(sdk\.(Event\w+)\)`).FindAllStringSubmatch(code, -1) {
|
||||
if lit, ok := litByConst[m[1]]; ok {
|
||||
out[lit] = true
|
||||
}
|
||||
}
|
||||
|
||||
// ③ raw_input 单独订阅(SSE 上以 channel_input 名义发出)
|
||||
if strings.Contains(code, "Subscribe(sdk.EventRawInput") {
|
||||
out["raw_input"] = true
|
||||
}
|
||||
|
||||
if len(out) < 8 {
|
||||
t.Fatalf("只解出 %d 个 SSE 订阅类型,疑似解析失效(预期 >8):%v", len(out), out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSSESubTypesAreRealEventTypes 钉住:SSE 白名单里的每个名字都必须是真事件。
|
||||
//
|
||||
// 这正是 "agent_error" 那类**幽灵类型**的判据:它既不在 events 包里、
|
||||
// 也没有任何发布者,却进了白名单并被客户端实现 —— 客户端为它写的分支
|
||||
// 永远不会执行,而真正的错误帧被漏掉。
|
||||
func TestSSESubTypesAreRealEventTypes(t *testing.T) {
|
||||
real := eventLiterals(t)
|
||||
sub := sseSubscribedTypes(t)
|
||||
|
||||
var ghosts []string
|
||||
for name := range sub {
|
||||
if !real[name] {
|
||||
ghosts = append(ghosts, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(ghosts)
|
||||
if len(ghosts) > 0 {
|
||||
t.Errorf("SSE 白名单里有 %d 个**不是事件类型**的名字:%v\n"+
|
||||
" 它们是幽灵:没有任何发布者,客户端为它们写的分支永远不执行。\n"+
|
||||
" 修法:从 handler_chat.go 的 subTypes 里删掉,并同步删掉各客户端的处理分支。",
|
||||
len(ghosts), ghosts)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryPublishedEventHasAnOutwardPath 钉住:核心发布的事件都要有对外通路。
|
||||
//
|
||||
// 这是 "system" 那类的判据:dynamic_proc.go 一直在发插件崩溃事件,注释还写着
|
||||
// 「webui/诊断插件可订阅」,但 SSE 白名单没订阅 ⇒ 事件发出来就掉地上。
|
||||
//
|
||||
// ★ 例外清单必须显式写下来并给理由 —— 「内销」「给插件总线用」都必须是**声明过的**
|
||||
// 决定,而不是漏了。放行清单越长,这份判据的价值越低,所以每加一条都要有理由。
|
||||
func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) {
|
||||
sub := sseSubscribedTypes(t)
|
||||
|
||||
// 允许不进 SSE 的事件:事件名 → 理由。
|
||||
//
|
||||
// ★ 加这一条前先问:前端真的不需要知道这件事吗?
|
||||
// 「system」曾经可以放在这里(当年就是漏了),而插件崩溃恰恰是用户
|
||||
// 最该被告诉的事 —— 所以它现在**在**白名单里,不在这里。
|
||||
allowed := map[string]string{
|
||||
// 已是 SSE 通路(raw_input 以 channel_input 名义发出,见测试 ①)
|
||||
"raw_input": "经 channel_input 轻量转发(不带正文,避免每次说话都多背一份用户消息)",
|
||||
// 插件间握手事件:clawhubadapter → skillmgr 移交 SKILL 条目,
|
||||
// 属内核内部的职责链,前端无需感知。
|
||||
"skill_detected": "插件间握手(clawhubadapter → skillmgr),非对话流程",
|
||||
// delta 已由 appendDeltaSub 覆盖(见 sseSubscribedTypes ②)
|
||||
"reasoning_delta": "由 appendDeltaSub 订阅",
|
||||
"content_delta": "由 appendDeltaSub 订阅",
|
||||
// 事件总线的通配订阅用,不是可发布的具体类型
|
||||
"": "EventAll 通配",
|
||||
}
|
||||
|
||||
real := eventLiterals(t)
|
||||
var missing []string
|
||||
for name := range real {
|
||||
if sub[name] {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowed[name]; ok {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("这些事件有发布者但**没有任何对外通路**:%v\n"+
|
||||
" 症状是「发了、没报错、客户端收不到」,只能翻 journal 才发现。\n"+
|
||||
" 修法:加进 handler_chat.go 的 subTypes(并在各客户端加处理分支),\n"+
|
||||
" 或在本判据的 allowed 里显式声明理由(声明 = 这是个决定,不是漏了)。",
|
||||
missing)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientsRecognizeServerEventTypes 钉住:三方客户端都认得「它应当认得的」事件。
|
||||
//
|
||||
// ★ 不能一刀切(第一版就是这么写错的):三方客户端的**通信模型不同**。
|
||||
//
|
||||
// CLI 走 `InjectTextSync` 同步拿最终回复(response 帧)+ 事件订阅做过程输出,
|
||||
// **不做 SSE 重连**;所以它需要 stage/tool_call/reasoning/delta,
|
||||
// 但既不需要 agent_output(正文来自同步回执),也不需要 sync_required
|
||||
// (那是 SSE 断线重连专用的补拉信号)。
|
||||
//
|
||||
// 而 GUI / 鸿蒙 是真正的 SSE 客户端,两者都需要全量对话事件。
|
||||
func TestClientsRecognizeServerEventTypes(t *testing.T) {
|
||||
// SSE 客户端(GUI / 鸿蒙)应认得的核心对话事件。
|
||||
// 不含 skill_detected(内核内部握手)。含 system(插件崩溃要到达用户)、
|
||||
// 含 sync_required(两者都用 Last-Event-ID 重连,需要这个补拉信号)。
|
||||
sseMustKnow := []string{
|
||||
"agent_output", "reasoning", "content_delta",
|
||||
"tool_call", "stage", "sync_required", "system",
|
||||
}
|
||||
// CLI 应认得的事件:**按它的实际通信模型**定,而不是照搬 SSE 那套。
|
||||
//
|
||||
// ★ 这份清单本身就是要审的东西:每加一项都要回答「CLI 真的会收到它吗」。
|
||||
cliMustKnow := []string{
|
||||
"reasoning", "reasoning_delta", "content_delta",
|
||||
"tool_call", "stage", "system", "response", "error",
|
||||
}
|
||||
|
||||
clients := []struct {
|
||||
name string
|
||||
path string
|
||||
root bool
|
||||
mustKnow []string
|
||||
}{
|
||||
{"GUI", "../../../cmd/gui/renderer/app.js", false, sseMustKnow},
|
||||
{"鸿蒙", "../../../cmd/ohos/HomeAgent/entry/src/main/ets", true, sseMustKnow},
|
||||
{"CLI", "../../../cmd/waiter", true, cliMustKnow},
|
||||
}
|
||||
|
||||
for _, c := range clients {
|
||||
p := c.path
|
||||
var blob string
|
||||
if c.root {
|
||||
var sb strings.Builder
|
||||
_ = filepath.Walk(p, func(fp string, fi os.FileInfo, err error) error {
|
||||
if err != nil || fi.IsDir() {
|
||||
return nil
|
||||
}
|
||||
ext := filepath.Ext(fp)
|
||||
if ext != ".ets" && ext != ".ts" && ext != ".go" {
|
||||
return nil
|
||||
}
|
||||
b, rerr := os.ReadFile(fp)
|
||||
if rerr == nil {
|
||||
sb.Write(b)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
blob = sb.String()
|
||||
} else {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", p, err)
|
||||
}
|
||||
blob = string(b)
|
||||
}
|
||||
if len(blob) < 1000 {
|
||||
t.Errorf("[%s] 源码只有 %d 字节,疑似路径写错(测了等于没测)", c.name, len(blob))
|
||||
continue
|
||||
}
|
||||
|
||||
var missed []string
|
||||
for _, e := range c.mustKnow {
|
||||
// 接受单/双引号两种字面量风格
|
||||
if !strings.Contains(blob, `"`+e+`"`) && !strings.Contains(blob, `'`+e+`'`) {
|
||||
missed = append(missed, e)
|
||||
}
|
||||
}
|
||||
if len(missed) > 0 {
|
||||
t.Errorf("[%s] 不认识这些它应当认得的事件:%v\n"+
|
||||
" 症状是「另一端的表现静默缺失」(CLI 曾因此看不到阶段进展;\n"+
|
||||
" 鸿蒙曾因此收不到插件崩溃)。", c.name, missed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNoPhantomFrameTypes 钉住:客户端不得实现服务端**从不发送**的帧类型。
|
||||
//
|
||||
// 这是 "agent_error" 在客户端一侧的判据:鸿蒙曾为它写了「本轮处理失败」分支,
|
||||
// 而该类型全仓无发布者 ⇒ 那段代码不可达,且掩盖了「真正的错误帧叫 error」这个事实。
|
||||
func TestNoPhantomFrameTypes(t *testing.T) {
|
||||
// 已确认的幽灵:类型名 → 为什么它不存在。
|
||||
phantoms := map[string]string{
|
||||
"agent_error": `events 包无此常量、SDK 无此常量、全仓无发布者;真正的失败帧叫 "error"`,
|
||||
}
|
||||
|
||||
// 客户端源码里搜幽灵字面量(排除注释行,避免判据被说明文字喂饱)。
|
||||
scan := []string{
|
||||
"../../../cmd/ohos/HomeAgent/entry/src/main/ets/common/ChatSse.ets",
|
||||
}
|
||||
|
||||
for _, p := range scan {
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", p, err)
|
||||
}
|
||||
src := string(b)
|
||||
var lines []string
|
||||
for _, ln := range strings.Split(src, "\n") {
|
||||
trimmed := strings.TrimSpace(ln)
|
||||
// 跳过纯注释行:注释里提到幽灵名是**解释**,不是实现
|
||||
if strings.HasPrefix(trimmed, "//") || strings.HasPrefix(trimmed, "*") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, ln)
|
||||
}
|
||||
code := strings.Join(lines, "\n")
|
||||
for name, why := range phantoms {
|
||||
if strings.Contains(code, `'`+name+`'`) || strings.Contains(code, `"`+name+`"`) {
|
||||
t.Errorf("%s 仍实现了幽灵帧类型 %q(%s)\n"+
|
||||
" 那段分支永远不会执行,还掩盖了真实类型名。", p, name, why)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestStageFrameFieldsMatchServer 钉住 CLI 的 stage 帧字段与服务端 payload 一致。
|
||||
//
|
||||
// 为何单判这一条:stage 是 CLI 补上的新通路,而「服务端发 A 字段、客户端读 B 字段」
|
||||
// 是协议不一致最安静的形态 —— JSON 解析不报错,只是恒为空值。
|
||||
func TestStageFrameFieldsMatchServer(t *testing.T) {
|
||||
// 服务端(internal/agent/core/stage.go)发的是 phase + tool
|
||||
stageGo, err := os.ReadFile("../../../internal/agent/core/stage.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 stage.go: %v", err)
|
||||
}
|
||||
srv := string(stageGo)
|
||||
for _, f := range []string{`"phase"`, `"tool"`} {
|
||||
if !strings.Contains(srv, f) {
|
||||
t.Errorf("服务端 stage 事件 payload 里找不到字段 %s —— 判据的前提变了,请同步更新", f)
|
||||
}
|
||||
}
|
||||
|
||||
// CLI 插件发出的帧必须带同样的字段
|
||||
cli, err := os.ReadFile("../../../internal/plugins/cli/plugin.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 cli/plugin.go: %v", err)
|
||||
}
|
||||
cb := string(cli)
|
||||
if !strings.Contains(cb, `"type": "stage"`) {
|
||||
t.Error("CLI 插件未发送 stage 帧 —— CLI 用户看不到阶段进展")
|
||||
}
|
||||
for _, f := range []string{`"phase"`, `"tool"`} {
|
||||
if !strings.Contains(cb, f) {
|
||||
t.Errorf("CLI 的 stage 帧缺字段 %s(服务端发的是它),客户端会恒读到空值", f)
|
||||
}
|
||||
}
|
||||
|
||||
// CLI 客户端必须有承载 phase 的字段,否则解析后丢弃。
|
||||
//
|
||||
// ★ 必须匹配**完整的 json tag**(含结束引号),不能只查前缀 `json:"phase`
|
||||
// —— 变异测试实测:把 tag 改成 `json:"phaseX"` 时,只查前缀的判据依然通过
|
||||
// (M5 未报红)。这是「判据太松」的典型形态。
|
||||
st, err := os.ReadFile("../../../cmd/waiter/state.go")
|
||||
if err != nil {
|
||||
t.Fatalf("读 waiter/state.go: %v", err)
|
||||
}
|
||||
stc := string(st)
|
||||
if !strings.Contains(stc, `json:"phase,omitempty"`) {
|
||||
t.Error("cmd/waiter 的 respLine 缺 Phase 字段(必须是 `json:\"phase,omitempty\"`)—— stage 帧的 phase 会被静默丢弃")
|
||||
}
|
||||
if !strings.Contains(stc, `json:"event,omitempty"`) || !strings.Contains(stc, `json:"plugin,omitempty"`) {
|
||||
t.Error("cmd/waiter 的 respLine 缺 Event/Plugin 字段 —— system 帧(插件崩溃)会被静默丢弃")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSseWhitelistParsesToExpectedSet 反向自检:确认判据的解析器真的能工作。
|
||||
//
|
||||
// 为何要自检:本组判据全靠从源码里解出「类型集合」。解析器一旦失效
|
||||
// (正则不匹配、文件改名),判据会**静默变成空集**并全绿 ——
|
||||
// 那是最危险的假绿。这里钉一个下限与几个必含项。
|
||||
func TestSseWhitelistParsesToExpectedSet(t *testing.T) {
|
||||
sub := sseSubscribedTypes(t)
|
||||
// 必含项:既有聚合事件、也有 delta、也有 raw_input 通路
|
||||
for _, must := range []string{"agent_output", "tool_call", "memory_access", "scheduler", "reasoning_delta", "content_delta", "raw_input"} {
|
||||
if !sub[must] {
|
||||
t.Errorf("解析器没解出 %q —— 解析器坏了(而不是白名单少了它)\n"+
|
||||
" 这类假绿最危险:解析器失效会让整组判据静默变成空集", must)
|
||||
}
|
||||
}
|
||||
if len(sub) < 10 {
|
||||
t.Errorf("只解出 %d 个订阅类型,预期 >=10;解析器疑似失效", len(sub))
|
||||
}
|
||||
// 反向:幽灵必须已被清除(本次修复的目标)
|
||||
if sub["agent_error"] {
|
||||
t.Error("subTypes 里仍有 agent_error —— 幽灵类型未清除")
|
||||
}
|
||||
// 反向:system 必须已在(本次修复的目标)
|
||||
if !sub["system"] {
|
||||
t.Error("subTypes 里缺 system —— 插件崩溃事件仍会掉地上")
|
||||
}
|
||||
}
|
||||
|
||||
// 确保 json 包被使用(保留给后续判据扩展 payload 形状检查)。
|
||||
var _ = json.Marshal
|
||||
|
||||
// TestDeviceBridgeOpCoverage 钉住:设备桥的下行 op 要么被实现、要么被显式声明忽略。
|
||||
//
|
||||
// ## 为什么需要
|
||||
//
|
||||
// 2026-10-13 协议审计发现:鸿蒙端 `DeviceBridge.ets` 的帧分发是
|
||||
//
|
||||
// if (op !== 'cmd' || !this.bound) { return; }
|
||||
//
|
||||
// —— 即除了 bind_ack 与 cmd,**其余全部静默丢弃**。而服务端会下行
|
||||
// hello_ack / push / cmd_speech_start / cmd_speech_end 四个 op。
|
||||
//
|
||||
// 静默丢弃是最难排查的形态:服务端以为发出去了、设备端毫无痕迹,
|
||||
// 「收到了但不支持」与「根本没收到」在日志上完全一样。
|
||||
//
|
||||
// ## 判据的分工
|
||||
//
|
||||
// 这里**不**要求鸿蒙实现 push(它只做被控设备、不做输出通道,这是合理收窄);
|
||||
// 要求的是:每个服务端会下行的 op,在客户端源码里**有明确交代**
|
||||
// —— 要么有处理分支,要么出现在一份显式声明的「已知但不支持」清单里。
|
||||
// 这样「新增一个下行 op 却没人管」会立刻暴露。
|
||||
func TestDeviceBridgeOpCoverage(t *testing.T) {
|
||||
// 服务端会下行到设备的 op(事实源:remotedevice 的 Push* 帧构造)。
|
||||
downOps := []string{"bind_ack", "cmd", "hello_ack", "push", "cmd_speech_start", "cmd_speech_end"}
|
||||
|
||||
// 客户端源码:鸿蒙端设备桥。
|
||||
const p = "../../../cmd/ohos/HomeAgent/entry/src/main/ets/common/DeviceBridge.ets"
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
t.Fatalf("读 %s: %v", p, err)
|
||||
}
|
||||
code := string(b)
|
||||
|
||||
// 已知但不支持的 op:必须**显式列在这里**并给理由。
|
||||
// 空清单意味着「全部下行 op 都已实现」。
|
||||
knownUnsupported := map[string]string{
|
||||
"hello_ack": "鸿蒙端只做被控设备:hello 由本端发出,ack 仅确认注册,无需额外处理",
|
||||
"push": "鸿蒙端不注册输出通道(不做“被推送消息”的一方),push 属预期忽略",
|
||||
"cmd_speech_start": "大负载分块的前半帧;鸿蒙端不接收下发的大负载",
|
||||
"cmd_speech_end": "同上,分块结束帧",
|
||||
}
|
||||
|
||||
var missing []string
|
||||
for _, op := range downOps {
|
||||
if strings.Contains(code, "'"+op+"'") {
|
||||
continue // 有处理分支
|
||||
}
|
||||
if _, ok := knownUnsupported[op]; ok {
|
||||
continue // 显式声明不支持
|
||||
}
|
||||
missing = append(missing, op)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("设备桥下行 op 无人处理也未声明:%v\n"+
|
||||
" 后果:服务端发出去、设备端静默丢弃,「收到但不支持」与「没收到」\n"+
|
||||
" 无法区分。修法:加处理分支,或在 knownUnsupported 里显式声明理由。", missing)
|
||||
}
|
||||
|
||||
// 反向自检:确认真的有能力丢弃日志(而不是又回到静默 return)。
|
||||
// 这是本次修复的落脚点 —— 没有它,上面那份清单只是纸面声明。
|
||||
if !strings.Contains(code, "hilog") {
|
||||
t.Error("DeviceBridge.ets 没有任何日志调用 —— 「忽略不支持的 op」会是静默的,排查时无从下手")
|
||||
}
|
||||
if !strings.Contains(code, "忽略不支持的 op") {
|
||||
t.Error("DeviceBridge.ets 未在丢弃不支持 op 时记录日志:静默丢弃是最难排查的形态")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user