fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可): - 后端 gateway/proactive.go 只广播 proactive_message 帧、**从不写会话历史** ⇒ 刷新补不回来 - 前端**零消费点** ⇒ 帧被静默丢弃(既无气泡也无通知) 后端:新增 proactive_persist.go(**先落库、再发帧**;web/assistant/mode=chat;未接线时空操作且不谎称已落库) 载荷新增 id/createdAt(落库行权威 id);PushResult.Detail 三态如实分开报告「投递到几个连接」与「是否落库」 前端:handlers/proactive.ts(气泡**恒落** + 按落库 id 去重 + **非前台才补通知** + 拒绝授权/畸形载荷可见降级) + lib/notify/proactive.ts(纯逻辑 isBackground/decideProactiveNotice/有界去重表) + handlers/task.ts(另三个被丢的帧 task_done/automation_result/subagent_result) + ws/resync.ts + useWsResync.ts(**重连重跑本页引导读取**,首次连接不跑) 双份核查(带三层证据):现状不双份 —— 路由分叉「一次 Push 至多一个 sink 成功」,且 IM 路由本来就把主动消息镜像回主会话恰好一次 契约 v0.72;变异实测:只发帧不落库 / 写成 user 角色 / 去重失效 / 前台也弹 / IM 成功再打活跃 ⇒ 全部打红
This commit is contained in:
1 parent
1bf85a955b
commit
84c124f5a9
18 files changed
+1545
-46
No files matched your search
+27
-3
@@ -1,6 +1,6 @@
|
||||
# Cyrene v2 客户端 API 契约
|
||||
|
||||
> **契约版本:`v0.71`**(上一版 `v0.70`,其条目见 §6;`v0.69` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **契约版本:`v0.72`**(上一版 `v0.71`,其条目见 §6;`v0.70` 及更早见本文件 §6,`v0.67` 及更早的基线已归档、只读:`Docs/archive/api-client.v0.65.md`)。
|
||||
> **读者**:Web 前端 / 移动端原生客户端(含 Android)/ IM 桥接等**跨平台客户端开发者**。
|
||||
> **唯一真源 = 当前代码**(`internal/services/gateway/*` 等)。本文档由代码逐条核对得出;与代码不符时**以代码为准**,并回改本文档。
|
||||
> **旧的超详细版已归档、只读**:`Docs/archive/api-client.v0.65.md`(412 KB,含 v0.28→v0.65 全部历史条目)与 `Docs/archive/api-reference.v0.65.md`(170 KB,后端全量字段级文档)。归档的原因是**结构性臃肿 + 历史条目并列 + 长期漂移**;本文档**不复制**那段历史,需要考古时去归档。
|
||||
@@ -12,6 +12,8 @@
|
||||
> **v0.70 是精确化版(已实现,纯增量)**:`GET /api/v1/admin/backthink` 的 `urgency` **默认就是该轮实际生效的精确档位**(随记录落库),另加可选 `urgencyLowerBound:true` 专门标记**旧行的下限推导值**;实时 `backthink` 帧自本版起也带记录里的 `urgency`(§2.2 B8 / §4.10(b),并更正 §4.10(b) 原「实时帧不带这两键」的表述)。**无破坏性变更**(新键可选,老客户端不读即零影响;`urgency` 的既有取值域与 `quietHoursBypassed` 语义都未改)。
|
||||
> **v0.71 是链路图补版(已实现,纯增量)**:新增 1 条管理只读端点 **`GET /api/v1/admin/flow/graph`**,把 `ws.graph_update` 的**最近一轮快照**从「只能靠实时帧拿」变成「打开页面即可只读拉取」(§2.2 B13 / §4.11)。修的是「页面在轮次结束后才打开(或刷新过)⇒ `/#/admin/flow` 与后台思考行的『查看完整链路导图』永远空着」—— `graph_update` **只走实时帧**,前端图桶(`flowGraphs`)**不持久化**,此前**没有任何**补拉通路。**无破坏性变更**(新端点;`graph_update` 帧形状、角色门、发布会话范围**一字未改**)。
|
||||
|
||||
> **v0.72 是主动推送可见性版(已实现,含一处行为变更)**:`proactive_message` 那条消息现在**落进主会话历史**(`web` / `assistant` / `mode:"chat"`,先落库再发帧),帧新增可选 `id`/`createdAt`(主会话历史行的权威 id 与时间戳)供客户端把实时气泡对齐到历史行(§3.4.5 与随后的「落库口径」说明)。修的是主人 2026-10-05 报的「后台思考的主动推送一个都没推出来」—— 活跃客户端路由此前**只广播帧、一行都不写**,刷新即丢且前端零消费点。**两条路由互斥 ⇒ 主会话里恰好一条(不双份)**;`proactive_message` 帧**逐字不变的部分**是既有全部键,客户端不读新键即行为与 v0.71 相同(只会多看到那条历史行)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 基础约定
|
||||
@@ -388,7 +390,7 @@
|
||||
| `graph_update` | — | 实时 flow 图快照(`data.sessionId` 标会话;**无 `turnId`** ⇒ 未按路分桶)。⚠️ **纯实时、不重放**:断开/刷新期间的那一轮不会补发 ⇒ 打开页面时用 `GET /api/v1/admin/flow/graph`(§2.2 B13 / §4.11)拉**最近一轮快照** |
|
||||
| `capacity_awaken` | sessionId | `{fromState,toState,tool}` |
|
||||
| `mood` | (无 sessionId) | `{mood,personaId,affection}`;角色级情绪,仅变化时发 |
|
||||
| `proactive_message` | — | `{platform,channelId,channelType,content,contentType,sender,timestamp,messageId}`(已消毒,绝不含凭据/媒体字节) |
|
||||
| `proactive_message` | — | `{platform,channelId,channelType,content,contentType,sender,timestamp,messageId,id?,createdAt?}`(已消毒,绝不含凭据/媒体字节)。**v0.72**:`id`/`createdAt` 是这条消息在**主会话历史**里的权威行 id 与落库时间戳(同现/同省),落库失败时键缺席 —— 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在) |
|
||||
| `approval_notice` | sessionId | `{actor,tool,op,detail,explain,allowed,at}`(主代理代审小字;`explain` 由后端产出、客户端原样渲染) |
|
||||
| `mode_switch` | — | 上行 `mode_switch` 的回放(透传 `data`) |
|
||||
| `observability_log` | — | `{entries:[{time,level,source,msg}...]}`,上限 200,最新在前 |
|
||||
@@ -397,6 +399,12 @@
|
||||
|
||||
> **observability_* 的订阅**:owner/admin 凭据**读取** `GET /admin/observability/{logs,metrics,trace}` 任一个即订阅;该凭据的 WS 连接全断即退订(无轮询、无常驻 goroutine)。
|
||||
|
||||
> **`proactive_message` 的落库口径(v0.72,行为变更)**:后台思考决定「此刻主动开口」时,这条消息现在是**主会话(`web`)历史里的一条真实 `assistant` 消息** —— 走活跃客户端路由(在线 owner/admin 连接)的那一路此前**只广播帧、一行都不写** ⇒ 刷新/重载后什么都补不回来(既不显示气泡,也补不出历史)。本版 `SendProactive` **先落库、再发帧**:
|
||||
> - 落库行:`sessionId="web"`、`role="assistant"`、`content` = 帧里的 `content`(后端已净化的同一份文本,系统元数据绝不进正文)、`platform` = 帧里的 `platform`、`mode="chat"`、`turn_id` **留空**(主动推送不属于任何一路回合)。因此 `GET /sessions/web/messages`、握手 `history` 回放、`message_persisted` 都能读到它。
|
||||
> - **`id`/`createdAt` 随帧下发**,客户端应据此把实时气泡对齐到历史行(按 id 去重;刷新后由历史行接续,不是第二个气泡)。**落库失败时这两个键缺席**,但同时:帧照发(实时气泡不丢),后端记 WARN 并返回错误 ⇒ 后台思考记录里的 `route/detail` 会写明**未落库**(不会再说成「已推送」)。
|
||||
> - **无在线 owner/admin 连接时也会落库**:推送器如实报告「没有活跃客户端」(`pushed=false`),这条话仍作为一条真实记录留在主会话里,下次打开就能看到。
|
||||
> - **IM 路由不需要客户端做任何事**:`im_bridge` 路由(IM 桥)本就把消息镜像回主会话并广播 `message_persisted`(逐条一条,**不双份**)。两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端,其余先 IM、IM 投递失败才降级活跃客户端),因此同一条主动消息在主会话里**恰好一条**。
|
||||
|
||||
#### 3.4.6 定向帧(不经 `hub.broadcast`,只到指定连接)
|
||||
|
||||
| type | 说明 |
|
||||
@@ -814,7 +822,21 @@
|
||||
> 只保留最近若干版(**v0.60 起**);更早的全部历史见归档 `Docs/archive/api-client.v0.65.md` §5(v0.28 → v0.59)。
|
||||
> 每版一句话 + 三档标注。**三档定义见 [§0.4](#04-三档标注惯例)。**
|
||||
|
||||
### v0.71(当前契约版本)
|
||||
### v0.72(当前契约版本)
|
||||
|
||||
**主动推送可见性 —— 主动消息成为主会话里的一条真实消息,并给帧补上权威行 id**(依据主人 2026-10-05 报「后台思考的主动推送一个都没推出来」;对齐 `design/12 §3` 的主动推送与 `design/16 §4.4` 的镜像口径)。
|
||||
|
||||
- **行为变更**:
|
||||
① **`proactive_message` 那条消息现在会落进主会话历史**(`web`、`role: assistant`、`mode: "chat"`、`turn_id` 留空)。此前后台思考走**活跃客户端路由**时只广播 WS 帧、**一行都不写** ⇒ 刷新/重载后既不显示气泡、也补不出历史;那句 `delivered to 2 active client(s)` 只表示「帧被 2 个连接收下」,**不代表进了会话**。顺序改为**先落库、再发帧**。
|
||||
② **帧新增两个可选键 `id` / `createdAt`**(同现/同省):这条消息在主会话历史里的权威行 id 与落库时间戳。客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后由历史行接续)。**落库失败时两键缺席** —— 客户端不应把「没有 id」当成一条可持久化的消息。
|
||||
③ **没有在线 owner/admin 连接时也落库**:推送器如实回 `pushed=false`(没有活跃客户端),但那句话仍作为真实记录留在主会话里。
|
||||
④ **后台思考记录里的 `PushResult.Detail` 措辞**:活跃客户端路由改为 `delivered to N active client(s), persisted to main session`(未落库时写明 `not persisted…`,无在线客户端但已落库时写明 `no active client, recorded in main session`)—— 面板因此不会把「帧发出去了、刷新就没了」误读成「已推送」。
|
||||
- **纯增量**:帧的形状只在**新增可选键**;落库行的形状就是既有 `historyMessage`(`§4.4`),因此既有读取面(`GET /sessions/web/messages`、握手 `history` 回放、`message_persisted`)**逐字不变**。
|
||||
- **破坏性**:无。老客户端按未知键忽略 `id`/`createdAt` 即可;但它会把主动消息**多看到一次**(历史行)—— 这是本版的目的,不是缺陷。
|
||||
- **不双份(已验证)**:两条路由在一次推送里**互斥**(`web` 提示只走活跃客户端;其余先 IM 桥、IM 投递失败才降级活跃客户端),而 IM 路由本就把消息镜像回主会话 ⇒ 同一条主动消息在主会话里**恰好一条**。契约细节见 §3.4.5 后的「`proactive_message` 的落库口径」。
|
||||
- ⚠️ **诚实边界**:被频率门(`PushGate`)拒绝的推送**不落库**(限流就是「这一条不发」,绝不留下一条它没发过的记录)。
|
||||
|
||||
### v0.71(上一版)
|
||||
|
||||
**链路图补版 —— 新增只读端点 `GET /api/v1/admin/flow/graph`,让「轮次结束后才打开的页面」也能看到最近一轮的链路图**(依据主人 2026-10-05 第二次报「`/#/admin/flow` 停在暂无流程图数据 + 后台思考行提示该会话还没有 graph_update 快照」;对齐 `design/19 §4` 的 **P3**「多轮历史回放」里的最小一步)。
|
||||
|
||||
@@ -994,6 +1016,8 @@
|
||||
### 7.3 诚实边界(既有事实)
|
||||
|
||||
- `subagent_result` **不落库**:父会话转录里没有新消息、**重连回放不补发**。
|
||||
- **`proactive_message`(v0.72 起)落库**:成为主会话 `web` 的一条 `assistant` 行(见 §3.4.5 后的「落库口径」)。**仍未落库的情形**(都是如实、预期的):① 被 `PushGate`(频率门 / 冷却 / 按风格窗口)或安静时段挡下的推送 —— 「这一条不发」就没有记录,**绝不留下一条它没发过的消息**;② 后端未接线历史存储(`HistoryStore: nil`,仅测试与异常部署)时只发帧不写库,进程重启即丢。
|
||||
- **`task_done` / `automation_result` / `subagent_result` 不是会话消息**:它们**不进消息 store、不是气泡**(后端也没有把它们写成 `chat_messages` 行;`subagent_result` 更是**错过即永久丢**)。客户端应把它们落到自己的可观测面(Web 端落在本端 WS 事件回显 + 任务日志)。
|
||||
- `GET /api/v1/files/{name}` **无鉴权**(能力 URL);文档读侧相反、**必须 Bearer**。
|
||||
- `GET /admin/llm/usage` **不按 `session_id`** 过滤/分组(尽管该列已落库)。
|
||||
- `POST /audio/speech` 的请求体 `voice`/`format` **当前不生效**(合成遵循服务端 `VOICE_TTS_VOICE_ID`/`VOICE_TTS_FORMAT`)。
|
||||
|
||||
@@ -6,6 +6,7 @@ import { AdminApp } from "./admin/AdminApp";
|
||||
import { Login } from "./components/Login";
|
||||
import { parseAdminRoute, readAdminEntry, subscribeAdminRoute } from "./admin/router";
|
||||
import { connectWs, disconnectWs } from "./lib/ws";
|
||||
import { useWsResync } from "./lib/useWsResync";
|
||||
import { markSessionOpenedRead } from "./lib/sessionRead";
|
||||
import { initTheme } from "./lib/theme";
|
||||
import {
|
||||
@@ -33,6 +34,12 @@ export default function App(): React.ReactNode {
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const setPersonas = useAppStore((s) => s.setPersonas);
|
||||
const seedMoods = useAppStore((s) => s.seedMoods);
|
||||
// WS 重连后重跑本页的引导读取(切片 P0 的顺带件):握手 history 回放只补**消息**,
|
||||
// 会话列表 / 角色目录 / 消息页这些初始 GET 在断线窗口里的变化没人补,页面会一直
|
||||
// 停在旧态。这里只递增一个世代号,下面的加载副作用把它列进依赖即可(首次连接不触发,
|
||||
// 页面挂载时的初始加载已经做过一遍)。
|
||||
const [resyncNonce, setResyncNonce] = useState(0);
|
||||
useWsResync(() => setResyncNonce((n) => n + 1));
|
||||
|
||||
useEffect(() => {
|
||||
initTheme();
|
||||
@@ -84,7 +91,7 @@ export default function App(): React.ReactNode {
|
||||
cancelled = true;
|
||||
if (retry !== null) window.clearTimeout(retry);
|
||||
};
|
||||
}, [token, setPersonas, seedMoods]);
|
||||
}, [token, setPersonas, seedMoods, resyncNonce]);
|
||||
|
||||
const sessions = useAppStore((s) => s.sessions);
|
||||
const activeSessionId = useAppStore((s) => s.activeSessionId);
|
||||
@@ -116,7 +123,7 @@ export default function App(): React.ReactNode {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [token, setSessions, setActiveSession]);
|
||||
}, [token, setSessions, setActiveSession, resyncNonce]);
|
||||
|
||||
// Whenever the focused session changes (switch, new, or first load) and it is
|
||||
// a session we know about, pull its initial page of history into the panel, then
|
||||
@@ -141,7 +148,7 @@ export default function App(): React.ReactNode {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSessionId, activeSessionKnown, setMessages]);
|
||||
}, [activeSessionId, activeSessionKnown, setMessages, resyncNonce]);
|
||||
|
||||
// The M9 modal UI and the admin subsystem (design/10) are two separate shells.
|
||||
// 面板由安全入口路径进入(见上方 route 注释),每个模块是前缀下的一段路径。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* 主动消息的**浏览器通知**判定(切片 P0「她主动找你」)。
|
||||
*
|
||||
* 本模块是**纯逻辑**:不碰 DOM、不碰 store、不读全局变量,因此可以在没有 jsdom 的
|
||||
* 纯 node 下逐条钉住主人裁定的口径 ——
|
||||
*
|
||||
* 1. **气泡恒落,通知可省**:气泡(消息 store)与前后台无关,永远落;浏览器通知
|
||||
* 只是「人不在看的时候补一发」,是提醒、不是记录。
|
||||
* 2. **只在不在前台时补一发**:判定 = Page Visibility **且** 焦点。只判可见性会漏掉
|
||||
* 「窗口被别的窗口盖住、visibilityState 仍是 visible」这一半 —— 那种时候主人
|
||||
* 根本看不见,正该提醒。
|
||||
* 3. **同一条只打扰一次**:去重表按消息 id 记(含「同一条既走实时帧、又被 IM 镜像
|
||||
* 回主会话」这种同时到达的情形)。气泡**不参与**提醒去重 —— 它是记录,去重由
|
||||
* 消息 store 按 id 负责。
|
||||
* 4. **拒绝授权 ⇒ 如实降级**:没授权就只留气泡,并让调用方留一句可见说明;
|
||||
* **绝不静默丢弃**,也绝不当成故障崩掉。
|
||||
*/
|
||||
|
||||
/** 浏览器通知权限(`Notification.permission` 的三态;运行时不支持该 API 时由调用方
|
||||
* 归到 `"denied"` —— 拿不到通知就是没授权,绝不假装已授权)。 */
|
||||
export type NotifyPermission = "granted" | "denied" | "default";
|
||||
|
||||
/** 一次提醒的判定结果。 */
|
||||
export type NoticeDecision =
|
||||
/** 不在前台且已授权 ⇒ 补一发系统通知。 */
|
||||
| "show"
|
||||
/** 同一条已经提醒过(或已在前台露过面)⇒ 不打扰第二次。 */
|
||||
| "duplicate"
|
||||
/** 页面可见且聚焦 ⇒ 按裁定不弹(气泡已经落了)。 */
|
||||
| "foreground"
|
||||
/** 通知权限未授权/被拒 ⇒ 如实降级为只留气泡。 */
|
||||
| "not-granted";
|
||||
|
||||
/** 「不在前台」的判定:**可见且聚焦**才算前台。`visibility` 用
|
||||
* `document.visibilityState` 的原值("visible" | "hidden" | …),未知取值一律按
|
||||
* 「不在前台」处理 —— 提醒多发一次不可怕,人错过了才可怕。 */
|
||||
export function isBackground(visibility: string, focused: boolean): boolean {
|
||||
return !(visibility === "visible" && focused);
|
||||
}
|
||||
|
||||
/** 决定这一刻要不要为这条主动消息补一发通知。纯函数:同样的输入永远同样的结论。 */
|
||||
export function decideProactiveNotice(input: {
|
||||
/** 同一条消息此前是否已经提醒过(调用方查去重表)。 */
|
||||
seen: boolean;
|
||||
/** `document.visibilityState`。 */
|
||||
visibility: string;
|
||||
/** `document.hasFocus()`。 */
|
||||
focused: boolean;
|
||||
/** 当前通知权限。 */
|
||||
permission: NotifyPermission;
|
||||
}): NoticeDecision {
|
||||
if (input.seen) return "duplicate";
|
||||
if (!isBackground(input.visibility, input.focused)) return "foreground";
|
||||
if (input.permission !== "granted") return "not-granted";
|
||||
return "show";
|
||||
}
|
||||
|
||||
/** 有界的「已提醒」表。它是**本页存活期间的打扰记录**,不是消息台账:超过上限就
|
||||
* 淘汰最旧的一条(一条很老的重复消息至多多提醒一次,绝不会让表无限长)。 */
|
||||
export function createSeenNotices(limit: number): {
|
||||
has(id: string): boolean;
|
||||
add(id: string): void;
|
||||
size(): number;
|
||||
} {
|
||||
const order: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
return {
|
||||
has(id) {
|
||||
return seen.has(id);
|
||||
},
|
||||
add(id) {
|
||||
if (!id || seen.has(id)) return;
|
||||
seen.add(id);
|
||||
order.push(id);
|
||||
while (order.length > limit) {
|
||||
const oldest = order.shift();
|
||||
if (oldest !== undefined) seen.delete(oldest);
|
||||
}
|
||||
},
|
||||
size() {
|
||||
return seen.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { registerResync } from "./ws/resync";
|
||||
|
||||
/**
|
||||
* 把本页的**引导读取**登记为「WS 重连后要重跑一次」的工作(切片 P0 的顺带件)。
|
||||
*
|
||||
* 用法:页面的初始 GET(会话列表 / 消息页 / 角色目录 / 快照补拉 …)抽成一个函数交给
|
||||
* 本 hook 一次即可 —— 重连时它会被重跑,断线窗口里错过的变化因此补得回来;首次连接
|
||||
* **不会**重跑(页面挂载时的初始加载已经做过一遍,见 `ws.ts#handleSocketOpen`)。
|
||||
*
|
||||
* 传进来的函数放在 ref 里,因此每次渲染换闭包都不会反复登记/退订;卸载时干净退订,
|
||||
* 不留悬挂回调(前端审查清单:内存泄漏)。
|
||||
*/
|
||||
export function useWsResync(fn: () => void): void {
|
||||
const ref = useRef(fn);
|
||||
useEffect(() => {
|
||||
ref.current = fn;
|
||||
}, [fn]);
|
||||
useEffect(() => registerResync(() => ref.current()), []);
|
||||
}
|
||||
@@ -13,6 +13,9 @@ import { handlePersonaEvent } from "./ws/handlers/persona";
|
||||
import { handleParallelEvent } from "./ws/handlers/parallel";
|
||||
import { handleDeviceEvent } from "./ws/handlers/device";
|
||||
import { handleApprovalEvent } from "./ws/handlers/approval";
|
||||
import { handleProactiveEvent } from "./ws/handlers/proactive";
|
||||
import { handleTaskEvent } from "./ws/handlers/task";
|
||||
import { notifyResync } from "./ws/resync";
|
||||
|
||||
/**
|
||||
* Realtime client for `/ws/chat`. Reconnects with exponential backoff and
|
||||
@@ -24,6 +27,9 @@ let ws: WebSocket | null = null;
|
||||
let shouldRun = false;
|
||||
let reconnectTimer: number | null = null;
|
||||
let attempt = 0;
|
||||
/** 这条页面生命周期里是否**曾经**打开过连接。用来把「首次连接」与「重连」分开:
|
||||
* 首次连接的引导读取由页面自己的挂载副作用负责,重连才需要补跑(见 handleSocketOpen)。 */
|
||||
let everOpened = false;
|
||||
|
||||
const MAX_BACKOFF = 30_000;
|
||||
const BASE_BACKOFF = 1_000;
|
||||
@@ -101,7 +107,13 @@ export function dispatchFrame(ev: {
|
||||
if (handleThinkingEvent(ev, r, s)) return;
|
||||
if (handleToolEvent(ev, r, s)) return;
|
||||
if (handleMessageEvent(ev, r, s)) return;
|
||||
// 主动推送(切片 P0):气泡恒落 + 非前台补一发通知。此前这一帧**没有消费点**
|
||||
// ⇒ 主人的「后台思考主动推送一个都没推出来」说的就是它。
|
||||
if (handleProactiveEvent(ev, r, s)) return;
|
||||
if (handleAgentEvent(ev, r, s)) return;
|
||||
// 任务/结果帧(切片 P0 顺带件):task_done / automation_result / subagent_result
|
||||
// 后端一直在发,此前全落进未知帧兜底白丢;subagent_result 不落库 ⇒ 错过即永久丢。
|
||||
if (handleTaskEvent(ev, r, s)) return;
|
||||
if (handleSessionEvent(ev, r, s)) return;
|
||||
if (handlePersonaEvent(ev, r, s)) return;
|
||||
// 忙时并行帧组在 device 组之前:`error` 除 `chat_busy` 外一律放行,仍由 device.ts
|
||||
@@ -115,6 +127,26 @@ export function dispatchFrame(ev: {
|
||||
s.addDeviceUpdate(`[${ev.type}] ${text}`);
|
||||
}
|
||||
|
||||
/** 连接建立后的固定动作(从 `openSocket` 抽出来,好让「重连补齐」这条口径能被纯
|
||||
* 逻辑测试直接钉住 —— 项目没有 jsdom,socket 生命周期本身跑不了)。
|
||||
*
|
||||
* 两件事:
|
||||
* ① 每个(重)连接都重发当前模式,让后端的壳与界面一致(既有行为);
|
||||
* ② **重连**(`wasOpenBefore`,即这一页此前打开过)时重跑本页登记的引导读取
|
||||
* (`lib/ws/resync.ts`)—— 握手 `history` 回放只补**消息**,会话列表 / 角色目录 /
|
||||
* 链路图快照这类初始 GET 在断线窗口里的变化没人补,页面会一直停在旧态。
|
||||
* 首次连接**不重跑**:页面挂载时的初始加载已经做过一遍。
|
||||
*
|
||||
* `send` 可注入,默认走真实上行通道(socket 未打开时 `sendUp` 返回 false,不抛)。 */
|
||||
export function handleSocketOpen(
|
||||
wasOpenBefore: boolean,
|
||||
send: (ev: WsUpEvent) => void = sendUp
|
||||
): void {
|
||||
const mode = useModeStore.getState().mode;
|
||||
send({ type: "mode_switch", data: { mode } });
|
||||
if (wasOpenBefore) notifyResync();
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
if (!shouldRun) return;
|
||||
if (reconnectTimer !== null) return;
|
||||
@@ -159,12 +191,12 @@ function openSocket(): void {
|
||||
s.setWsStatus(attempt === 0 ? "connecting" : "reconnecting");
|
||||
|
||||
ws.onopen = () => {
|
||||
const reconnected = everOpened;
|
||||
everOpened = true;
|
||||
opened = true;
|
||||
attempt = 0;
|
||||
useAppStore.getState().setWsStatus("connected");
|
||||
// Sync the current mode on every (re)connect so the agent's shell matches.
|
||||
const mode = useModeStore.getState().mode;
|
||||
sendUp({ type: "mode_switch", data: { mode } });
|
||||
handleSocketOpen(reconnected);
|
||||
};
|
||||
|
||||
ws.onmessage = (msg) => {
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
// 主动推送帧(`proactive_message`)的**消费**测试(切片 P0「她主动找你」;design/12 §3)。
|
||||
//
|
||||
// 为什么需要它:后端一直在发这帧,而前端**零消费点** —— 主人的原话是「后台思考的
|
||||
// 主动推送一个都没推出来」。本测试锁主人裁定的四条口径:
|
||||
// ① **气泡恒落**:帧落进消息 store,且与握手回放 / IM 镜像**按落库行 id 去重**
|
||||
// (同一条绝不渲染两个气泡;刷新后由历史行接续,仍是一个);
|
||||
// ② **后台非前台才补通知**:可见且聚焦不弹;
|
||||
// ③ **拒绝授权 ⇒ 如实降级**:只留气泡 + 一句可见说明,绝不报错崩掉、绝不静默;
|
||||
// ④ **同一条只提醒一次**(含同一条既走实时帧、又被 IM 镜像回传的情形)。
|
||||
//
|
||||
// 项目没有前端测试框架/jsdom,所以这是零依赖的纯 node 检查:它驱动真实的帧解码器
|
||||
// (lib/ws.ts 的 dispatchFrame)与真实的 store,并把浏览器 API(Notification /
|
||||
// document)用桩装上(handler 只在**调用时**解引用它们,因此模块加载期不碰 DOM)。
|
||||
//
|
||||
// 跑法(用 vite 自带的 esbuild,不新增依赖、不改 package.json;banner 补上 store 的
|
||||
// persist 中间件在 node 下探测的 window.localStorage):
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/lib/ws/handlers/proactive.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-proactive.test.mjs \
|
||||
// && node /tmp/cyrene-proactive.test.mjs
|
||||
//
|
||||
// `tsc --noEmit`(npm run build 的一部分)会对本文件做类型检查,因此它只从项目内导入、
|
||||
// 不 import node:*。
|
||||
|
||||
import { dispatchFrame } from "../../ws";
|
||||
import { useAppStore } from "../../../store/useAppStore";
|
||||
import type { ChatMessage } from "../../types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function same<T>(got: T, want: T, what: string): void {
|
||||
ok(
|
||||
JSON.stringify(got) === JSON.stringify(want),
|
||||
`${what} — got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`
|
||||
);
|
||||
}
|
||||
|
||||
const MAIN = "web";
|
||||
|
||||
/** 一条主动消息的线上帧(后端 `hub.SendProactive` 的载荷形状:落库成功时带
|
||||
* `id`/`createdAt`,失败时这两个键缺席)。 */
|
||||
function proactiveFrame(over: Record<string, unknown> = {}): {
|
||||
type: string;
|
||||
data: Record<string, unknown>;
|
||||
} {
|
||||
return {
|
||||
type: "proactive_message",
|
||||
data: {
|
||||
platform: "cyrene-backthink",
|
||||
content: "记得喝水哦",
|
||||
contentType: "text",
|
||||
sender: "Cyrene",
|
||||
timestamp: "2026-10-05T10:00:00Z",
|
||||
id: "row-1",
|
||||
createdAt: 1_760_000_000_000,
|
||||
...over,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function msgs(): ChatMessage[] {
|
||||
return useAppStore.getState().messages;
|
||||
}
|
||||
|
||||
/** 每个场景前把 store 复位成「新连接、无在途路」。 */
|
||||
function reset(activeSessionId = MAIN, messages: ChatMessage[] = []): void {
|
||||
useAppStore.setState({
|
||||
activeSessionId,
|
||||
messages,
|
||||
sessionMessages: {},
|
||||
deviceUpdates: [],
|
||||
turnBindings: {},
|
||||
sessionBusy: false,
|
||||
pendingCount: 0,
|
||||
activeTurns: 0,
|
||||
queueNotice: null,
|
||||
});
|
||||
}
|
||||
|
||||
/** 装一个 `Notification` 桩(并返回它记下的通知),权限由参数给出。 */
|
||||
function installNotification(perm: string): Array<{ title: string; body?: string }> {
|
||||
const shown: Array<{ title: string; body?: string }> = [];
|
||||
class StubNotification {
|
||||
static permission = perm;
|
||||
constructor(title: string, options?: { body?: string }) {
|
||||
shown.push({ title, body: options?.body });
|
||||
}
|
||||
}
|
||||
(globalThis as unknown as { Notification?: unknown }).Notification = StubNotification;
|
||||
return shown;
|
||||
}
|
||||
|
||||
/** 装一个 `document` 桩,只提供本 handler 用到的两个成员。 */
|
||||
function setDocument(visibility: string, focused: boolean): void {
|
||||
(globalThis as unknown as { document?: unknown }).document = {
|
||||
visibilityState: visibility,
|
||||
hasFocus: () => focused,
|
||||
};
|
||||
}
|
||||
|
||||
// ── ① 气泡恒落:帧落进消息 store(主会话),字段取自落库行 ───────────────────────
|
||||
{
|
||||
reset();
|
||||
installNotification("granted");
|
||||
setDocument("visible", true);
|
||||
dispatchFrame(proactiveFrame());
|
||||
same(msgs().length, 1, "主动帧落成一个气泡");
|
||||
const m = msgs()[0];
|
||||
same(m.id, "row-1", "气泡 id = 落库行 id(去重与刷新接续都靠它)");
|
||||
same(m.sessionId, MAIN, "主动消息恒落主会话");
|
||||
same(m.role, "assistant", "角色 = assistant(她单方面开口的话)");
|
||||
same(m.content, "记得喝水哦", "正文逐字来自帧");
|
||||
same(m.createdAt, 1_760_000_000_000, "时间取落库行的时间戳");
|
||||
same(useAppStore.getState().deviceUpdates.length, 0, "已消费帧,不落未知帧兜底");
|
||||
}
|
||||
|
||||
// ── ① 按 id 去重:重复帧 / 与 IM 镜像(message_persisted)撞车都不新增气泡 ─────────
|
||||
{
|
||||
reset();
|
||||
installNotification("granted");
|
||||
setDocument("hidden", false);
|
||||
dispatchFrame(proactiveFrame());
|
||||
same(msgs().length, 1, "第一帧落成一个气泡");
|
||||
dispatchFrame(proactiveFrame());
|
||||
same(msgs().length, 1, "同 id 重复帧不新增(按 id 去重)");
|
||||
// IM 镜像路径:同一条消息被 `message_persisted` 再回传一次(同 id、同正文)。
|
||||
dispatchFrame({
|
||||
type: "message_persisted",
|
||||
sessionId: MAIN,
|
||||
data: {
|
||||
message: {
|
||||
id: "row-1",
|
||||
sessionId: MAIN,
|
||||
role: "assistant",
|
||||
content: "记得喝水哦",
|
||||
createdAt: 1_760_000_000_000,
|
||||
},
|
||||
},
|
||||
});
|
||||
same(msgs().length, 1, "与 IM 镜像同时到达也只保留一个气泡");
|
||||
same(useAppStore.getState().deviceUpdates.length, 0, "两个帧都被消费,没有未知帧兜底");
|
||||
}
|
||||
|
||||
// ── ② 刷新后仍在:清空 store 后由握手回放(同一个 id)接续,而不是多出一条 ────────
|
||||
{
|
||||
reset();
|
||||
installNotification("granted");
|
||||
setDocument("visible", true);
|
||||
dispatchFrame(proactiveFrame());
|
||||
same(msgs().length, 1, "实时帧落成气泡");
|
||||
// 模拟刷新:store 清空(消息不持久化,后端才是真源),随后握手 history 回放同一条行。
|
||||
reset();
|
||||
dispatchFrame({
|
||||
type: "history",
|
||||
sessionId: MAIN,
|
||||
data: {
|
||||
messages: [
|
||||
{
|
||||
id: "row-1",
|
||||
sessionId: MAIN,
|
||||
role: "assistant",
|
||||
content: "记得喝水哦",
|
||||
createdAt: 1_760_000_000_000,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
same(msgs().map((m) => m.id), ["row-1"], "刷新后由历史行接续(这正是「气泡恒落」的根)");
|
||||
}
|
||||
|
||||
// ── ② 通知分流:前台不弹、非前台才弹 ────────────────────────────────────────────
|
||||
{
|
||||
reset();
|
||||
const shown = installNotification("granted");
|
||||
setDocument("visible", true); // 可见且聚焦 = 前台
|
||||
dispatchFrame(proactiveFrame({ id: "row-fg", content: "前台这条" }));
|
||||
same(shown.length, 0, "页面可见且聚焦 ⇒ 不弹通知(气泡已经落了)");
|
||||
same(msgs().length, 1, "前台也照样落气泡(气泡与前后台无关)");
|
||||
|
||||
setDocument("hidden", false); // 非前台
|
||||
dispatchFrame(proactiveFrame({ id: "row-bg", content: "后台这条" }));
|
||||
same(shown.length, 1, "非前台 ⇒ 补一发通知");
|
||||
same(shown[0].body, "后台这条", "通知正文 = 主动消息正文");
|
||||
same(msgs().length, 2, "两条都落了气泡");
|
||||
}
|
||||
|
||||
// ── ② 同一条只打扰一次(重复帧 / 后台重复到达都不再弹) ─────────────────────────
|
||||
{
|
||||
reset();
|
||||
const shown = installNotification("granted");
|
||||
setDocument("hidden", false);
|
||||
dispatchFrame(proactiveFrame({ id: "row-once", content: "只说一次" }));
|
||||
same(shown.length, 1, "第一次非前台 ⇒ 弹一发");
|
||||
dispatchFrame(proactiveFrame({ id: "row-once", content: "只说一次" }));
|
||||
same(shown.length, 1, "同一条再到达 ⇒ 不打扰第二次");
|
||||
same(msgs().length, 1, "而气泡仍然只有一个(按 id 去重)");
|
||||
}
|
||||
|
||||
// ── ③ 拒绝授权 ⇒ 只留气泡 + 一句可见说明,不报错、不静默 ────────────────────────
|
||||
{
|
||||
reset();
|
||||
const shown = installNotification("denied");
|
||||
setDocument("hidden", false);
|
||||
dispatchFrame(proactiveFrame({ id: "row-denied", content: "通知被拒时这条" }));
|
||||
same(shown.length, 0, "没有授权 ⇒ 不弹(绝不假装弹过)");
|
||||
same(msgs().length, 1, "气泡必须仍然落地");
|
||||
const updates = useAppStore.getState().deviceUpdates;
|
||||
same(updates.length, 1, "留一句可见说明(不是静默丢弃)");
|
||||
ok((updates[0] ?? "").includes("通知"), "说明里点明是浏览器通知的问题");
|
||||
// 第二条再被拒时不重复说明(状态只需说明一次,不刷屏)。
|
||||
dispatchFrame(proactiveFrame({ id: "row-denied-2", content: "第二条" }));
|
||||
same(useAppStore.getState().deviceUpdates.length, 1, "同一降级状态只说明一次");
|
||||
same(msgs().length, 2, "第二条气泡照落");
|
||||
}
|
||||
|
||||
// ── ③ 没有通知 API 时同样是「只留气泡」而不是崩掉 ───────────────────────────────
|
||||
{
|
||||
reset();
|
||||
(globalThis as unknown as { Notification?: unknown }).Notification = undefined;
|
||||
setDocument("hidden", false);
|
||||
dispatchFrame(proactiveFrame({ id: "row-no-api", content: "没有通知 API" }));
|
||||
same(msgs().length, 1, "环境没有 Notification 也照样落气泡、不抛错");
|
||||
}
|
||||
|
||||
// ── ④ 畸形载荷不静默:落进既有的本端 WS 事件回显 ─────────────────────────────────
|
||||
{
|
||||
reset();
|
||||
installNotification("granted");
|
||||
setDocument("visible", true);
|
||||
dispatchFrame({ type: "proactive_message", data: { platform: "cyrene-backthink" } });
|
||||
same(msgs().length, 0, "没有正文的载荷不造气泡");
|
||||
same(useAppStore.getState().deviceUpdates.length, 1, "但要在回显里留一行(不静默丢弃)");
|
||||
}
|
||||
|
||||
// ── ④ 落库失败(后端如实省略 id):气泡仍落,本端给一个本地 id ────────────────────
|
||||
{
|
||||
reset();
|
||||
installNotification("granted");
|
||||
setDocument("visible", true);
|
||||
dispatchFrame(proactiveFrame({ id: undefined, createdAt: undefined }));
|
||||
same(msgs().length, 1, "没有落库行 id 时本端仍落气泡");
|
||||
ok(msgs()[0].id !== "", "本端给一个非空 id(渲染与 key 都需要它)");
|
||||
}
|
||||
|
||||
console.log(`proactive_message logic test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,198 @@
|
||||
import type { ChatMessage } from "../../types";
|
||||
import { now, uid } from "../../util";
|
||||
import { toRecord } from "../../wsParse";
|
||||
import { MAIN_SESSION_ID } from "../../../store/sessionCache";
|
||||
import {
|
||||
createSeenNotices,
|
||||
decideProactiveNotice,
|
||||
type NotifyPermission,
|
||||
} from "../../notify/proactive";
|
||||
import { frameText, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
|
||||
/**
|
||||
* 主动推送帧(`proactive_message`,design/12 §3;切片 P0「她主动找你」)。
|
||||
*
|
||||
* 为什么需要它:后端一直在发这帧,前端**零消费点** ⇒ 它落进 `dispatchFrame` 的未知帧
|
||||
* 兜底,界面上既没有气泡、也没有通知。主人报的「后台思考的主动推送一个都没推出来」
|
||||
* 说的正是这个。本组补上两件事:
|
||||
*
|
||||
* ① **气泡恒落**:帧落进消息 store(按 id 去重 —— 与握手回放 / REST 历史拉取按同一个
|
||||
* 落库行 id 对齐,因此「同一条实时到了、刷新又拉回来」仍是一个气泡)。气泡是
|
||||
* **记录**,与页面在不在前台无关。
|
||||
* ② **非前台才补通知**:见 `lib/notify/proactive.ts` 的裁定。前台不弹、同一条只打扰
|
||||
* 一次、未授权就如实降级(只留气泡 + 一句可见说明),**绝不静默丢弃**。
|
||||
*
|
||||
* 载荷畸形时也**不静默**:落到既有的「本端 WS 事件回显」可观测面(与未知帧同一去处)。
|
||||
*
|
||||
* 命中返回 true,未命中返回 false 交给下一组。
|
||||
*/
|
||||
export function handleProactiveEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
if (ev.type !== "proactive_message") return false;
|
||||
const payload = parseProactive(r);
|
||||
if (!payload) {
|
||||
s.addDeviceUpdate(`[proactive_message] 载荷无法解析:${rawText(ev.data)}`);
|
||||
return true;
|
||||
}
|
||||
s.appendOrUpdateMessage(toChatMessage(payload));
|
||||
notifyProactive(payload, s);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 一帧主动消息的可用字段(已收窄)。 */
|
||||
interface ProactiveMessage {
|
||||
/** 主会话历史行的权威 id;后端落库失败时**省略**(本端那时只能造一个本地 id)。 */
|
||||
id: string;
|
||||
content: string;
|
||||
/** 这帧所属的会话:主动消息恒落在主会话(后端就是这么落库的),因此缺省即主会话。 */
|
||||
sessionId: string;
|
||||
platform?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
/** 收窄一帧 `proactive_message` 的载荷。没有正文即不可用(返回 null)—— 气泡里
|
||||
* 没有内容的消息不是消息。 */
|
||||
function parseProactive(r: FramePayload): ProactiveMessage | null {
|
||||
const content = frameText(r?.content);
|
||||
if (!content) return null;
|
||||
const id = frameText(r?.id) ?? "";
|
||||
const createdAt =
|
||||
typeof r?.createdAt === "number" && r.createdAt > 0
|
||||
? r.createdAt
|
||||
: parseTimestamp(r?.timestamp) ?? now();
|
||||
return {
|
||||
id,
|
||||
content,
|
||||
sessionId: MAIN_SESSION_ID,
|
||||
platform: frameText(r?.platform),
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把主动消息投影为 store 里的助手气泡。`id` 用落库行 id(与历史行同源,去重靠它);
|
||||
* 没有(落库失败)时给一个本端 id,气泡照落 —— 后端也说了它没留下记录,刷新不会
|
||||
* 回来,这是如实的一致行为,而不是本端隐瞒。 */
|
||||
function toChatMessage(p: ProactiveMessage): ChatMessage {
|
||||
return {
|
||||
id: p.id || uid(),
|
||||
sessionId: p.sessionId,
|
||||
role: "assistant",
|
||||
content: p.content,
|
||||
platform: p.platform,
|
||||
createdAt: p.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** `timestamp` 是 Go `time.Time` 的 RFC3339 串;解析不出时不编值(返回 undefined,
|
||||
* 由调用方回落本地时钟)。 */
|
||||
function parseTimestamp(v: unknown): number | undefined {
|
||||
if (typeof v !== "string" || !v) return undefined;
|
||||
const t = Date.parse(v);
|
||||
return Number.isNaN(t) ? undefined : t;
|
||||
}
|
||||
|
||||
/** 本页存活期间的「已提醒」表:同一条消息只打扰一次(含同一条既走实时帧、又被 IM
|
||||
* 镜像回主会话这种同时到达的情形)。气泡不参与去重 —— 那是消息 store 按 id 的事。 */
|
||||
const seenNotices = createSeenNotices(200);
|
||||
|
||||
/** 最近一次已经说明过的降级状态:通知没开是**状态**,不是每次推送都要报告一遍的事故。 */
|
||||
let degradeNote: NotifyPermission | null = null;
|
||||
|
||||
/** 按 `lib/notify/proactive.ts` 的裁定决定要不要补一发系统通知,并把「同一条只打扰
|
||||
* 一次」记进本页的去重表。 */
|
||||
function notifyProactive(p: ProactiveMessage, s: StoreState): void {
|
||||
const key = p.id || `${p.createdAt}|${p.content}`;
|
||||
const permission = notifyPermission();
|
||||
const decision = decideProactiveNotice({
|
||||
seen: seenNotices.has(key),
|
||||
visibility: documentVisibility(),
|
||||
focused: documentFocused(),
|
||||
permission,
|
||||
});
|
||||
// 无论结论如何都记下「这条已经露过面」:前台看过、或提醒过,都不该被第二次打扰。
|
||||
seenNotices.add(key);
|
||||
if (decision === "not-granted") {
|
||||
noteDegrade(s, permission);
|
||||
return;
|
||||
}
|
||||
if (decision !== "show") return;
|
||||
if (!showSystemNotification(p.content, key)) {
|
||||
// 构造通知本身失败(浏览器策略/权限收紧等)也算降级:留一句可见说明,
|
||||
// 绝不让它冒泡进帧处理把气泡也带崩。
|
||||
noteDegrade(s, notifyPermission());
|
||||
}
|
||||
}
|
||||
|
||||
/** 通知没开时留一句**可见说明**(同一状态只说一次,不刷屏):让主人知道「不是她没说,
|
||||
* 是浏览器通知没开」,而不是以为主动消息根本没来。 */
|
||||
function noteDegrade(s: StoreState, permission: NotifyPermission): void {
|
||||
if (degradeNote === permission) return;
|
||||
degradeNote = permission;
|
||||
s.addDeviceUpdate(
|
||||
permission === "denied"
|
||||
? "浏览器通知未授权(已拒绝):主动消息只出现在气泡里,可在本站的站点设置里允许通知。"
|
||||
: "浏览器通知尚未授权:主动消息只出现在气泡里,可在本站允许通知。"
|
||||
);
|
||||
}
|
||||
|
||||
/** `Notification` 构造器(不存在时 null)。**调用时**才解引用全局,因此纯 node
|
||||
* 测试可以安全导入本模块(模块加载期不碰 DOM)。 */
|
||||
type NotificationCtor = (new (title: string, options?: { body?: string; tag?: string }) => unknown) & {
|
||||
permission?: string;
|
||||
};
|
||||
|
||||
function notificationCtor(): NotificationCtor | null {
|
||||
const g = globalThis as unknown as { Notification?: NotificationCtor };
|
||||
return typeof g.Notification === "function" ? g.Notification : null;
|
||||
}
|
||||
|
||||
/** 当前通知权限。运行时没有该 API ⇒ 归到 `"denied"`(拿不到通知就是没授权,
|
||||
* 绝不假装已授权)。 */
|
||||
function notifyPermission(): NotifyPermission {
|
||||
const ctor = notificationCtor();
|
||||
if (!ctor) return "denied";
|
||||
if (ctor.permission === "granted") return "granted";
|
||||
if (ctor.permission === "denied") return "denied";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** `document.visibilityState`;取不到时按 `"visible"`(= 不去打扰)。 */
|
||||
function documentVisibility(): string {
|
||||
const g = globalThis as unknown as { document?: { visibilityState?: string } };
|
||||
const v = g.document?.visibilityState;
|
||||
return typeof v === "string" ? v : "visible";
|
||||
}
|
||||
|
||||
/** `document.hasFocus()`;取不到或抛错时按「已聚焦」处理(= 不去打扰)。 */
|
||||
function documentFocused(): boolean {
|
||||
const g = globalThis as unknown as { document?: { hasFocus?: () => boolean } };
|
||||
const hasFocus = g.document?.hasFocus;
|
||||
if (typeof hasFocus !== "function") return true;
|
||||
try {
|
||||
return hasFocus();
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** 弹一发系统通知;失败(含构造抛错)返回 false,由调用方降级为「只留气泡 +
|
||||
* 一句可见说明」。 */
|
||||
function showSystemNotification(body: string, tag: string): boolean {
|
||||
const ctor = notificationCtor();
|
||||
if (!ctor) return false;
|
||||
try {
|
||||
new ctor("昔涟", { body, tag });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 把无法解析的载荷转成一行可读文本(本端事件回显用)。 */
|
||||
function rawText(v: unknown): string {
|
||||
if (typeof v === "string") return v;
|
||||
try {
|
||||
return JSON.stringify(v) ?? String(v);
|
||||
} catch {
|
||||
return String(v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// 任务 / 结果帧的**消费**测试(切片 P0 的顺带件):`task_done` / `automation_result` /
|
||||
// `subagent_result`。
|
||||
//
|
||||
// 为什么需要它:这三个帧后端一直在发(`ws_bridge.go` 的 `eventBridge` 把它们成形为
|
||||
// 下行帧),前端却**零消费点** ⇒ 全部落进未知帧兜底,等于白丢。其中 `subagent_result`
|
||||
// 更严重:它**不落库**(父会话转录里没有新消息、重连回放不补发)⇒ 错过即永久丢。
|
||||
// 本测试锁「至少不静默」:
|
||||
// ① 三条各自落进**本端 WS 事件回显**(`deviceUpdates`),而不是被无声丢掉;
|
||||
// ② 带 id 的结论顺手写进**任务日志**(`taskLogs`,子代理页读它);
|
||||
// ③ 载荷残缺时回显那一行仍然发(「这一批跑完了」这件事不能因为字段缺而消失)。
|
||||
//
|
||||
// 跑法:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/lib/ws/handlers/task.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-task.test.mjs \
|
||||
// && node /tmp/cyrene-task.test.mjs
|
||||
|
||||
import { dispatchFrame } from "../../ws";
|
||||
import { useAppStore } from "../../../store/useAppStore";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
useAppStore.setState({ deviceUpdates: [], taskLogs: {} });
|
||||
}
|
||||
|
||||
function updates(): string[] {
|
||||
return useAppStore.getState().deviceUpdates;
|
||||
}
|
||||
|
||||
// ── task_done:回显一行 + 任务日志按 taskId 收一行 ──────────────────────────────
|
||||
{
|
||||
reset();
|
||||
dispatchFrame({
|
||||
type: "task_done",
|
||||
data: { id: "task-9", kind: "shell", status: "failed", exitCode: 1, error: "boom" },
|
||||
});
|
||||
ok(updates().length === 1, "task_done 落进本端事件回显");
|
||||
ok(updates()[0].includes("task-9") && updates()[0].includes("failed"), "回显点名任务与终态");
|
||||
ok(updates()[0].includes("boom"), "失败原因也在回显里(不吞错误)");
|
||||
const log = useAppStore.getState().taskLogs["task-9"] ?? "";
|
||||
ok(log.includes("task_done"), "任务日志按 taskId 收到一行");
|
||||
}
|
||||
|
||||
// ── automation_result:回显一行(含规则、动作与成败) ───────────────────────────
|
||||
{
|
||||
reset();
|
||||
dispatchFrame({
|
||||
type: "automation_result",
|
||||
data: { kind: "schedule", rule_id: "morning-brief", action: "tts", output: "ok" },
|
||||
});
|
||||
ok(updates().length === 1, "automation_result 落进本端事件回显");
|
||||
ok(updates()[0].includes("morning-brief") && updates()[0].includes("tts"), "回显点名规则与动作");
|
||||
ok(updates()[0].includes("完成"), "成功时说明是完成");
|
||||
|
||||
reset();
|
||||
dispatchFrame({
|
||||
type: "automation_result",
|
||||
data: { kind: "reminder", rule_id: "r-1", action: "notify", error: "channel offline" },
|
||||
});
|
||||
ok(updates()[0].includes("失败") && updates()[0].includes("channel offline"), "失败时如实写明原因");
|
||||
}
|
||||
|
||||
// ── subagent_result:回显一批的摘要 + 逐代理结论进任务日志 ──────────────────────
|
||||
{
|
||||
reset();
|
||||
dispatchFrame({
|
||||
type: "subagent_result",
|
||||
sessionId: "web",
|
||||
data: {
|
||||
sessionId: "web",
|
||||
status: "done",
|
||||
summary: "两路都跑完了",
|
||||
agents: [
|
||||
{ id: "a-1", goal: "查天气", status: "done", summary: "晴", text: "明天晴,25 度" },
|
||||
{ id: "a-2", goal: "查日程", status: "failed", summary: "读不到日历" },
|
||||
],
|
||||
},
|
||||
});
|
||||
ok(updates().length === 1, "subagent_result 落进本端事件回显(不落库的帧更不能静默丢)");
|
||||
ok(updates()[0].includes("2 个") && updates()[0].includes("两路都跑完了"), "回显写明批量与汇总");
|
||||
const a1 = useAppStore.getState().taskLogs["a-1"] ?? "";
|
||||
const a2 = useAppStore.getState().taskLogs["a-2"] ?? "";
|
||||
ok(a1.includes("明天晴,25 度"), "子代理的结论正文进了它自己的任务日志");
|
||||
ok(a2.includes("读不到日历"), "没有 text 时退回 summary(结论不能丢在载荷形状上)");
|
||||
}
|
||||
|
||||
// ── 载荷残缺:回显那一行仍然发(「跑完了」这件事不能因为字段缺而消失) ──────────
|
||||
{
|
||||
reset();
|
||||
dispatchFrame({ type: "subagent_result", data: { status: "done" } });
|
||||
ok(updates().length === 1, "没有 agents 数组时仍然留一行回显");
|
||||
ok(useAppStore.getState().taskLogs[""] === undefined, "不写空 key 的任务日志");
|
||||
}
|
||||
|
||||
console.log(`task result frames logic test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,96 @@
|
||||
import { toRecord } from "../../wsParse";
|
||||
import { frameText, type FrameEvent, type FramePayload, type StoreState } from "./shared";
|
||||
|
||||
/**
|
||||
* 任务 / 结果帧处理组(切片 P0 的顺带件):`task_done`、`automation_result`、
|
||||
* `subagent_result`。
|
||||
*
|
||||
* 为什么需要它:这三个帧后端**一直在发**(`internal/services/gateway/ws_bridge.go`
|
||||
* 的 `eventBridge` 把它们从 bus 主题成形并投递给 owner/admin 连接),前端却零消费点
|
||||
* ⇒ 全部落进未知帧兜底,等于白丢。其中 `subagent_result` 更严重:它**不落库**
|
||||
* (design/05 §9 的诚实边界:父会话转录里没有新消息、重连回放不补发)⇒ 错过即永久丢。
|
||||
*
|
||||
* 本组的取舍是「**至少不静默**」:三条都进既有的本端 WS 事件回显(`deviceUpdates`),
|
||||
* 并把带 id 的结论顺手写进任务日志(`taskLogs`,子代理页读它)。它们不进消息 store
|
||||
* —— 后端没把它们写成会话消息,前端也不该凭空造气泡。
|
||||
*
|
||||
* 命中返回 true,未命中返回 false 交给下一组。
|
||||
*/
|
||||
export function handleTaskEvent(ev: FrameEvent, r: FramePayload, s: StoreState): boolean {
|
||||
switch (ev.type) {
|
||||
case "task_done":
|
||||
return handleTaskDone(r, s);
|
||||
case "automation_result":
|
||||
return handleAutomationResult(r, s);
|
||||
case "subagent_result":
|
||||
return handleSubagentResult(r, s);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** `task_done`:后台任务到达终态(载荷 = 后端 `task.DoneEvent`)。任务日志按
|
||||
* `taskId` 收一行,回显里再留一行摘要 —— 与 `task_output` 的日志口径同源。 */
|
||||
function handleTaskDone(r: FramePayload, s: StoreState): boolean {
|
||||
const id = frameText(r?.id) ?? "";
|
||||
const status = frameText(r?.status) ?? "unknown";
|
||||
const err = frameText(r?.error);
|
||||
const line = `[task_done] 任务 ${id || "(未知)"} 结束:${status}${err ? `(${err})` : ""}`;
|
||||
if (id) s.appendTaskLog(id, `${line}\n`);
|
||||
s.addDeviceUpdate(line);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** `automation_result`:一次自动化/提醒规则跑完(载荷 = 后端 `automations.Result`)。 */
|
||||
function handleAutomationResult(r: FramePayload, s: StoreState): boolean {
|
||||
const kind = frameText(r?.kind) ?? "automation";
|
||||
const rule = frameText(r?.rule_id) ?? frameText(r?.ruleId) ?? "(未知规则)";
|
||||
const action = frameText(r?.action);
|
||||
const err = frameText(r?.error);
|
||||
s.addDeviceUpdate(
|
||||
`[automation_result] ${kind} ${rule}${action ? ` → ${action}` : ""}:${err ? `失败(${err})` : "完成"}`
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 一条子代理结论(载荷 `agents[]` 的可用字段)。 */
|
||||
interface SubagentOutcome {
|
||||
id: string;
|
||||
status: string;
|
||||
summary: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** `subagent_result`:一个后台子代理批次的最终结果(**不落库**,错过即永久丢)。 */
|
||||
function handleSubagentResult(r: FramePayload, s: StoreState): boolean {
|
||||
const sessionId = frameText(r?.sessionId);
|
||||
const summary = frameText(r?.summary) ?? "";
|
||||
const agents = parseSubagentAgents(r?.agents);
|
||||
s.addDeviceUpdate(
|
||||
`[subagent_result] ${sessionId ? `${sessionId} ` : ""}子代理批次完成` +
|
||||
`${agents.length ? `(${agents.length} 个)` : ""}${summary ? `:${summary}` : ""}`
|
||||
);
|
||||
for (const a of agents) {
|
||||
const body = a.text || a.summary;
|
||||
if (a.id && body) s.appendTaskLog(a.id, `[subagent_result] ${a.status}:${body}\n`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 收窄载荷里的 `agents[]`;不是数组或没有可用字段时返回空数组(回显那行仍然发,
|
||||
* 因此「这一批跑完了」这件事不会因为载荷残缺而被静默丢掉)。 */
|
||||
function parseSubagentAgents(v: unknown): SubagentOutcome[] {
|
||||
if (!Array.isArray(v)) return [];
|
||||
const out: SubagentOutcome[] = [];
|
||||
for (const item of v) {
|
||||
const rec = toRecord(item);
|
||||
if (!rec) continue;
|
||||
out.push({
|
||||
id: frameText(rec.id) ?? "",
|
||||
status: frameText(rec.status) ?? "done",
|
||||
summary: frameText(rec.summary) ?? "",
|
||||
text: frameText(rec.text) ?? "",
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
// 「重连后重跑当前页引导读取」的逻辑测试(切片 P0 的顺带件)。
|
||||
//
|
||||
// 为什么需要它:`ws.ts` 的 `onopen` 此前只重发 `mode_switch`,于是断线窗口里
|
||||
// **非消息**的实时状态永久缺失 —— 握手 `history` 回放只补消息,会话列表 / 角色目录 /
|
||||
// 「最近一轮」链路图快照这类**引导读取**在断线期间的变化没人补,页面会一直停在旧态。
|
||||
// 本测试锁三件事:
|
||||
// ① **重连**(曾打开过)会重跑登记项;**首次连接不会**(页面挂载时已经加载过);
|
||||
// ② `mode_switch` 这条既有行为在两种情况都保留;
|
||||
// ③ 单个登记项抛错不影响其它项,也不冒泡(页面某处补拉失败不该拖垮实时通道)。
|
||||
//
|
||||
// 项目没有前端测试框架/jsdom,所以这是零依赖的纯 node 检查:它驱动真实的登记表
|
||||
// (lib/ws/resync.ts)与真实的连接建立动作(lib/ws.ts 的 handleSocketOpen)。
|
||||
//
|
||||
// 跑法:
|
||||
//
|
||||
// cd frontend/web \
|
||||
// && ./node_modules/.bin/esbuild src/lib/ws/resync.test.ts \
|
||||
// --bundle --platform=node --format=esm \
|
||||
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
|
||||
// --outfile=/tmp/cyrene-resync.test.mjs \
|
||||
// && node /tmp/cyrene-resync.test.mjs
|
||||
//
|
||||
// `tsc --noEmit` 会对本文件做类型检查,因此它只从项目内导入、不 import node:*。
|
||||
|
||||
import { handleSocketOpen } from "../ws";
|
||||
import { notifyResync, registerResync, resyncHandlerCount } from "./resync";
|
||||
import type { WsUpEvent } from "../types";
|
||||
|
||||
let checks = 0;
|
||||
|
||||
function ok(cond: boolean, what: string): void {
|
||||
checks += 1;
|
||||
if (!cond) throw new Error(`FAILED: ${what}`);
|
||||
}
|
||||
|
||||
/** 收集一次重连动作发出的所有上行帧。 */
|
||||
function recorder(): { events: WsUpEvent[]; send: (ev: WsUpEvent) => void } {
|
||||
const events: WsUpEvent[] = [];
|
||||
return { events, send: (ev) => events.push(ev) };
|
||||
}
|
||||
|
||||
// ── ① 首次连接:不重跑引导读取(页面挂载时的初始加载已经做过一遍),但模式照发 ────
|
||||
{
|
||||
let ran = 0;
|
||||
const unregister = registerResync(() => {
|
||||
ran += 1;
|
||||
});
|
||||
const { events, send } = recorder();
|
||||
handleSocketOpen(false, send);
|
||||
ok(ran === 0, "首次连接不重跑引导读取");
|
||||
ok(events.length === 1 && events[0].type === "mode_switch", "首次连接仍重发当前模式");
|
||||
unregister();
|
||||
notifyResync();
|
||||
ok(ran === 0, "退订之后不再被调用(组件卸载不留悬挂回调)");
|
||||
}
|
||||
|
||||
// ── ② 重连:重跑登记项,且模式照发 ─────────────────────────────────────────────
|
||||
{
|
||||
let ran = 0;
|
||||
const unregister = registerResync(() => {
|
||||
ran += 1;
|
||||
});
|
||||
const { events, send } = recorder();
|
||||
handleSocketOpen(true, send);
|
||||
ok(ran === 1, "重连重跑一次本页的引导读取");
|
||||
ok(events.length === 1 && events[0].type === "mode_switch", "重连仍重发当前模式");
|
||||
handleSocketOpen(true, send);
|
||||
ok(ran === 2, "每次重连都重跑(不是只补一次)");
|
||||
unregister();
|
||||
}
|
||||
|
||||
// ── ③ 一个登记项抛错不影响其它项,也不冒泡 ────────────────────────────────────
|
||||
{
|
||||
let ok2 = 0;
|
||||
const off1 = registerResync(() => {
|
||||
throw new Error("这一页的补拉失败了");
|
||||
});
|
||||
const off2 = registerResync(() => {
|
||||
ok2 += 1;
|
||||
});
|
||||
let threw = false;
|
||||
try {
|
||||
handleSocketOpen(true, () => {});
|
||||
} catch {
|
||||
threw = true;
|
||||
}
|
||||
ok(!threw, "单个登记项抛错绝不冒泡进 socket 生命周期");
|
||||
ok(ok2 === 1, "其它登记项照常被重跑");
|
||||
off1();
|
||||
off2();
|
||||
ok(resyncHandlerCount() === 0, "全部退订后登记表为空");
|
||||
}
|
||||
|
||||
// ── ④ 登记表幂等:同一个函数登记两次只跑一次(Set 语义) ────────────────────────
|
||||
{
|
||||
let ran = 0;
|
||||
const fn = (): void => {
|
||||
ran += 1;
|
||||
};
|
||||
const off1 = registerResync(fn);
|
||||
const off2 = registerResync(fn);
|
||||
ok(resyncHandlerCount() === 1, "同一个函数只登记一次");
|
||||
notifyResync();
|
||||
ok(ran === 1, "只重跑一次");
|
||||
off1();
|
||||
off2();
|
||||
ok(resyncHandlerCount() === 0, "两次退订都不会残留");
|
||||
}
|
||||
|
||||
console.log(`ws reconnect resync logic test: ${checks} checks passed`);
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 「重连后重跑当前页引导读取」的通用登记表(切片 P0 的顺带件)。
|
||||
*
|
||||
* 为什么需要它:`ws.ts` 的 `onopen` 此前只重发一帧 `mode_switch`,于是**断线窗口**里
|
||||
* 发生的变化没人补 —— 握手 `history` 回放只补**消息**,而会话列表、角色目录、
|
||||
* 「最近一轮」链路图快照这类**引导读取**(页面的初始 GET)只有在重新挂载时才会跑,
|
||||
* 页面会一直停在旧态,直到手动刷新。修掉它只需要一个通用钩子:各页把「本页的引导
|
||||
* 读取」登记一次,重连时统一重跑。
|
||||
*
|
||||
* 本模块刻意**不 import react**(hook 在 `lib/useWsResync.ts`):登记表本身是纯逻辑,
|
||||
* 因此能在无 jsdom 的纯 node 下钉住「重连真的会重跑、首次连接不会白跑一遍」。
|
||||
*/
|
||||
|
||||
/** 一件重连后要重跑的引导读取。 */
|
||||
type ResyncFn = () => void;
|
||||
|
||||
const handlers = new Set<ResyncFn>();
|
||||
|
||||
/** 登记一件重连后要重跑的引导读取,返回退订函数(组件卸载时调用)。 */
|
||||
export function registerResync(fn: ResyncFn): () => void {
|
||||
handlers.add(fn);
|
||||
return () => {
|
||||
handlers.delete(fn);
|
||||
};
|
||||
}
|
||||
|
||||
/** 重跑全部登记项(**只在重连后**调用,见 `ws.ts#handleSocketOpen`)。
|
||||
*
|
||||
* 单个登记项抛错绝不影响其它项,也绝不冒泡进 socket 生命周期 —— 页面里某一处补拉
|
||||
* 失败不该让整条实时通道跟着出问题(它自己的加载/错误态照旧各自负责)。 */
|
||||
export function notifyResync(): void {
|
||||
for (const fn of Array.from(handlers)) {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
/* 一处失败不影响其它页面的重跑 */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 当前登记的项数(供测试与排查「到底有没有人登记」)。 */
|
||||
export function resyncHandlerCount(): number {
|
||||
return handlers.size;
|
||||
}
|
||||
@@ -72,19 +72,21 @@ func (p *mockPusher) count() int { p.mu.Lock(); defer p.mu.Unlock(); return p.ca
|
||||
|
||||
// mockActive 实现 ActiveClient。
|
||||
type mockActive struct {
|
||||
mu sync.Mutex
|
||||
n int
|
||||
err error
|
||||
calls int
|
||||
last adapter.UnifiedMessage
|
||||
mu sync.Mutex
|
||||
n int
|
||||
persisted bool
|
||||
err error
|
||||
calls int
|
||||
last adapter.UnifiedMessage
|
||||
}
|
||||
|
||||
func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
|
||||
func (m *mockActive) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ProactiveDelivery, error) {
|
||||
m.mu.Lock()
|
||||
m.calls++
|
||||
m.last = msg
|
||||
n, persisted, err := m.n, m.persisted, m.err
|
||||
m.mu.Unlock()
|
||||
return m.n, m.err
|
||||
return ProactiveDelivery{Connections: n, Persisted: persisted}, err
|
||||
}
|
||||
|
||||
// mockIMAdapter 实现 IMAdapter。
|
||||
|
||||
@@ -69,13 +69,35 @@ func (p PushResult) String() string {
|
||||
return fmt.Sprintf("pushed=%v route=%s bypass=%v detail=%q", p.Pushed, p.Route, p.Bypassed, p.Detail)
|
||||
}
|
||||
|
||||
// ProactiveDelivery 是一次活跃客户端投递的结果(切片 P0「她主动找你」)。
|
||||
// 它把**两件本就独立**的事分开报告:帧送到了几条连接,以及这条消息有没有
|
||||
// 成为主会话里的一条真实消息。
|
||||
//
|
||||
// 为什么要分开:修复前活跃客户端路由只广播 WS 帧、不落库,于是面板上的
|
||||
// 「delivered to 2 active client(s)」被读成「已推送」,而刷新后什么都没剩
|
||||
// —— 那正是主人报的「后台思考的主动推送一个都没有推出来」。落库与投递各自
|
||||
// 成败,记录里就必须各自可见。
|
||||
type ProactiveDelivery struct {
|
||||
// Connections 是接受该帧的在线 owner/admin 连接数。0 意味着此刻没有
|
||||
// 在线目标;它**不代表消息丢了**——消息可能已经落库(见 Persisted)。
|
||||
Connections int
|
||||
// Persisted 报告该消息是否已成为主会话历史(`history.MainSessionID`)里的
|
||||
// 一条真实消息,因而刷新/重载后仍在。
|
||||
Persisted bool
|
||||
}
|
||||
|
||||
// ActiveClient 把主动消息投递给所有当前活跃的 Web/原生
|
||||
// 客户端,并报告有多少个收到。它是首选的推送目标。
|
||||
// 客户端,并报告投递与落库两件事。它是首选的推送目标。
|
||||
// 生产中由 gateway 的 WebSocket hub 实现;测试注入 mock。
|
||||
type ActiveClient interface {
|
||||
// SendProactive 把 msg 推给每个活跃客户端,并返回
|
||||
// 接受它的客户端数量。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error)
|
||||
// ProactiveDelivery。
|
||||
//
|
||||
// 实现契约(切片 P0):**先落库、再发帧**,且发帧的载荷带上落库行的 id ——
|
||||
// 客户端据此把实时气泡对齐到历史行(按 id 去重、刷新后仍在)。落库失败时
|
||||
// 仍要发出帧(实时气泡不能丢),但必须把错误返回且 Persisted=false,
|
||||
// 让调用方如实记录「发出去了、但没留下记录」。
|
||||
SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (ProactiveDelivery, error)
|
||||
}
|
||||
|
||||
// IMAdapter 是 IM 桥降级:它把主动消息经 IM
|
||||
@@ -246,19 +268,43 @@ func (p *pusher) exportAsync(msg adapter.UnifiedMessage) {
|
||||
}()
|
||||
}
|
||||
|
||||
// pushActive 只投递给活跃 Web/原生客户端。
|
||||
// pushActive 只投递给活跃 Web/原生客户端。它按 ProactiveDelivery 如实报告
|
||||
// 「送到了几条连接」与「有没有落进主会话」(切片 P0):投递数为 0 时消息仍可能
|
||||
// 已经是一条真实记录,两者绝不互相冒充。
|
||||
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.active != nil {
|
||||
n, err := p.active.SendProactive(ctx, msg)
|
||||
if err != nil {
|
||||
p.log.Warn("backthink active client push failed", "error", err)
|
||||
} else if n > 0 {
|
||||
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: fmt.Sprintf("delivered to %d active client(s)", n)}
|
||||
}
|
||||
if p.active == nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||||
}
|
||||
d, err := p.active.SendProactive(ctx, msg)
|
||||
if err != nil {
|
||||
p.log.Warn("backthink active client push failed", "error", err)
|
||||
}
|
||||
if d.Connections > 0 {
|
||||
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: activeDeliveryDetail(d)}
|
||||
}
|
||||
if d.Persisted {
|
||||
// 此刻没有在线客户端,但这条话已经成为主会话里的一条真实记录:打开/
|
||||
// 刷新页面就能看到,只是没有实时帧可发。如实分别报告这两件事,面板才不会
|
||||
// 把「没推出去」与「没留下」混为一谈。
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client, recorded in main session"}
|
||||
}
|
||||
if err != nil {
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client", Err: err.Error()}
|
||||
}
|
||||
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
|
||||
}
|
||||
|
||||
// activeDeliveryDetail 是活跃客户端路由的 Detail 措辞:投递数与「有没有落进
|
||||
// 主会话」两件事实都在里面,且**绝不**只说「已推送」(那正是被误读成「刷新还在」
|
||||
// 的原话)。
|
||||
func activeDeliveryDetail(d ProactiveDelivery) string {
|
||||
state := "not persisted to main session"
|
||||
if d.Persisted {
|
||||
state = "persisted to main session"
|
||||
}
|
||||
return fmt.Sprintf("delivered to %d active client(s), %s", d.Connections, state)
|
||||
}
|
||||
|
||||
// pushIM 只投递给 IM 桥。
|
||||
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
|
||||
if p.im == nil {
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package backthink
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 P0「她主动找你」的**活跃客户端路由口径**:
|
||||
//
|
||||
// - PushResult.Detail 必须把「送到了几条连接」与「有没有落进主会话」**分开**
|
||||
// 报告 —— 修复前那句「delivered to 2 active client(s)」被读者当成「已推送
|
||||
// (刷新也在)」,而当时一行都没落库;
|
||||
// - 一次推送**至多让一个 sink 成功**(IM 桥与活跃客户端互斥),这正是
|
||||
// 「主会话里不会出现双份」的结构性依据:两条路由各自落库一次,而一次推送
|
||||
// 只走其中一条。
|
||||
|
||||
// TestPusher_ActiveDetailReportsPersistence 验证 Detail 分别报告投递数与落库。
|
||||
func TestPusher_ActiveDetailReportsPersistence(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
active *mockActive
|
||||
wantPushed bool
|
||||
wantIn string
|
||||
wantNotIn string
|
||||
}{
|
||||
{
|
||||
name: "投递成功且已落库",
|
||||
active: &mockActive{n: 2, persisted: true},
|
||||
wantPushed: true,
|
||||
wantIn: "persisted to main session",
|
||||
wantNotIn: "not persisted",
|
||||
},
|
||||
{
|
||||
name: "投递成功但未落库(故障可见)",
|
||||
active: &mockActive{n: 2, persisted: false},
|
||||
wantPushed: true,
|
||||
wantIn: "not persisted to main session",
|
||||
},
|
||||
{
|
||||
name: "无在线客户端但已落库",
|
||||
active: &mockActive{n: 0, persisted: true},
|
||||
wantPushed: false,
|
||||
wantIn: "recorded in main session",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
p := newPusher(tc.active, nil, &mockGate{allow: true}, discardLog())
|
||||
res := p.Push(context.Background(), PushRequest{
|
||||
ChannelHint: channelWeb,
|
||||
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
|
||||
})
|
||||
if res.Pushed != tc.wantPushed {
|
||||
t.Fatalf("Pushed = %v, want %v (res=%+v)", res.Pushed, tc.wantPushed, res)
|
||||
}
|
||||
if !strings.Contains(res.Detail, tc.wantIn) {
|
||||
t.Fatalf("Detail = %q, want it to contain %q", res.Detail, tc.wantIn)
|
||||
}
|
||||
if tc.wantNotIn != "" && strings.Contains(res.Detail, tc.wantNotIn) {
|
||||
t.Fatalf("Detail = %q, must not contain %q", res.Detail, tc.wantNotIn)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPusher_OnePushSucceedsOnAtMostOneSink 验证一次推送**至多让一个 sink 成功**
|
||||
// —— 互斥是「主会话里不会双份」的结构性依据。IM 失败后的活跃客户端降级是同一
|
||||
// 次推送内的兜底:失败的 IM 什么都没写,因此整体仍只有一条记录。
|
||||
func TestPusher_OnePushSucceedsOnAtMostOneSink(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
hint string
|
||||
imErr error
|
||||
activeN int
|
||||
wantIMCalls int
|
||||
wantActiveCall int
|
||||
}{
|
||||
{name: "web 提示只打活跃客户端", hint: channelWeb, activeN: 1, wantIMCalls: 0, wantActiveCall: 1},
|
||||
{name: "auto 提示 IM 可用时只打 IM", hint: channelAuto, activeN: 2, wantIMCalls: 1, wantActiveCall: 0},
|
||||
{name: "IM 失败时降级到活跃客户端", hint: channelAuto, imErr: errors.New("no owner IM channel"), activeN: 1, wantIMCalls: 1, wantActiveCall: 1},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
active := &mockActive{n: tc.activeN, persisted: true}
|
||||
im := &mockIMAdapter{err: tc.imErr}
|
||||
p := newPusher(active, im, &mockGate{allow: true}, discardLog())
|
||||
|
||||
res := p.Push(context.Background(), PushRequest{
|
||||
ChannelHint: tc.hint,
|
||||
Message: adapter.UnifiedMessage{Content: "记得喝水哦"},
|
||||
})
|
||||
if im.count() != tc.wantIMCalls || active.calls != tc.wantActiveCall {
|
||||
t.Fatalf("sink calls: im=%d (want %d) active=%d (want %d)",
|
||||
im.count(), tc.wantIMCalls, active.calls, tc.wantActiveCall)
|
||||
}
|
||||
// 成功的 sink 至多一个:能落库的路径因此也至多一条。
|
||||
imOK := tc.imErr == nil && tc.wantIMCalls == 1
|
||||
activeOK := tc.activeN > 0 && tc.wantActiveCall == 1
|
||||
if imOK && activeOK {
|
||||
t.Fatalf("both sinks succeeded in one push (res=%+v)", res)
|
||||
}
|
||||
if !imOK && !activeOK && res.Pushed {
|
||||
t.Fatalf("Pushed = true without a successful sink (res=%+v)", res)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
)
|
||||
|
||||
// wsProactiveMessage 是自主主动推送的 WS 事件类型
|
||||
@@ -27,21 +29,46 @@ type proactivePayload struct {
|
||||
Sender string `json:"sender,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
MessageID string `json:"messageId,omitempty"`
|
||||
// ID 是这条主动消息在**主会话历史**里的权威行 id(切片 P0「气泡恒落」)。
|
||||
// 客户端据此把实时气泡对齐到落库行:与握手回放 / REST 历史拉取按 id 去重
|
||||
// (同一条绝不渲染两个气泡),刷新后仍在。落库失败时该键**省略** ——
|
||||
// 客户端因此知道这条只有实时帧、没有历史行,而不是拿到一个悬空的 id。
|
||||
ID string `json:"id,omitempty"`
|
||||
// CreatedAt 是落库行的时间戳(Unix 毫秒),与 ID 同时出现/同时省略。它是
|
||||
// 权威的排序时间(与 `timestamp` 是同一次推送的两个视角:前者是历史行的
|
||||
// 时间,后者是这条统一消息自身的时间)。
|
||||
CreatedAt int64 `json:"createdAt,omitempty"`
|
||||
}
|
||||
|
||||
// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回
|
||||
// 有多少个接受了它。计数为零意味着没有 owner/admin 客户端在线,
|
||||
// 因此调用方回退到 IM bridge。只有 owner/admin 连接会被作为
|
||||
// 目标(红线:主动推送仅 owner);viewer 或未认证连接
|
||||
// 绝不被触达。它绝不阻塞:发送缓冲满时对该
|
||||
// 慢客户端丢弃该帧,而不是卡住后台思考推送。
|
||||
func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
|
||||
// SendProactive 把 msg 投递给每个存活的 owner/admin 连接,并返回投递与落库
|
||||
// 两件事的结果(切片 P0,`backthink.ProactiveDelivery`)。
|
||||
//
|
||||
// 顺序是刻意的 —— **先落库、再发帧**:主动消息必须成为主会话
|
||||
// (`history.MainSessionID`)里的一条真实消息(assistant 角色),否则刷新/
|
||||
// 重载后什么都补不回来(这正是「气泡恒落」的根);而帧要带上落库行的 id,
|
||||
// 客户端才能把实时气泡对齐到历史行。
|
||||
//
|
||||
// 落库失败**不静默**:记一条带来源的 WARN,并把错误返回给调用方,同时
|
||||
// Persisted=false —— 帧照发(实时气泡不能丢),但记录里绝不说「已推送」。
|
||||
//
|
||||
// 计数为零意味着没有 owner/admin 客户端在线;消息此时仍会作为一条真实记录
|
||||
// 留在主会话里(下次打开就能看到),调用方据此回退到 IM bridge 把它**投出去**。
|
||||
// 只有 owner/admin 连接会被作为目标(红线:主动推送仅 owner);viewer 或未认证
|
||||
// 连接绝不被触达。它绝不阻塞:发送缓冲满时对该慢客户端丢弃该帧,
|
||||
// 而不是卡住后台思考推送。
|
||||
func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (backthink.ProactiveDelivery, error) {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return 0, err
|
||||
return backthink.ProactiveDelivery{}, err
|
||||
}
|
||||
b, err := json.Marshal(wsEvent{Type: wsProactiveMessage, Data: proactivePayloadFrom(msg)})
|
||||
persisted, persistErr := h.persistProactiveToMain(ctx, msg)
|
||||
if persistErr != nil {
|
||||
h.log.Warn("persist proactive message to main session failed",
|
||||
"err", persistErr, "platform", msg.Platform)
|
||||
}
|
||||
b, err := json.Marshal(wsEvent{Type: wsProactiveMessage, Data: proactivePayloadFrom(msg, persisted)})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("gateway: marshal proactive: %w", err)
|
||||
return backthink.ProactiveDelivery{Persisted: persisted.ID != ""},
|
||||
fmt.Errorf("gateway: marshal proactive: %w", err)
|
||||
}
|
||||
h.mu.RLock()
|
||||
defer h.mu.RUnlock()
|
||||
@@ -56,17 +83,19 @@ func (h *hub) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (in
|
||||
default:
|
||||
}
|
||||
}
|
||||
return n, nil
|
||||
return backthink.ProactiveDelivery{Connections: n, Persisted: persisted.ID != ""}, persistErr
|
||||
}
|
||||
|
||||
// Server.SendProactive 通过委托给 hub 满足后台思考的 ActiveClient
|
||||
// 接缝,因此组合根能把网关自身交给汇聚点设置器。
|
||||
func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (int, error) {
|
||||
func (s *Server) SendProactive(ctx context.Context, msg adapter.UnifiedMessage) (backthink.ProactiveDelivery, error) {
|
||||
return s.hub.SendProactive(ctx, msg)
|
||||
}
|
||||
|
||||
// proactivePayloadFrom 把 UnifiedMessage 投影为消毒后的线上载荷。
|
||||
func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload {
|
||||
// persisted 是刚落库的主会话消息(未接线历史存储或落库失败时为零值),
|
||||
// 它的 id/时间戳随之进入载荷,使客户端能把实时气泡对齐到历史行。
|
||||
func proactivePayloadFrom(msg adapter.UnifiedMessage, persisted history.Message) proactivePayload {
|
||||
return proactivePayload{
|
||||
Platform: msg.Platform,
|
||||
ChannelID: msg.ChannelID,
|
||||
@@ -76,6 +105,8 @@ func proactivePayloadFrom(msg adapter.UnifiedMessage) proactivePayload {
|
||||
Sender: msg.Sender.DisplayName,
|
||||
Timestamp: msg.Timestamp,
|
||||
MessageID: msg.MessageID,
|
||||
ID: persisted.ID,
|
||||
CreatedAt: persisted.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
)
|
||||
|
||||
// 本文件承载主动推送的**落库**那一半(切片 P0「她主动找你」)。它从
|
||||
// proactive.go 拆出来,好让「帧长什么样」与「记录怎么写」各自成篇
|
||||
// (AGENTS §10 模块按职责拆源文件)。
|
||||
//
|
||||
// 为什么必须有这一半:修复前活跃客户端路由只把 `proactive_message` 帧
|
||||
// 广播给在线连接(fire-and-forget),**一行都不写** ⇒ 刷新/重载后什么都补不回来,
|
||||
// 界面既不显示气泡也补不出历史。主人的原话是「后台思考的主动推送一个都没推出来」。
|
||||
// 主动消息是**她真实说过的一句话**,因此它必须与 IM 镜像路径走同一条去向:
|
||||
// 主会话历史里的一条 assistant 消息。
|
||||
|
||||
// persistProactiveToMain 把一条主动消息写进主会话(`history.MainSessionID`),
|
||||
// 并返回落库后的完整消息(含权威 id/时间戳),供帧载荷携带。
|
||||
//
|
||||
// 三条口径:
|
||||
// - **角色恒为 assistant**:这是她单方面开口说的话,不是主人的输入 —— 塞成
|
||||
// user 角色会污染「谁说了什么」,也会在历史里冒充主人发言(红线)。
|
||||
// - **正文用调用方已经净化过的文本**(backthink 的 stripInternalMarkers 出口),
|
||||
// 系统元数据(决策标记、渠道选择)绝不进正文,也绝不新增任何隐藏字段 ——
|
||||
// 注入面因此与「气泡里看到的那句话」逐字相同。
|
||||
// - **turn_id 留空**:主动推送不属于任何一路回合(她单方面开口,没有对应的
|
||||
// 入站回合),空值诚实地表示这一点(与 channelbridge 的同一取舍)。
|
||||
//
|
||||
// 未接线历史存储时它是空操作并返回零值(`Persisted=false`)—— 与编排器 /
|
||||
// IM 镜像路径同一取舍:没存储不是错误,但**绝不能谎称已落库**。写库失败返回
|
||||
// 包装后的错误,由调用方让它可见(WARN + 返回给推送服务)。
|
||||
func (h *hub) persistProactiveToMain(ctx context.Context, msg adapter.UnifiedMessage) (history.Message, error) {
|
||||
if h.history == nil {
|
||||
return history.Message{}, nil
|
||||
}
|
||||
full, err := h.history.AppendFull(ctx, history.Message{
|
||||
SessionID: history.MainSessionID,
|
||||
Role: history.RoleAssistant,
|
||||
Content: msg.Content,
|
||||
Platform: msg.Platform,
|
||||
Mode: modeChat,
|
||||
})
|
||||
if err != nil {
|
||||
return history.Message{}, fmt.Errorf("gateway: persist proactive to main session: %w", err)
|
||||
}
|
||||
return full, nil
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/loop"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/channelbridge"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/user"
|
||||
)
|
||||
|
||||
// 本文件锁定切片 P0 的**落库**那一半:主动消息无论走活跃客户端路由
|
||||
// (gateway hub)还是 IM 桥路由(channelbridge),都必须成为主会话
|
||||
// (`history.MainSessionID`)里**恰好一条** assistant 消息。
|
||||
//
|
||||
// 为什么需要它:修复前活跃客户端路由只广播 `proactive_message` 帧、一行都不写,
|
||||
// 于是「帧被 2 个连接收下」被读成「已推送」,刷新后什么都不剩(主人报的正是
|
||||
// 「后台思考的主动推送一个都没推出来」)。另一半风险是**双份** —— IM 适配器
|
||||
// 出站消息本来就会被镜像回主会话(channelbridge/proactive.go 的 persistToMain),
|
||||
// 若活跃路由也写一次、而一条推送同时走了两条路,主会话里就会出现两条。
|
||||
// 因此这里两个路由各自跑一遍,逐个断言「恰好一条」,并额外断言
|
||||
// 一条推送只会触发**一路**写入。
|
||||
|
||||
// proactiveStoreMsg 构造一条最小化的自主主动消息,等同于 backthink 的
|
||||
// buildProactiveMessage 在无渠道决策(通用哨兵值)时的产物。
|
||||
func proactiveStoreMsg(content string) adapter.UnifiedMessage {
|
||||
return adapter.UnifiedMessage{
|
||||
Platform: "cyrene-backthink",
|
||||
ChannelType: adapter.ChannelDirect,
|
||||
Content: content,
|
||||
ContentType: adapter.ContentText,
|
||||
Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"},
|
||||
}
|
||||
}
|
||||
|
||||
// mainSessionRows 读回主会话的落库行(最新优先)。
|
||||
func mainSessionRows(t *testing.T, store *history.Store) []history.Message {
|
||||
t.Helper()
|
||||
rows, err := store.List(context.Background(), history.MainSessionID, 0, 50, "")
|
||||
if err != nil {
|
||||
t.Fatalf("list main session: %v", err)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
// TestHub_SendProactive_PersistsAssistantMessageToMainSession 验证活跃客户端
|
||||
// 路由把主动消息写成主会话里的一条 assistant 消息(「气泡恒落」的根:刷新后
|
||||
// 仍在),且发出去的帧带上落库行的权威 id/时间戳 —— 客户端据此把实时气泡
|
||||
// 对齐到历史行,而不是让它刷新即丢。
|
||||
func TestHub_SendProactive_PersistsAssistantMessageToMainSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newHistoryStoreForGateway(t)
|
||||
hb := newTestHub(nil, nil)
|
||||
hb.history = store
|
||||
owner := newOwnerConn(hb, 8)
|
||||
hb.register(owner)
|
||||
|
||||
delivery, err := hb.SendProactive(context.Background(), proactiveStoreMsg("记得喝水哦"))
|
||||
if err != nil {
|
||||
t.Fatalf("SendProactive err = %v", err)
|
||||
}
|
||||
if delivery.Connections != 1 {
|
||||
t.Fatalf("connections = %d, want 1", delivery.Connections)
|
||||
}
|
||||
if !delivery.Persisted {
|
||||
t.Fatalf("Persisted = false, want true (主动消息必须落进主会话,否则刷新就没了)")
|
||||
}
|
||||
|
||||
rows := mainSessionRows(t, store)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条(>1 = 双份)", len(rows))
|
||||
}
|
||||
row := rows[0]
|
||||
if row.Role != history.RoleAssistant {
|
||||
t.Fatalf("role = %q, want %q(她单方面开口的话,绝不能冒充主人的发言)", row.Role, history.RoleAssistant)
|
||||
}
|
||||
if row.SessionID != history.MainSessionID {
|
||||
t.Fatalf("session = %q, want %q", row.SessionID, history.MainSessionID)
|
||||
}
|
||||
if row.Content != "记得喝水哦" {
|
||||
t.Fatalf("content = %q, want the proactive text verbatim", row.Content)
|
||||
}
|
||||
if row.TurnID != "" {
|
||||
t.Fatalf("turn_id = %q, want empty(主动推送不属于任何一路回合)", row.TurnID)
|
||||
}
|
||||
|
||||
ev := readFakeFrame(t, owner)
|
||||
if ev.Type != wsProactiveMessage {
|
||||
t.Fatalf("frame type = %q, want %q", ev.Type, wsProactiveMessage)
|
||||
}
|
||||
data, ok := ev.Data.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("frame data type = %T, want map[string]any", ev.Data)
|
||||
}
|
||||
if data["id"] != row.ID {
|
||||
t.Fatalf("frame id = %v, want the persisted row id %q", data["id"], row.ID)
|
||||
}
|
||||
if got, _ := data["createdAt"].(float64); int64(got) != row.CreatedAt {
|
||||
t.Fatalf("frame createdAt = %v, want %d", data["createdAt"], row.CreatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
// failingHistory 让 AppendFull 失败,用于验证「落库失败**可见**」:帧照发
|
||||
// (实时气泡不能丢)、错误如实返回、Persisted=false(绝不谎报已推送)。
|
||||
type failingHistory struct {
|
||||
HistoryStore
|
||||
err error
|
||||
}
|
||||
|
||||
func (f failingHistory) AppendFull(context.Context, history.Message) (history.Message, error) {
|
||||
return history.Message{}, f.err
|
||||
}
|
||||
|
||||
// TestHub_SendProactive_PersistFailureIsVisible 验证落库失败时不做假成功:
|
||||
// 错误被返回、Persisted=false、Detail 的措辞由调用方如实反映,而帧仍然发出。
|
||||
func TestHub_SendProactive_PersistFailureIsVisible(t *testing.T) {
|
||||
t.Parallel()
|
||||
hb := newTestHub(nil, nil)
|
||||
hb.history = failingHistory{err: errors.New("db down")}
|
||||
owner := newOwnerConn(hb, 8)
|
||||
hb.register(owner)
|
||||
|
||||
delivery, err := hb.SendProactive(context.Background(), proactiveStoreMsg("在吗"))
|
||||
if err == nil {
|
||||
t.Fatalf("err = nil, want the persist failure to be reported")
|
||||
}
|
||||
if delivery.Persisted {
|
||||
t.Fatalf("Persisted = true, want false(没落库就是没落库)")
|
||||
}
|
||||
if delivery.Connections != 1 {
|
||||
t.Fatalf("connections = %d, want 1(落库失败不该吞掉实时帧)", delivery.Connections)
|
||||
}
|
||||
var frameData any
|
||||
if ev := readFakeFrame(t, owner); ev.Type != wsProactiveMessage {
|
||||
t.Fatalf("frame type = %q, want %q", ev.Type, wsProactiveMessage)
|
||||
} else {
|
||||
frameData = ev.Data
|
||||
}
|
||||
data, ok := frameData.(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("frame data type = %T, want map[string]any", frameData)
|
||||
}
|
||||
if _, hasID := data["id"]; hasID {
|
||||
t.Fatalf("frame carries id %v, want the key omitted(没有落库行就没有权威 id)", data["id"])
|
||||
}
|
||||
}
|
||||
|
||||
// fakeIMAdapter 是 IM 路由测试所需的最小适配器(只用到连接态与出站)。
|
||||
type fakeIMAdapter struct {
|
||||
platform string
|
||||
sends []adapter.UnifiedMessage
|
||||
}
|
||||
|
||||
func (f *fakeIMAdapter) Name() string { return f.platform }
|
||||
func (f *fakeIMAdapter) Config() map[string]any { return map[string]any{} }
|
||||
func (f *fakeIMAdapter) Connect(context.Context) error { return nil }
|
||||
func (f *fakeIMAdapter) Disconnect(context.Context) error { return nil }
|
||||
func (f *fakeIMAdapter) IsConnected() bool { return true }
|
||||
func (f *fakeIMAdapter) HealthCheck(context.Context) error { return nil }
|
||||
func (f *fakeIMAdapter) ReadMessages(ctx context.Context, _ func(adapter.UnifiedMessage)) {
|
||||
<-ctx.Done()
|
||||
}
|
||||
func (f *fakeIMAdapter) SendMessage(_ context.Context, resp adapter.UnifiedResponse) error {
|
||||
f.sends = append(f.sends, resp.Messages...)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeIMAdapter) SendProactive(_ context.Context, msg adapter.UnifiedMessage) error {
|
||||
f.sends = append(f.sends, msg)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeIMAdapter) SetMessageHandler(adapter.MessageHandler) {}
|
||||
func (f *fakeIMAdapter) Capabilities() adapter.Capabilities { return adapter.DefaultCapabilities() }
|
||||
func (f *fakeIMAdapter) ListChannels(context.Context) ([]adapter.ChannelInfo, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// stubBridgeChatter 满足 channelbridge.Chatter(本测试不会跑回合)。
|
||||
type stubBridgeChatter struct{}
|
||||
|
||||
func (stubBridgeChatter) Run(context.Context, string, []model.Message) (*loop.Result, error) {
|
||||
return &loop.Result{Text: "hi"}, nil
|
||||
}
|
||||
|
||||
// stubBridgeSessions 满足 channelbridge.SessionStore(本测试不会走入站路径)。
|
||||
type stubBridgeSessions struct{}
|
||||
|
||||
func (stubBridgeSessions) GetOrCreate(context.Context, adapter.UnifiedMessage, *user.OwnerProfile) (session.Session, error) {
|
||||
return session.Session{SessionID: "sess-1", Owner: true, MemWritable: true}, nil
|
||||
}
|
||||
|
||||
// newProactiveTestBridge 装配一条接在**真历史存储**上的 IM 桥(一个已连接、
|
||||
// 在白名单里的主人渠道),使 IM 路由的落库能被真库断言。
|
||||
func newProactiveTestBridge(t *testing.T, store *history.Store, onPersisted *[]history.Message) (*channelbridge.Bridge, *fakeIMAdapter) {
|
||||
t.Helper()
|
||||
owner := &user.OwnerProfile{Accounts: []user.OwnerAccount{{Platform: "telegram", AccountID: "100"}}}
|
||||
var hook func(history.Message)
|
||||
if onPersisted != nil {
|
||||
hook = func(m history.Message) { *onPersisted = append(*onPersisted, m) }
|
||||
}
|
||||
b, err := channelbridge.New(channelbridge.Options{
|
||||
Log: gwTestLogger(),
|
||||
Loop: stubBridgeChatter{},
|
||||
Sessions: stubBridgeSessions{},
|
||||
Owner: owner,
|
||||
History: store,
|
||||
OnPersisted: hook,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("new bridge: %v", err)
|
||||
}
|
||||
ad := &fakeIMAdapter{platform: "telegram"}
|
||||
b.Add(ad)
|
||||
return b, ad
|
||||
}
|
||||
|
||||
// TestChannelBridge_SendProactive_MirrorsExactlyOnce 是**现状核查**的可执行
|
||||
// 证据:IM 路由本来就会把主动消息镜像回主会话(且只镜像一次)。因此在这条
|
||||
// 路由上再加一次落库就是双份 —— 活跃路由的落库正是靠「两条路由互斥」避开它的。
|
||||
func TestChannelBridge_SendProactive_MirrorsExactlyOnce(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newHistoryStoreForGateway(t)
|
||||
var persisted []history.Message
|
||||
bridge, ad := newProactiveTestBridge(t, store, &persisted)
|
||||
|
||||
if err := bridge.SendProactive(context.Background(), proactiveStoreMsg("记得喝水哦")); err != nil {
|
||||
t.Fatalf("SendProactive err = %v", err)
|
||||
}
|
||||
if len(ad.sends) != 1 {
|
||||
t.Fatalf("IM sends = %d, want 1", len(ad.sends))
|
||||
}
|
||||
rows := mainSessionRows(t, store)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("主会话里应恰好一条主动消息,得到 %d 条(>1 = 双份)", len(rows))
|
||||
}
|
||||
if rows[0].Role != history.RoleAssistant || !strings.Contains(rows[0].Content, "记得喝水哦") {
|
||||
t.Fatalf("row = %+v, want an assistant message carrying the proactive text", rows[0])
|
||||
}
|
||||
if len(persisted) != 1 || persisted[0].ID != rows[0].ID {
|
||||
t.Fatalf("OnPersisted 应恰好收到刚落地的那一条(Web 端据此实时镜像),得到 %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestProactiveRoutes_EachRouteAddsExactlyOneRow 把两条路由对**同一条**主动
|
||||
// 消息各跑一次:主会话里应恰好两条(每条路由各一条)。它同时钉住两件事 ——
|
||||
// ① 两条路由都真的落库(不再有「只发帧不落库」的那条);② 没有一条路由写两份
|
||||
// (若活跃路由也走 IM 镜像那一次,这里会是 3 条)。
|
||||
func TestProactiveRoutes_EachRouteAddsExactlyOneRow(t *testing.T) {
|
||||
t.Parallel()
|
||||
store := newHistoryStoreForGateway(t)
|
||||
hb := newTestHub(nil, nil)
|
||||
hb.history = store
|
||||
hb.register(newOwnerConn(hb, 8))
|
||||
bridge, _ := newProactiveTestBridge(t, store, nil)
|
||||
|
||||
msg := proactiveStoreMsg("两条路各跑一次")
|
||||
if _, err := hb.SendProactive(context.Background(), msg); err != nil {
|
||||
t.Fatalf("active route err = %v", err)
|
||||
}
|
||||
if got := len(mainSessionRows(t, store)); got != 1 {
|
||||
t.Fatalf("活跃路由之后主会话有 %d 条,want 1", got)
|
||||
}
|
||||
if err := bridge.SendProactive(context.Background(), msg); err != nil {
|
||||
t.Fatalf("im route err = %v", err)
|
||||
}
|
||||
rows := mainSessionRows(t, store)
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("两条路由各跑一次后主会话有 %d 条,want 2(3 条 = 有一条路由写了双份)", len(rows))
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Role != history.RoleAssistant {
|
||||
t.Fatalf("row role = %q, want assistant", row.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,8 +39,8 @@ func TestHub_SendProactive_OwnerAndAdmin(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SendProactive err = %v", err)
|
||||
}
|
||||
if n != 2 {
|
||||
t.Fatalf("count = %d, want 2 (owner+admin; viewer and bare excluded)", n)
|
||||
if n.Connections != 2 {
|
||||
t.Fatalf("count = %d, want 2 (owner+admin; viewer and bare excluded)", n.Connections)
|
||||
}
|
||||
|
||||
for _, c := range []*wsConn{owner, admin} {
|
||||
@@ -82,8 +82,8 @@ func TestHub_SendProactive_NoOnlineOwner(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SendProactive err = %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Fatalf("count = %d, want 0", n)
|
||||
if n.Connections != 0 {
|
||||
t.Fatalf("count = %d, want 0", n.Connections)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,8 +113,8 @@ func TestServer_SendProactive_DelegatesToHub(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("SendProactive err = %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("count = %d, want 1", n)
|
||||
if n.Connections != 1 {
|
||||
t.Fatalf("count = %d, want 1", n.Connections)
|
||||
}
|
||||
ev := readFakeFrame(t, c)
|
||||
if ev.Type != "proactive_message" {
|
||||
|
||||
Reference in new issue
Block a user