From 534138d92ee16ab2e2ff78cae5bee4d49dd25b85 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 26 Aug 2026 22:54:12 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20gui=20renderer=20Batch=205=20?= =?UTF-8?q?=E2=80=94=20daemon=20event=20bridge=20(session/status=20store?= =?UTF-8?q?=20+=20sid-routed=20dispatch)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Agent.md | 2 +- .../gui/renderer/src/lib/daemonBridge.test.ts | 148 +++++++++ emrg/gui/renderer/src/lib/daemonBridge.ts | 311 ++++++++++++++++++ 3 files changed, 460 insertions(+), 1 deletion(-) create mode 100644 emrg/gui/renderer/src/lib/daemonBridge.test.ts create mode 100644 emrg/gui/renderer/src/lib/daemonBridge.ts diff --git a/Agent.md b/Agent.md index 252ec2a1..b9d35970 100644 --- a/Agent.md +++ b/Agent.md @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg Python: `uv run pytest tests/ -v` (1107) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (342: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (354: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 12 daemonBridge) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/lib/daemonBridge.test.ts b/emrg/gui/renderer/src/lib/daemonBridge.test.ts new file mode 100644 index 00000000..4bb55187 --- /dev/null +++ b/emrg/gui/renderer/src/lib/daemonBridge.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from "vitest"; +import { createDaemonBridge, type DaemonEventFrame } from "./daemonBridge"; +import { createTranscriptStore } from "./transcript"; + +/** + * daemonBridge.test.ts — daemon 事件桥测试(Batch 5 slice 1)。 + * 镜像 vanilla App.handleEvent(app.js 1411-1600):按 type 路由到 transcript/app store。 + * 严格 sid 路由(#977):每会话独立 busy/ownStream/disconnected 状态。 + */ + +function setup() { + const t = vi.fn((key: string, params?: Record) => { + if (key === "app.queued") return `queued:${params?.pos}`; + if (key === "app.queuedResent") return `resent:${params?.n}`; + if (key === "app.queuedCancelled") return "cancelled"; + if (key === "app.error") return `err:${params?.msg}`; + return key; + }); + const transcript = createTranscriptStore({ t }); + const sendMessage = vi.fn().mockResolvedValue({ requestId: "req-1" }); + let cb: ((evt: DaemonEventFrame) => void) | null = null; + const disposeCb = vi.fn(); + const onEvent = vi.fn((handler: (evt: DaemonEventFrame) => void) => { + cb = handler; + return disposeCb; + }); + const bridge = createDaemonBridge({ onEvent, emrg: { sendMessage }, transcript, t }); + return { bridge, transcript, t, sendMessage, onEvent, disposeCb, emit: (f: DaemonEventFrame) => cb?.(f) }; +} + +function entriesText(store: ReturnType, sid: string | null = null): string[] { + return store.getEntries(sid).map((e) => + e.kind === "user" ? `u:${e.text}` : e.kind === "assistant" ? `a:${e.segments.map((s) => s.text).join("")}` : e.kind === "system" ? `s:${e.text}` : `t:${e.kind}`, + ); +} + +describe("createDaemonBridge", () => { + it("订阅 onEvent 并返回 dispose(取消订阅)", () => { + const { bridge, onEvent, disposeCb } = setup(); + expect(onEvent).toHaveBeenCalledTimes(1); + bridge.dispose(); + expect(disposeCb).toHaveBeenCalledTimes(1); + }); + + it("message_delta → transcript.handleDelta(chunks 数组与单帧两种形态)", () => { + const { emit, transcript } = setup(); + emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "Hel" }] }, sid: "s1" }); + emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "lo" }] }, sid: "s1" }); + const txt = entriesText(transcript, "s1"); + expect(txt.some((x) => x === "a:Hello")).toBe(true); + }); + + it("done → transcript.handleDone + 释放 own stream 锁(request 匹配)", () => { + const { emit, bridge } = setup(); + bridge.store.update((s) => ({ ...s, busyBySid: { ...s.busyBySid, s1: true }, ownStreamRidBySid: { ...s.ownStreamRidBySid, s1: "r1" } })); + bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "x" }] }, sid: "s1" }); + bridge.handleFrame({ type: "done", data: { request_id: "r1" }, sid: "s1" }); + const st = bridge.store.get(); + expect(st.busyBySid["s1"]).toBe(false); + expect(st.ownStreamRidBySid["s1"] ?? null).toBeNull(); + }); + + it("tool_started / tool_finished → 工具行状态", () => { + const { emit, transcript } = setup(); + emit({ type: "tool_started", data: { request_id: "r1", tool_call_id: "c1", tool_name: "bash", intent: "run" }, sid: "s1" }); + const rows = transcript.getEntries("s1").filter((e) => e.kind === "tool-row"); + expect(rows).toHaveLength(1); + emit({ type: "tool_finished", data: { tool_call_id: "c1", tool_name: "bash", elapsed: 5, content: "out" }, sid: "s1" }); + const after = transcript.getEntries("s1").filter((e) => e.kind === "tool-row"); + expect((after[0] as { row: { status: string } }).row.status).toBe("done"); + }); + + it("cancelled → clearTyping + 释放锁(无 request_id 全清)", () => { + const { emit, bridge } = setup(); + bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "partial" }] }, sid: "s1" }); + bridge.handleFrame({ type: "cancelled", data: {}, sid: "s1" }); + expect(bridge.store.get().busyBySid["s1"]).toBe(false); + }); + + it("sessions / open_sessions → store 更新", () => { + const { emit, bridge } = setup(); + emit({ type: "sessions", data: { sessions: [{ session_id: "s1", title: "T" }] }, sid: null }); + expect(bridge.store.get().sessions).toEqual([{ session_id: "s1", title: "T" }]); + emit({ type: "open_sessions", data: { openSessions: [{ sid: "s1", projectName: "p" }] }, sid: null }); + expect(bridge.store.get().openSessions).toEqual([{ sid: "s1", projectName: "p" }]); + }); + + it("status → 连接状态 + serverId/model/version;pong → serverId/model/evolutionCount", () => { + const { emit, bridge } = setup(); + emit({ type: "status", data: { connected: true, server_id: "sv1", model: "m1", current_version: "v1" }, sid: null }); + let st = bridge.store.get(); + expect(st.connected).toBe(true); + expect(st.serverId).toBe("sv1"); + expect(st.model).toBe("m1"); + expect(st.currentVersion).toBe("v1"); + emit({ type: "pong", data: { identity: { instance_id: "sv2" }, model: "m2", evolution_count: 7 }, sid: null }); + st = bridge.store.get(); + expect(st.serverId).toBe("sv2"); + expect(st.model).toBe("m2"); + expect(st.evolutionCount).toBe(7); + }); + + it("task_queued → 排队系统消息(sid 路由)", () => { + const { emit, transcript } = setup(); + emit({ type: "task_queued", data: { position: 3 }, sid: "s2" }); + expect(entriesText(transcript, "s2")).toContain("s:queued:3"); + }); + + it("error → 错误系统消息 + 释放锁", () => { + const { emit, bridge, transcript } = setup(); + bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "x" }] }, sid: "s1" }); + bridge.handleFrame({ type: "done", data: { request_id: "r1" }, sid: "s1" }); + emit({ type: "error", data: { message: "boom" }, sid: "s1" }); + expect(entriesText(transcript, "s1")).toContain("s:err:boom"); + }); + + it("disconnected → 按 sid 标记 + 清锁 + 清队列;无 sid → 全局 connected=false", () => { + const { emit, bridge } = setup(); + emit({ type: "status", data: { connected: true }, sid: null }); + bridge.store.update((s) => ({ ...s, busyBySid: { ...s.busyBySid, s1: true }, ownStreamRidBySid: { ...s.ownStreamRidBySid, s1: "r1" } })); + emit({ type: "disconnected", data: {}, sid: "s1" }); + let st = bridge.store.get(); + expect(st.disconnectedBySid["s1"]).toBe(true); + expect(st.connected).toBe(true); // 后台会话断连不触发全局 + emit({ type: "disconnected", data: {}, sid: null }); + st = bridge.store.get(); + expect(st.connected).toBe(false); + }); + + it("未知事件类型静默忽略", () => { + const { emit, transcript } = setup(); + emit({ type: "future_type", data: {}, sid: null }); + expect(entriesText(transcript)).toEqual([]); + }); + + it("sid 隔离:s1 的 done 不释放 s2 的锁", () => { + const { emit, bridge } = setup(); + // 模拟 s1 流式 + s2 流式(P3:每会话独立 ownStreamRid) + bridge.store.update((s) => ({ ...s, busyBySid: { ...s.busyBySid, s1: true, s2: true }, ownStreamRidBySid: { ...s.ownStreamRidBySid, s1: "r1", s2: "r2" } })); + bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "a" }] }, sid: "s1" }); + bridge.handleFrame({ type: "message_delta", data: { chunks: [{ request_id: "r2", content: "b" }] }, sid: "s2" }); + bridge.handleFrame({ type: "done", data: { request_id: "r1" }, sid: "s1" }); + const st = bridge.store.get(); + expect(st.busyBySid["s1"]).toBe(false); + expect(st.busyBySid["s2"]).toBe(true); // s2 锁保留 + expect(st.ownStreamRidBySid["s2"]).toBe("r2"); // s2 的 rid 未被误清 + }); +}); diff --git a/emrg/gui/renderer/src/lib/daemonBridge.ts b/emrg/gui/renderer/src/lib/daemonBridge.ts new file mode 100644 index 00000000..ae22fbed --- /dev/null +++ b/emrg/gui/renderer/src/lib/daemonBridge.ts @@ -0,0 +1,311 @@ +/** + * daemonBridge.ts — daemon 事件桥(Batch 5 slice 1:final switch 的地基)。 + * 源:vanilla renderer/js/app.js App.handleEvent(1411-1600)+ Chat.handleDelta 等。 + * + * 职责:订阅 `window.emrg.onEvent`,按事件类型把帧路由到: + * - TranscriptStore(聊天区状态机,transcript.ts 已具备全部 handler) + * - DaemonAppStore(会话列表/打开会话/连接状态/serverId/model/evolutionCount) + * 严格按 sid 路由(#977 教训:会话串线根因 = 无 sid 隔离;P3 后每帧带 sid)。 + * + * 纯逻辑、零 DOM:注入 onEvent/emrg/transcript/t,可脱离 Electron 单测。 + * Shell 接线(Batch 5 后续 slice)消费 store 渲染;本模块不碰 window.emrg 默认值 + * (测试/接线层负责注入)。 + */ + +import type { TranslateFn } from "./utils"; +import { createSnapshotStore, type SnapshotStore } from "./snapshot-store"; +import type { TranscriptStore, DeltaChunk, DoneData, ToolStartData, ToolEndData } from "./transcript"; +import type { OpenSessionEntry } from "./sidebar"; + +/* ── 事件帧(与 main.js 广播契约一致:{ type, data, sid }) ── */ + +/** sessions 事件载荷(daemon sessions_list → main 重组) */ +export interface SessionsData { + sessions?: SessionSummary[]; +} +export interface SessionSummary { + session_id: string; + title?: string; + [k: string]: unknown; +} + +/** status 事件载荷(main 连接状态广播) */ +export interface StatusData { + connected?: boolean; + server_id?: string; + model?: string; + current_version?: string; + evolution_count?: number | null; + auth_failed?: boolean; + reconnecting?: boolean; + installing?: boolean; +} + +/** pong 事件载荷(daemon 心跳) */ +export interface PongData { + identity?: { instance_id?: string }; + model?: string; + evolution_count?: number | null; +} + +/** 队列注入协议帧(#655:busy 排队) */ +export interface QueuedData { + position?: number; + request_id?: string; + request_ids?: string[]; +} +export interface ErrorData { + message?: string; +} + +/** 统一事件帧(onEvent 回调入参) */ +export interface DaemonEventFrame { + type: string; + data: Record & { chunks?: DeltaChunk[] } & Partial; + sid?: string | null; +} + +/* ── App 级状态 store(桥维护,Shell 消费) ── */ + +export interface DaemonAppState { + /** 连接状态(status.connected / disconnected 事件驱动) */ + connected: boolean; + /** status 附带的诊断标记 */ + authFailed: boolean; + reconnecting: boolean; + installing: boolean; + serverId: string; + model: string; + currentVersion: string; + evolutionCount: number | null; + sessions: SessionSummary[]; + openSessions: OpenSessionEntry[]; + /** 每会话 busy 锁(P3 slice 1:done/cancelled 按 sid 释放,不误清激活会话) */ + busyBySid: Record; + /** 每会话 own stream request id(决定"来自其他客户端"标签) */ + ownStreamRidBySid: Record; + /** 每会话断线标记(P3 finalize:后台会话断线不触发全局 UI) */ + disconnectedBySid: Record; +} + +const SID_NULL = "__emrg_null_sid__"; +const KEY = (sid?: string | null): string => sid || SID_NULL; + +export function createDaemonAppStore(): SnapshotStore { + return createSnapshotStore({ + connected: false, + authFailed: false, + reconnecting: false, + installing: false, + serverId: "", + model: "", + currentVersion: "", + evolutionCount: null, + sessions: [], + openSessions: [], + busyBySid: {}, + ownStreamRidBySid: {}, + disconnectedBySid: {}, + }); +} + +/* ── 桥 ── */ + +export interface SendMessagePayload { + sessionId: string | null; + text: string; + requestId?: string; + sandbox?: string; +} + +/** 注入依赖(onEvent/emrg 不设默认,接线层显式传入) */ +export interface DaemonBridgeDeps { + onEvent: (cb: (evt: DaemonEventFrame) => void) => () => void; + emrg: { + sendMessage(p: SendMessagePayload): Promise<{ requestId?: string }>; + }; + transcript: TranscriptStore; + t?: TranslateFn; +} + +export interface DaemonBridge { + /** App 级状态(sessions/连接/openSessions/每会话锁) */ + store: SnapshotStore; + /** 取消订阅(组件卸载/断连清理时调用) */ + dispose(): void; + /** 供接线层手动投递(测试/重放用) */ + handleFrame(frame: DaemonEventFrame): void; +} + +export function createDaemonBridge(deps: DaemonBridgeDeps): DaemonBridge { + const { onEvent, emrg, transcript, t } = deps; + const tt: TranslateFn = t ?? ((key: string): string => key); + const store = createDaemonAppStore(); + + // P2 queue-injection(#655):busy 时发送的消息入 daemon 队列,queued_requeue + // 以原 requestId 重发(不重加用户行)。逐 sid 记录(后台会话独立跟踪)。 + const queuedSends = new Map(); + + function sidBusy(sid: string | null, busy: boolean): void { + const k = KEY(sid); + store.update((s) => ({ ...s, busyBySid: { ...s.busyBySid, [k]: busy } })); + } + function sidOwnRid(sid: string | null, rid: string | null): void { + const k = KEY(sid); + store.update((s) => ({ ...s, ownStreamRidBySid: { ...s.ownStreamRidBySid, [k]: rid } })); + } + function sidDisconnected(sid: string | null, v: boolean): void { + const k = KEY(sid); + store.update((s) => ({ ...s, disconnectedBySid: { ...s.disconnectedBySid, [k]: v } })); + } + + /** done/cancelled 释放该事件所属会话的锁(vanilla:仅当 request 匹配或 timeout) */ + function releaseOwnStream(sid: string | null, requestId?: string | null, force = false): void { + const k = KEY(sid); + const cur = store.get(); + const rid = cur.ownStreamRidBySid[k]; + if (force || (requestId && (rid === requestId))) { + sidBusy(sid, false); + sidOwnRid(sid, null); + } + } + + /** 按 sid 重发排队消息(vanilla queued_requeue 协议,含 wasBusy 收敛修正 #695) */ + async function resendQueued(sid: string | null, ids: Set): Promise { + const k = KEY(sid); + const q = queuedSends.get(k); + if (!q || q.length === 0) return; + const toResend = q.filter((e) => ids.has(e.requestId)); + const remaining = q.filter((e) => !ids.has(e.requestId)); + if (toResend.length) { + const wasBusy = store.get().busyBySid[k] ?? false; + for (let i = 0; i < toResend.length; i++) { + const item = toResend[i]; + sidBusy(sid, true); + sidOwnRid(sid, item.requestId); + try { + const res = await emrg.sendMessage({ + sessionId: sid, + text: item.text, + requestId: item.requestId, + sandbox: item.sandbox, + }); + sidOwnRid(sid, res?.requestId ?? item.requestId); + } catch { + sidBusy(sid, false); + sidOwnRid(sid, null); + } + if (wasBusy || i > 0) { + remaining.push(item); + } + } + if (remaining.length) queuedSends.set(k, remaining); + else queuedSends.delete(k); + transcript.addSystemMessage(tt("app.queuedResent", { n: toResend.length }), sid); + } + } + + function handleFrame(frame: DaemonEventFrame): void { + const { type, data } = frame; + const sid = frame.sid ?? null; + switch (type) { + case "message_delta": + transcript.handleDelta(data.chunks || [data], sid); + break; + case "done": + transcript.handleDone(data as DoneData, sid); + releaseOwnStream(sid, (data as DoneData).request_id, Boolean((data as DoneData).timeout)); + break; + case "tool_started": + transcript.handleToolStart(data as ToolStartData, sid); + break; + case "tool_finished": + transcript.handleToolEnd(data as ToolEndData, sid); + break; + case "cancelled": + transcript.clearTyping(sid); + releaseOwnStream(sid, null, true); + break; + case "task_queued": + transcript.addSystemMessage(tt("app.queued", { pos: (data as QueuedData).position ?? 0 }), sid); + break; + case "steer_committed": { + // 已注入当前回合 → 从待重发记录移除 + const k = KEY(sid); + const q = queuedSends.get(k); + const rid = (data as QueuedData).request_id; + if (q && rid) { + const idx = q.findIndex((e) => e.requestId === rid); + if (idx >= 0) q.splice(idx, 1); + if (q.length === 0) queuedSends.delete(k); + } + break; + } + case "queued_requeue": { + const ids = new Set((data as QueuedData).request_ids || []); + void resendQueued(sid, ids); + break; + } + case "queued_cancelled": + if (queuedSends.delete(KEY(sid))) { + transcript.addSystemMessage(tt("app.queuedCancelled"), sid); + } + break; + case "error": + transcript.addSystemMessage(tt("app.error", { msg: (data as ErrorData).message ?? "" }), sid); + releaseOwnStream(sid, null, true); + break; + case "pong": { + const pong = data as PongData; + store.update((s) => ({ + ...s, + serverId: pong.identity?.instance_id || s.serverId, + model: pong.model || s.model, + evolutionCount: pong.evolution_count ?? s.evolutionCount, + })); + break; + } + case "status": { + const st = data as StatusData; + store.update((s) => ({ + ...s, + connected: st.connected ?? s.connected, + authFailed: st.auth_failed ?? s.authFailed, + reconnecting: st.reconnecting ?? s.reconnecting, + installing: st.installing ?? s.installing, + serverId: st.server_id || s.serverId, + model: st.model || s.model, + currentVersion: st.current_version || s.currentVersion, + })); + break; + } + case "sessions": { + const sd = data as SessionsData; + store.update((s) => ({ ...s, sessions: sd.sessions || [] })); + break; + } + case "open_sessions": { + const os = data as { openSessions?: OpenSessionEntry[] }; + store.update((s) => ({ ...s, openSessions: os.openSessions || [] })); + break; + } + case "disconnected": { + sidBusy(sid, false); + sidOwnRid(sid, null); + sidDisconnected(sid, true); + queuedSends.delete(KEY(sid)); + if (!sid) { + store.update((s) => ({ ...s, connected: false })); + } + break; + } + default: + // 未知事件类型:忽略(vanilla 同语义,未来类型静默兼容) + break; + } + } + + const dispose = onEvent((evt) => handleFrame(evt)); + + return { store, dispose, handleFrame }; +} From f3bc2910b2ea395623ee32e58e52f3490b37746b Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 26 Aug 2026 23:17:54 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20gui=20renderer=20Batch=205=20slice?= =?UTF-8?q?=202=20=E2=80=94=20DaemonBridgeProvider=20(AppProviders=20daemo?= =?UTF-8?q?n-event=20context=20layer)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DaemonBridgeProvider: React context layer wiring lib/daemonBridge (PR #1016) into the React tree — creates the shared TranscriptStore + DaemonBridge (subscribes window.emrg.onEvent, sendMessage via window.emrg.sendMessage). - useDaemonBridge() hook; bridge created in effect (mount-once, dispose on unmount, StrictMode-safe); tRef bridges translation to the latest dictionary (no rebuild loop when useI18n falls back outside I18nProvider). - Graceful degradation when window.emrg is absent (browser preview/tests). - App.tsx mounts the provider (design 4.1 AppProviders); Shell.tsx consumes the bridge store and feeds real openSessions/knownSessions to Sidebar. - 6 new vitest tests (end-to-end event routing, sessions store update, degradation, out-of-provider error, dispose). Renderer 354 -> 360. - Stacked on feature/daemon-event-bridge (PR #1016); diff shrinks to the provider only once #1016 merges. --- Agent.md | 2 +- emrg/gui/renderer/src/App.tsx | 12 +- .../components/DaemonBridgeProvider.test.tsx | 123 ++++++++++++++++++ .../src/components/DaemonBridgeProvider.tsx | 81 ++++++++++++ emrg/gui/renderer/src/components/Shell.tsx | 10 +- 5 files changed, 221 insertions(+), 7 deletions(-) create mode 100644 emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx create mode 100644 emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx diff --git a/Agent.md b/Agent.md index b9d35970..06f2f11d 100644 --- a/Agent.md +++ b/Agent.md @@ -121,7 +121,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg Python: `uv run pytest tests/ -v` (1107) — import check: `uv run python -c "from emrg.client.app import run_client"` GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js` -Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (354: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 12 daemonBridge) + `npm run build` → `renderer/dist/` +Renderer: `cd emrg/gui/renderer && npm run typecheck && npm test` (360: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting + 11 i18n + 11 markdown + 15 transcript + 7 TranscriptView + 15 history + 22 composer + 14 Composer + 12 sidebar + 10 Sidebar + 9 fileTree + 9 FileTree + 16 resultPanel + 8 ResultPanel + 27 workspaceView + 8 WorkspaceView + 10 dialog + 6 Dialog + 9 ConfirmDialog + 9 RenameDialog + 10 dialogLists + 3 HelpDialog + 9 MemoryDialog + 6 SkillsDialog + 9 openSession + 6 WelcomeDialog + 8 OpenSessionDialog + 7 NewSessionDialog + 7 rewind + 8 RewindDialog + 7 GithubDeviceDialog + 12 daemonBridge + 6 DaemonBridgeProvider) + `npm run build` → `renderer/dist/` CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文) Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响) Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径) diff --git a/emrg/gui/renderer/src/App.tsx b/emrg/gui/renderer/src/App.tsx index 8fe163d8..599b1670 100644 --- a/emrg/gui/renderer/src/App.tsx +++ b/emrg/gui/renderer/src/App.tsx @@ -1,15 +1,17 @@ import { ErrorBoundary } from "./components/ErrorBoundary"; import { Shell } from "./components/Shell"; +import { DaemonBridgeProvider } from "./components/DaemonBridgeProvider"; import { I18nProvider } from "./lib/i18n"; /** - * App — React 组件树根(Batch 0 骨架)。 + * App — React 组件树根(Batch 0 骨架 + Batch 5 slice 2 daemon-event context)。 * * 组件树与设计文档 §4.1 对齐: * * └── # 全局错误边界(React 版,替代 error-boundary.js) - * └── # i18n / theme / daemon-event context(本期:i18n) - * └── # 本期为 占位 + * └── # i18n / theme / daemon-event context + * └── # Batch 5 slice 2:daemon 事件桥 + 聊天状态机 + * └── # 本期为 占位 * * 后续批次将 Shell 逐步替换为 Sidebar / TranscriptView / ResultPanel / Dialogs; * 旧 vanilla renderer(js/*.js)保持不动直到 Batch 5 一次性切换(设计 D3)。 @@ -18,7 +20,9 @@ export function App() { return ( - + + + ); diff --git a/emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx b/emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx new file mode 100644 index 00000000..83de93a0 --- /dev/null +++ b/emrg/gui/renderer/src/components/DaemonBridgeProvider.test.tsx @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { useSyncExternalStore } from "react"; +import type { ReactNode } from "react"; +import { DaemonBridgeProvider, useDaemonBridge } from "./DaemonBridgeProvider"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { I18nProvider } from "../lib/i18n"; +import { useSnapshotStore } from "../hooks/useSnapshotStore"; +import type { DaemonEventFrame } from "../lib/daemonBridge"; + +/** + * DaemonBridgeProvider.test.tsx — Batch 5 slice 2:AppProviders daemon-event + * context 层测试。模拟 preload 的 window.emrg(ipcRenderer.on 多订阅者语义): + * 验证订阅创建、事件端到端路由进共享 TranscriptStore、卸载取消订阅、 + * window.emrg 缺失降级、Provider 外 useDaemonBridge 抛错。 + */ + +/** 模拟 preload 暴露的 window.emrg(多订阅者通道,与 ipcRenderer.on 语义一致) */ +function mockEmrg() { + const listeners = new Set<(evt: DaemonEventFrame) => void>(); + const onEvent = vi.fn((cb: (evt: DaemonEventFrame) => void) => { + listeners.add(cb); + return () => listeners.delete(cb); + }); + const sendMessage = vi.fn().mockResolvedValue({ requestId: "req-9" }); + (window as unknown as { emrg?: unknown }).emrg = { onEvent, sendMessage }; + return { + listeners, + onEvent, + sendMessage, + emit: (evt: DaemonEventFrame) => listeners.forEach((cb) => cb(evt)), + }; +} + +function wrapper(children: ReactNode) { + return ( + + {children} + + ); +} + +describe("DaemonBridgeProvider (Batch 5 slice 2)", () => { + afterEach(() => { + delete (window as unknown as { emrg?: unknown }).emrg; + }); + + it("在 effect 中创建桥并订阅 window.emrg.onEvent", async () => { + const m = mockEmrg(); + render(wrapper(
)); + await waitFor(() => expect(screen.getByTestId("child")).toBeInTheDocument()); + expect(m.onEvent).toHaveBeenCalledTimes(1); + }); + + it("把 daemon 事件端到端路由进共享 TranscriptStore(message_delta 双帧拼接)", async () => { + const m = mockEmrg(); + let text = ""; + function Probe() { + const { transcript } = useDaemonBridge(); + // 与 TranscriptView 相同的订阅方式:store 版本号变更触发重渲染 + useSyncExternalStore(transcript.subscribe, transcript.getVersion); + text = transcript + .getEntries("s1") + .map((e) => (e.kind === "assistant" ? e.segments.map((s) => s.text).join("") : e.kind)) + .join(","); + return
; + } + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "Hel" }] }, sid: "s1" }); + m.emit({ type: "message_delta", data: { chunks: [{ request_id: "r1", content: "lo" }] }, sid: "s1" }); + await waitFor(() => expect(text).toContain("Hello")); + }); + + it("sessions 事件更新桥 store(Shell 侧边栏数据源)", async () => { + const m = mockEmrg(); + let openSessions = 0; + function Probe() { + const { bridge } = useDaemonBridge(); + // 与 Shell 相同的订阅方式:bridge.store 快照订阅 + const appState = useSnapshotStore(bridge.store); + openSessions = appState.openSessions.length; + return
; + } + render(wrapper()); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + m.emit({ type: "open_sessions", data: { openSessions: [{ sid: "s1", projectName: "p" }] }, sid: null }); + await waitFor(() => expect(openSessions).toBe(1)); + }); + + it("window.emrg 缺失时优雅降级(不抛错,子组件可挂载)", async () => { + render( + +
+ , + ); + await waitFor(() => expect(screen.getByTestId("child")).toBeInTheDocument()); + }); + + it("Provider 之外 useDaemonBridge 抛错(被 ErrorBoundary 捕获)", () => { + const errSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + function Bad() { + useDaemonBridge(); + return null; + } + const { container } = render( + + + , + ); + expect(container.querySelector(".error-boundary-overlay")).toBeInTheDocument(); + errSpy.mockRestore(); + }); + + it("卸载时 dispose(取消订阅,listener 清空)", async () => { + const m = mockEmrg(); + const { unmount } = render(wrapper(
)); + await waitFor(() => expect(m.onEvent).toHaveBeenCalledTimes(1)); + expect(m.listeners.size).toBe(1); + unmount(); + expect(m.listeners.size).toBe(0); + }); +}); diff --git a/emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx b/emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx new file mode 100644 index 00000000..a112b03a --- /dev/null +++ b/emrg/gui/renderer/src/components/DaemonBridgeProvider.tsx @@ -0,0 +1,81 @@ +import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react"; +import { + createDaemonBridge, + type DaemonBridge, + type DaemonEventFrame, + type SendMessagePayload, +} from "../lib/daemonBridge"; +import { createTranscriptStore, type TranscriptStore } from "../lib/transcript"; +import { useI18n, type TranslateFn } from "../lib/i18n"; + +/** + * DaemonBridgeProvider — Batch 5 slice 2:daemon-event context 层(设计文档 §4.1 + * AppProviders)。PR #1016 的 lib/daemonBridge.ts 是纯逻辑地基;本组件把它接进 + * React 树: + * + * - 创建 TranscriptStore(聊天区状态机,与 TranscriptView 共享同一实例)和 + * DaemonBridge(订阅 window.emrg.onEvent,发送走 window.emrg.sendMessage)。 + * - context 暴露 { bridge, transcript };`useDaemonBridge()` 消费(Shell 及后续 + * slice 的组件用它取会话列表 / 连接状态 / 聊天状态机)。 + * - 桥在 effect 中创建:StrictMode dev 双挂载时 dispose 清理第一次订阅(无泄漏), + * 生产构建 StrictMode 为 no-op,单次挂载;value 就绪前一帧渲染 null。 + * - 非 Electron 环境(window.emrg 缺失)优雅降级:onEvent 返回 no-op 取消函数、 + * sendMessage 以传入 requestId resolve —— 浏览器预览 / 单测不崩溃,store 可用。 + */ + +export interface DaemonBridgeContextValue { + bridge: DaemonBridge; + transcript: TranscriptStore; +} + +const DaemonBridgeContext = createContext(null); + +/** preload.js 暴露的 window.emrg 最小形状(仅本组件用到的两个通道) */ +interface EmrgWindow { + emrg?: { + onEvent?: (cb: (evt: DaemonEventFrame) => void) => () => void; + sendMessage?: (p: SendMessagePayload) => Promise<{ requestId?: string }>; + }; +} + +export function DaemonBridgeProvider({ children }: { children: ReactNode }) { + const { t } = useI18n(); + // t 每次渲染可能是新函数对象(无 I18nProvider 时 useI18n 回退路径每帧新建), + // 若把 t 放进 effect deps 会无限重建桥(事件处理时翻译需取最新 t)→ 用 ref 桥接: + // 桥只在挂载时创建一次(deps []),翻译始终委托给 tRef.current(最新词典)。 + const tRef = useRef(t); + tRef.current = t; + const [value, setValue] = useState(null); + + useEffect(() => { + const tt: TranslateFn = (key, params) => tRef.current(key, params); + const transcript = createTranscriptStore({ t: tt }); + const bridge = createDaemonBridge({ + onEvent: (cb) => { + const emrg = (window as unknown as EmrgWindow).emrg; + return emrg?.onEvent ? emrg.onEvent(cb) : () => {}; + }, + emrg: { + sendMessage: async (p) => { + const emrg = (window as unknown as EmrgWindow).emrg; + if (!emrg?.sendMessage) return { requestId: p.requestId }; + return emrg.sendMessage(p); + }, + }, + transcript, + t: tt, + }); + setValue({ bridge, transcript }); + return () => bridge.dispose(); + }, []); + + if (!value) return null; + return {children}; +} + +/** 消费 hook:返回 { bridge, transcript };Provider 之外调用抛错(接线错误快速暴露) */ +export function useDaemonBridge(): DaemonBridgeContextValue { + const ctx = useContext(DaemonBridgeContext); + if (!ctx) throw new Error("useDaemonBridge must be used within DaemonBridgeProvider"); + return ctx; +} diff --git a/emrg/gui/renderer/src/components/Shell.tsx b/emrg/gui/renderer/src/components/Shell.tsx index 6c2270e3..7bb9e784 100644 --- a/emrg/gui/renderer/src/components/Shell.tsx +++ b/emrg/gui/renderer/src/components/Shell.tsx @@ -1,4 +1,6 @@ import { useI18n } from "../lib/i18n"; +import { useSnapshotStore } from "../hooks/useSnapshotStore"; +import { useDaemonBridge } from "./DaemonBridgeProvider"; import { Sidebar } from "./Sidebar"; import { ResultPanel } from "./ResultPanel"; import { WorkspaceView } from "./WorkspaceView"; @@ -6,11 +8,15 @@ import { WorkspaceView } from "./WorkspaceView"; /** * Shell — React 骨架占位布局(Batch 0 + Batch 3 Sidebar/ResultPanel 挂载)。 * 设计文档 §5 Batch 0 项 4:错误边界/横幅/菜单等零风险组件先就位。 - * Batch 3:左侧挂载 + 右侧挂载 (当前空数据,Batch 5 接线 daemon 事件)。 + * Batch 3:左侧挂载 + 右侧挂载 (当前空数据)。 + * Batch 5 slice 2:接入 DaemonBridgeProvider —— 侧边栏实时消费 daemon 广播的 + * 会话列表(open_sessions / sessions 事件驱动,bridge.store 快照订阅)。 * 后续批次逐个替换为真实组件:TranscriptView / Dialogs。 */ export function Shell() { const { t } = useI18n(); + const { bridge } = useDaemonBridge(); + const appState = useSnapshotStore(bridge.store); return (
@@ -21,7 +27,7 @@ export function Shell() {