fix(backthink): 她主动找你终于看得见 —— 主动消息落主会话 + 前端消费 + 非前台浏览器通知
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

修主人报的硬故障「后台思考的主动推送一个都没有推出来」(根因两条,缺一不可):
- 后端 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:
AskaEth committed 2026-10-05 16:02:36 +08:00
1 parent 1bf85a955b
commit 84c124f5a9
18 files changed
+1545 -46

No files matched your search

+27 -3
View File
@@ -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`)。
+10 -3
View File
@@ -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 注释),每个模块是前缀下的一段路径。
+84
View File
@@ -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;
},
};
}
+20
View File
@@ -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()), []);
}
+35 -3
View File
@@ -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`);
+96
View File
@@ -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;
}
+110
View File
@@ -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`);
+44
View File
@@ -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;
}
+9 -7
View File
@@ -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。
+57 -11
View File
@@ -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)
}
})
}
}
+44 -13
View File
@@ -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)
}
}
}
+6 -6
View File
@@ -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" {