feat(gateway+web): D 消息编辑/删除 WS 实时广播 — message_edited/message_deleted
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 后端: ws_hub.go broadcastMessageEdited(完整DTO,thinking剥离)/broadcastMessageDeleted(全广播+sessionId,前端过滤); history_write.go PATCH/DELETE成功后触发(404/400/501不广播); history/message_ops.go GetMessage(导出壳,取删除前sessionId)
- 前端: useAppStore.updateMessageById/removeMessageById + messageEventApplies(同会话过滤); ws.ts 消费两事件(缺content丢弃); dispatchFrame导出(可测)
- 真机e2e: PATCH/DELETE → WS收到 message_edited/message_deleted, 二次删404无帧
- Docs/api-client v0.34 §3.8; 测试 history_write_broadcast_test + messageSync.test(14 checks)
- 遗留: 前端编辑/删除UI入口+api客户端函数未接(lib/api 无 PATCH/DELETE messages); ws_hub.go 589行>500(既有)
This commit is contained in:
AskaEth committed 2026-09-30 11:58:49 +08:00
1 parent 4ea68d17f3
commit 2720d77331
8 files changed
+485 -15

No files matched your search

+113
View File
@@ -0,0 +1,113 @@
// Logic test for the D3 realtime-sync frames (`message_edited` / `message_deleted`).
// The project has no frontend test runner and no jsdom, so this is a dependency-free
// plain-node check: it drives the real frame decoder (`dispatchFrame` in lib/ws.ts)
// and the real store, and throws on the first failure.
//
// Run it with the esbuild that already ships with vite (no new dependency, no
// package.json change); the banner supplies the `window.localStorage` the store's
// persist middleware probes, which is simply absent under node:
//
// cd frontend/web \
// && ./node_modules/.bin/esbuild src/store/messageSync.test.ts \
// --bundle --platform=node --format=esm \
// --banner:js='globalThis.window={localStorage:{getItem:()=>null,setItem:()=>{},removeItem:()=>{}}};' \
// --outfile=/tmp/cyrene-message-sync.test.mjs \
// && node /tmp/cyrene-message-sync.test.mjs
//
// `tsc --noEmit` (part of `npm run build`) type-checks this file, which is why it
// imports nothing from node:*.
import { dispatchFrame } from "../lib/ws";
import { messageEventApplies, useAppStore } from "./useAppStore";
import type { ChatMessage } from "../lib/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";
const OTHER = "web:sub-1";
/** Reset the store to a focused session holding a fixed message list. */
function seed(messages: ChatMessage[], activeSessionId: string): void {
useAppStore.setState({ activeSessionId, messages });
}
function messages(): ChatMessage[] {
return useAppStore.getState().messages;
}
function base(): ChatMessage[] {
return [
{ id: "m1", sessionId: MAIN, role: "user", content: "old", mode: "chat", createdAt: 1000 },
{ id: "m2", sessionId: MAIN, role: "assistant", content: "reply", createdAt: 2000 },
];
}
// ① Session filter: a frame scoped to another conversation never touches this one.
ok(messageEventApplies(MAIN, MAIN), "same session applies");
ok(!messageEventApplies(OTHER, MAIN), "another session does not apply");
ok(messageEventApplies(undefined, MAIN), "an unscoped (id-only) frame applies");
// ② message_edited patches the addressed bubble in place, by id, in the active
// session — and leaves its siblings alone.
seed(base(), MAIN);
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m1", sessionId: MAIN, content: "new", mode: "chat", createdAt: 1000 } });
same(
messages().map((m) => m.content),
["new", "reply"],
"edit patches only the addressed message"
);
same(messages().find((m) => m.id === "m1")?.mode, "chat", "edit keeps the message mode");
ok(messages().find((m) => m.id === "m1")?.createdAt === 1000, "edit keeps createdAt");
// ③ An edit frame for another session is ignored (no cross-session write) …
seed(base(), MAIN);
dispatchFrame({ type: "message_edited", sessionId: OTHER, data: { id: "m3", sessionId: OTHER, content: "elsewhere" } });
same(messages().map((m) => m.content), ["old", "reply"], "edit for another session does not apply");
// … and an edit for an id we do not hold never appends a row.
seed(base(), MAIN);
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m-ghost", sessionId: MAIN, content: "ghost" } });
same(messages().length, 2, "edit for an unknown id does not append");
// A malformed frame (no content) must not blank out a rendered bubble.
seed(base(), MAIN);
dispatchFrame({ type: "message_edited", sessionId: MAIN, data: { id: "m1", sessionId: MAIN } });
same(messages()[0].content, "old", "content-less edit frame is dropped");
// ④ message_deleted removes the addressed bubble in the active session only.
seed(base(), MAIN);
dispatchFrame({ type: "message_deleted", sessionId: MAIN, data: { id: "m1" } });
same(messages().map((m) => m.id), ["m2"], "delete removes the addressed message");
// A delete frame for another session is ignored.
seed(base(), MAIN);
dispatchFrame({ type: "message_deleted", sessionId: OTHER, data: { id: "m1" } });
same(messages().map((m) => m.id), ["m1", "m2"], "delete for another session does not apply");
// An unscoped delete still applies (the id is globally unique, so it can only ever
// match its own message).
seed(base(), MAIN);
dispatchFrame({ type: "message_deleted", data: { id: "m2" } });
same(messages().map((m) => m.id), ["m1"], "unscoped delete applies by id");
// A delete for an id we do not hold is a no-op.
seed(base(), MAIN);
dispatchFrame({ type: "message_deleted", sessionId: MAIN, data: { id: "m-ghost" } });
same(messages().length, 2, "delete for an unknown id is a no-op");
// The store actions are also safe on their own (defensive: duplicate frames).
seed(base(), MAIN);
useAppStore.getState().updateMessageById("m-ghost", { content: "x" });
useAppStore.getState().removeMessageById("m-ghost");
same(messages().length, 2, "store id-actions no-op on an unknown id");
console.log(`message_edited/message_deleted logic test: ${checks} checks passed`);