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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user