From 6f2d671613ce200f2bb68529932237516776568d Mon Sep 17 00:00:00 2001 From: "rayson951005@gmail.com" Date: Fri, 26 Jun 2026 12:55:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(room-memory):=20=E6=88=BF=E9=97=B4?= =?UTF-8?q?=E5=BA=95=E8=B4=A6=20room=5Fevents=20+=20=E7=99=BD=E6=9D=BF?= =?UTF-8?q?=E6=9C=BA=E6=A2=B0=E5=90=88=E5=B9=B6=20+=20=E6=96=B0=E6=88=90?= =?UTF-8?q?=E5=91=98=E6=B3=A8=E5=85=A5=20(PR9/=C2=A74)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit v3 §4 / §11.1 bullet 10。broker 把每条房间事件落底账、机械蒸馏进结构化白板, 新成员 join 时把白板快照注入其会话,让它立刻有上下文(零 LLM)。 v3 §4 / §11.1 bullet 10. The broker now records every room event to the ledger, mechanically distils it into a structured whiteboard, and hands a new member the whiteboard snapshot on join so it has context immediately — zero LLM. - src/whiteboard.ts: mergeWhiteboard 纯函数(不可变)—— task_completed 入 recentMilestones,带 contract 再入 contractsReady;每槽位封顶 MAX_WHITEBOARD_SLOT=50 (保留最新);不可合并 kind 返回原引用(调用方据此跳过 Store 写)。 - src/broker.ts: publish 成功扇出后 best-effort appendEvent + updateWhiteboard (底账/白板写失败绝不回滚或阻断已完成的实时投递);presence 走 emitPresence 旁路,天然不入底账。subscribe 0→1 时 getWhiteboard → 仅发给 joiner 自己 (不广播,不与 drainPendingTo 离线补投重复)。 - src/broker-client.ts: onWhiteboard 处理器 + 分发 type:"whiteboard" 帧。 - src/room-bridge.ts: renderWhiteboard(快照→一行中文摘要:契约/进行中/阻塞计数 + 最近几条)→ 注入 Claude,接通 broker→edge 的新成员注入边路。 Tests: whiteboard 纯函数 (5: 合并/契约/不可合并返回原引用/不可变/槽位封顶) + broker-room-memory 集成 (3: append-on-publish 且 presence 不入底账 / 白板仅 0→1 注入一次 / 底账写失败不阻断实时投递) + renderWhiteboard 单测 + room-bridge 集成 (join 收到并注入白板) + broker-routing WsClient 过滤 whiteboard 帧。check 全绿 1815 pass。 Backlog(§4 推后): recentEvents 全量回放(白板注入已满足 §4.4,回放会与 pending 重叠)、abg whiteboard / abg note CLI、LLM 蒸馏、room_events 滚动归档剪枝。 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- plugins/agentbridge/server/bridge-server.js | 4 +- plugins/agentbridge/server/daemon.js | 44 +++++- src/broker-client.ts | 17 +++ src/broker.ts | 36 ++++- .../broker-room-memory.test.ts | 133 ++++++++++++++++++ src/integration-test/broker-routing.test.ts | 6 +- src/integration-test/room-bridge.test.ts | 23 +++ src/room-bridge.ts | 38 +++++ src/unit-test/room-bridge-render.test.ts | 21 ++- src/unit-test/whiteboard.test.ts | 60 ++++++++ src/whiteboard.ts | 67 +++++++++ 11 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 src/integration-test/broker-room-memory.test.ts create mode 100644 src/unit-test/whiteboard.test.ts create mode 100644 src/whiteboard.ts diff --git a/plugins/agentbridge/server/bridge-server.js b/plugins/agentbridge/server/bridge-server.js index 01097cc..fd251b3 100755 --- a/plugins/agentbridge/server/bridge-server.js +++ b/plugins/agentbridge/server/bridge-server.js @@ -14707,10 +14707,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("b202e8f", "source"), + commit: defineString("d824fd2", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("7c98560c206e", "source") + codeHash: defineString("2784fec70261", "source") }); function sameRuntimeContract(a, b) { if (!a || !b) diff --git a/plugins/agentbridge/server/daemon.js b/plugins/agentbridge/server/daemon.js index ef9f9a6..ea72b2c 100755 --- a/plugins/agentbridge/server/daemon.js +++ b/plugins/agentbridge/server/daemon.js @@ -30,10 +30,10 @@ function defineNumber(value, fallback) { } var BUILD_INFO = Object.freeze({ version: defineString("0.1.24", "0.0.0-source"), - commit: defineString("b202e8f", "source"), + commit: defineString("d824fd2", "source"), bundle: defineBundle("plugin"), contractVersion: defineNumber(1, CONTRACT_VERSION), - codeHash: defineString("7c98560c206e", "source") + codeHash: defineString("2784fec70261", "source") }); function daemonStatusBuildInfo() { return { ...BUILD_INFO }; @@ -6823,6 +6823,7 @@ class BrokerClient { subscriptions = new Set; outbox = []; eventHandlers = []; + whiteboardHandlers = []; closed = false; authFailed = false; reconnectAttempt = 0; @@ -6890,6 +6891,9 @@ class BrokerClient { onEvent(handler) { this.eventHandlers.push(handler); } + onWhiteboard(handler) { + this.whiteboardHandlers.push(handler); + } close() { this.closed = true; this.clearReconnectTimer(); @@ -6947,6 +6951,14 @@ class BrokerClient { this.log(`event handler threw: ${String(e)}`); } } + } else if (msg.type === "whiteboard") { + for (const h of this.whiteboardHandlers) { + try { + h(msg.roomId, msg.whiteboard); + } catch (e) { + this.log(`whiteboard handler threw: ${String(e)}`); + } + } } }; ws.onclose = () => { @@ -7282,6 +7294,29 @@ function label(env) { const dn = env.payload?.displayName; return env.from?.name || (typeof dn === "string" ? dn : "") || env.from?.agentId || "\u67D0\u6210\u5458"; } +function renderWhiteboard(wb) { + if (!wb || typeof wb !== "object") + return null; + const w = wb; + const arr = (x) => Array.isArray(x) ? x : []; + const contracts = arr(w.contractsReady); + const inProgress = arr(w.inProgress); + const blockers = arr(w.blockers); + const milestones = arr(w.recentMilestones); + if (contracts.length + inProgress.length + blockers.length + milestones.length === 0) + return null; + const names = (items, key) => items.slice(-3).map((it) => typeof it[key] === "string" ? it[key] : "?").join(key === "summary" ? " / " : ", "); + const parts2 = ["\uD83D\uDCCB \u623F\u95F4\u767D\u677F"]; + if (contracts.length) + parts2.push(`\u5DF2\u5C31\u7EEA\u5951\u7EA6 ${contracts.length}\uFF08${names(contracts, "contract")}\uFF09`); + if (inProgress.length) + parts2.push(`\u8FDB\u884C\u4E2D ${inProgress.length}`); + if (blockers.length) + parts2.push(`\u963B\u585E ${blockers.length}`); + if (milestones.length) + parts2.push(`\u6700\u8FD1\uFF1A${names(milestones, "summary")}`); + return parts2.join(" \xB7 "); +} function renderRoomEvent(env) { const who = label(env); switch (env.kind) { @@ -7344,6 +7379,11 @@ async function startRoomBridge(deps) { if (text) deps.emit(text); }); + client.onWhiteboard((_roomId, wb) => { + const text = renderWhiteboard(wb); + if (text) + deps.emit(text); + }); client.subscribe(room); client.connect().catch((e) => log(`room bridge: connect failed \u2014 ${String(e)}`)); log(`room bridge: subscribed to room ${room}`); diff --git a/src/broker-client.ts b/src/broker-client.ts index a09c71c..a9bd20f 100644 --- a/src/broker-client.ts +++ b/src/broker-client.ts @@ -33,6 +33,8 @@ export function reconnectDelay(baseMs: number, maxMs: number, attempt: number, r return ceiling / 2 + rand * (ceiling / 2); } +type WhiteboardHandler = (roomId: string, whiteboard: unknown) => void; + /** * Edge-side client to the control-plane broker (§5 adapter transport + §8.2 * resilience foundation). @@ -55,6 +57,7 @@ export class BrokerClient { private readonly subscriptions = new Set(); private readonly outbox: Array<{ topic: string; envelope: Envelope }> = []; private readonly eventHandlers: EventHandler[] = []; + private readonly whiteboardHandlers: WhiteboardHandler[] = []; private closed = false; /** Set on auth_error: a bad token must NOT trigger an infinite reconnect loop. */ private authFailed = false; @@ -134,6 +137,11 @@ export class BrokerClient { this.eventHandlers.push(handler); } + /** Register a handler for the room whiteboard snapshot pushed on join (§4.4). */ + onWhiteboard(handler: WhiteboardHandler): void { + this.whiteboardHandlers.push(handler); + } + close(): void { this.closed = true; this.clearReconnectTimer(); @@ -194,6 +202,15 @@ export class BrokerClient { this.log(`event handler threw: ${String(e)}`); } } + } else if (msg.type === "whiteboard") { + // §4.4 new-member injection: a room whiteboard snapshot pushed on join. + for (const h of this.whiteboardHandlers) { + try { + h(msg.roomId, msg.whiteboard); + } catch (e) { + this.log(`whiteboard handler threw: ${String(e)}`); + } + } } }; ws.onclose = () => { diff --git a/src/broker.ts b/src/broker.ts index 1144bec..581c47b 100644 --- a/src/broker.ts +++ b/src/broker.ts @@ -5,6 +5,7 @@ import type { MessageTransport } from "./backbone/transport"; import type { Envelope } from "./backbone/envelope"; import { InProcTransport } from "./backbone/transport/inproc-transport"; import { buildPresenceEnvelope, type PresenceMeta } from "./presence"; +import { mergeWhiteboard } from "./whiteboard"; export const DEFAULT_BROKER_PORT = 4700; // outside the multi-pair 4500/4501/4502+stride range const CLOSE_AUTH_FAILED = 4401; @@ -247,7 +248,19 @@ export class Broker { // drain await means a disconnect mid-drain can't reorder it after the // close()-emitted member_left (a "left-then-joined" ghost) under a future // truly-async Store. Drain is broadcast-irrelevant; ordering vs join is moot. - if (becamePresent) await this.emitPresence(topic, "member_joined", ws.data.identity, ws.data.presence); + if (becamePresent) { + await this.emitPresence(topic, "member_joined", ws.data.identity, ws.data.presence); + // New-member injection (§4.4): hand the joiner the room's distilled + // whiteboard so it has context immediately, WITHOUT replaying the raw + // ledger (that would double-deliver against drainPendingTo). Sent only to + // this socket, best-effort. + try { + const whiteboard = await this.opts.store.getWhiteboard(topic); + if (whiteboard) this.send(ws, { type: "whiteboard", roomId: topic, whiteboard }); + } catch (e) { + this.log(`whiteboard inject failed for ${me}@${topic}: ${String(e)}`); + } + } // Drain anything queued during the connected-but-not-yet-subscribed gap // (between hello's drain and this subscribe). Safe: drainPending removes, // so an already-drained message is never re-delivered. @@ -307,6 +320,16 @@ export class Broker { } // Live fan-out; each subscriber's handler applies shouldDeliver (DM / from-skip). await this.transport.publish(msg.topic, env); + // Room memory (§4): append to the ledger + distil into the whiteboard AFTER + // delivery, best-effort — a memory write must never roll back or block the + // live fan-out that already happened. Presence is broker-synthesized via + // emitPresence (not this path), so it's naturally excluded from the ledger. + try { + await this.opts.store.appendEvent(env.roomId, env); + await this.updateWhiteboard(env); + } catch (e) { + this.log(`room-memory update failed (${env.idempotencyKey}): ${String(e)}`); + } return; } default: { @@ -377,6 +400,17 @@ export class Broker { return (this.topicMembers.get(topic)?.get(id) ?? 0) > 0; } + /** + * Distil an event into the room whiteboard (§4.2), zero-LLM. mergeWhiteboard + * returns the SAME reference when the kind doesn't touch the board, so an + * unmergeable event (a DM, etc.) skips the Store write entirely. + */ + private async updateWhiteboard(env: Envelope): Promise { + const prev = await this.opts.store.getWhiteboard(env.roomId); + const next = mergeWhiteboard(prev, env); + if (next !== prev && next !== null) await this.opts.store.saveWhiteboard(env.roomId, next); + } + /** Persist a store_if_offline envelope for intended recipients with no live subscription (§3.2). */ private async storeForOfflineRecipients(topic: string, env: Envelope, from?: string): Promise { const intended = Array.isArray(env.to) ? env.to : await this.opts.store.getMembers(topic); diff --git a/src/integration-test/broker-room-memory.test.ts b/src/integration-test/broker-room-memory.test.ts new file mode 100644 index 0000000..2f1e0d0 --- /dev/null +++ b/src/integration-test/broker-room-memory.test.ts @@ -0,0 +1,133 @@ +import { describe, test, expect, afterEach } from "bun:test"; +import { Broker } from "../broker"; +import { InMemoryStore } from "../backbone/store/memory-store"; +import { IdentityService } from "../backbone/identity-service"; +import { StorePskIdentityProvider } from "../backbone/identity/store-psk-identity-provider"; +import { buildTaskCompletedEnvelope } from "../task-completed"; +import type { Store } from "../backbone/store"; + +const ROOM = "checkout"; +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +/** Raw buffering WS client so we can observe non-event frames (e.g. `whiteboard`). */ +class WsClient { + ws!: WebSocket; + frames: any[] = []; + static async connect(url: string): Promise { + const c = new WsClient(); + c.ws = new WebSocket(url); + c.ws.onmessage = (ev) => c.frames.push(JSON.parse(ev.data as string)); + await new Promise((res, rej) => { + c.ws.onopen = () => res(); + c.ws.onerror = () => rej(new Error("ws connect failed")); + }); + return c; + } + send(m: unknown) { + this.ws.send(JSON.stringify(m)); + } + close() { + this.ws.close(); + } + async helloAndSubscribe(token: string) { + this.send({ type: "hello", token }); + await sleep(30); + this.send({ type: "subscribe", topic: ROOM }); + await sleep(40); + } +} + +async function startBroker(store: Store = new InMemoryStore()) { + const svc = new IdentityService(store); + await svc.registerIdentity("alice@x.com", "Alice"); + await svc.registerIdentity("bob@x.com", "Bob"); + const tokenA = await svc.issueToken("alice@x.com"); + const tokenB = await svc.issueToken("bob@x.com"); + const broker = new Broker({ store, identityProvider: new StorePskIdentityProvider(store), host: "127.0.0.1", port: 0, log: () => {} }); + const { port } = broker.start(); + return { broker, store, tokenA, tokenB, url: `ws://127.0.0.1:${port}/ws` }; +} + +function taskEnv(summary: string, contract?: string) { + return buildTaskCompletedEnvelope({ roomId: ROOM, from: { agentId: "bob@x.com", agentType: "codex" }, summary, contract }); +} + +describe("Broker room memory — ledger + whiteboard (§4)", () => { + let cleanup: Array<() => void> = []; + afterEach(() => { + for (const fn of cleanup) fn(); + cleanup = []; + }); + + test("a published task_completed is appended to the ledger; broker-synthesized presence is NOT", async () => { + const { broker, store, tokenA, tokenB, url } = await startBroker(); + cleanup.push(() => broker.stop()); + const alice = await WsClient.connect(url); // subscribing alice triggers a member_joined (presence) + cleanup.push(() => alice.close()); + await alice.helloAndSubscribe(tokenA); + + const bob = await WsClient.connect(url); + cleanup.push(() => bob.close()); + bob.send({ type: "hello", token: tokenB }); + await sleep(30); + bob.send({ type: "publish", topic: ROOM, envelope: taskEnv("auth done", "auth/v1") }); + await sleep(60); + + const events = await store.getRecentEvents(ROOM, 10); + expect(events).toHaveLength(1); // ONLY the task_completed — presence never hits the ledger + expect(events[0]!.kind).toBe("task_completed"); + const wb = await store.getWhiteboard(ROOM); + expect(wb!.recentMilestones).toHaveLength(1); + expect(wb!.contractsReady[0]).toMatchObject({ contract: "auth/v1" }); + }); + + test("a new member is handed the whiteboard on join (only on the 0→1 transition)", async () => { + const { broker, store, tokenA, tokenB, url } = await startBroker(); + cleanup.push(() => broker.stop()); + // bob publishes first so the whiteboard has content. + const bob = await WsClient.connect(url); + cleanup.push(() => bob.close()); + bob.send({ type: "hello", token: tokenB }); + await sleep(30); + bob.send({ type: "publish", topic: ROOM, envelope: taskEnv("checkout shipped", "checkout/v1") }); + await sleep(50); + expect((await store.getWhiteboard(ROOM))!.contractsReady).toHaveLength(1); + + // alice joins → receives a whiteboard frame. + const alice = await WsClient.connect(url); + cleanup.push(() => alice.close()); + await alice.helloAndSubscribe(tokenA); + const wbFrame = alice.frames.find((f) => f.type === "whiteboard"); + expect(wbFrame).toBeDefined(); + expect(wbFrame.roomId).toBe(ROOM); + expect(wbFrame.whiteboard.contractsReady[0]).toMatchObject({ contract: "checkout/v1" }); + + // a second subscribe on the SAME connection must not re-inject (no 0→1). + const before = alice.frames.filter((f) => f.type === "whiteboard").length; + alice.send({ type: "subscribe", topic: ROOM }); + await sleep(40); + expect(alice.frames.filter((f) => f.type === "whiteboard").length).toBe(before); + }); + + test("a ledger-write failure does NOT block live delivery (best-effort)", async () => { + const store = new InMemoryStore(); + (store as unknown as { appendEvent: () => Promise }).appendEvent = () => Promise.reject(new Error("ledger down")); + const { broker, tokenA, tokenB, url } = await startBroker(store); + cleanup.push(() => broker.stop()); + const alice = await WsClient.connect(url); + cleanup.push(() => alice.close()); + await alice.helloAndSubscribe(tokenA); + + const bob = await WsClient.connect(url); + cleanup.push(() => bob.close()); + bob.send({ type: "hello", token: tokenB }); + await sleep(30); + bob.send({ type: "publish", topic: ROOM, envelope: taskEnv("still delivered") }); + await sleep(60); + + // alice still received the event despite appendEvent throwing. + const evt = alice.frames.find((f) => f.type === "event" && f.envelope?.kind === "task_completed"); + expect(evt).toBeDefined(); + expect(evt.envelope.payload.summary).toBe("still delivered"); + }); +}); diff --git a/src/integration-test/broker-routing.test.ts b/src/integration-test/broker-routing.test.ts index c9dbb71..6b5aa42 100644 --- a/src/integration-test/broker-routing.test.ts +++ b/src/integration-test/broker-routing.test.ts @@ -18,9 +18,11 @@ class WsClient { c.ws = new WebSocket(url); c.ws.onmessage = (ev) => { const m = JSON.parse(ev.data as string); - // Ignore presence churn (§11.1 bullet 9): these tests assert on the routing - // of PUBLISHED envelopes, not member_joined/left (covered by broker-presence). + // Ignore presence churn (§11.1 bullet 9) + whiteboard injection (§4.4): + // these tests assert on the routing of PUBLISHED envelopes, not member_joined/ + // left (broker-presence) or the on-join whiteboard snapshot (broker-room-memory). if (m?.type === "event" && (m.envelope?.kind === "member_joined" || m.envelope?.kind === "member_left")) return; + if (m?.type === "whiteboard") return; const w = c.waiters.shift(); if (w) w(m); else c.q.push(m); diff --git a/src/integration-test/room-bridge.test.ts b/src/integration-test/room-bridge.test.ts index afa7db6..1ff472f 100644 --- a/src/integration-test/room-bridge.test.ts +++ b/src/integration-test/room-bridge.test.ts @@ -60,6 +60,29 @@ describe("startRoomBridge — last-mile broker→session injection (§11.1)", () expect(emitted).toEqual([]); }); + test("on join, the room whiteboard is rendered and injected (§4.4 new-member injection)", async () => { + const { dir, store, tokenB, broker, url, dbPath } = await setup(); + cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); + + // bob publishes a task_completed first so the room has a whiteboard. + const bob = new BrokerClient({ url, token: tokenB }); + await bob.connect(); + cleanup.push(() => bob.close()); + bob.publish( + ROOM, + buildTaskCompletedEnvelope({ roomId: ROOM, from: { agentId: "bob@x.com", agentType: "codex" }, summary: "auth done", contract: "auth/v1" }), + ); + await delay(120); // let the broker append + distil the whiteboard + + // alice's room bridge starts → subscribes → broker pushes the whiteboard snapshot. + const emitted: string[] = []; + const handle = await startRoomBridge({ cwd: dir, emit: (t) => emitted.push(t), store, dbPath, brokerUrl: url }); + cleanup.push(() => handle.stop()); + await waitFor(() => emitted.some((t) => t.includes("📋 房间白板"))); + const wbLine = emitted.find((t) => t.includes("📋 房间白板"))!; + expect(wbLine).toContain("auth/v1"); + }); + test("inert when cwd is not mapped to a room", async () => { const { dir, store, broker, url, dbPath } = await setup({ mapCwd: false }); cleanup.push(() => broker.stop(), () => rmSync(dir, { recursive: true, force: true })); diff --git a/src/room-bridge.ts b/src/room-bridge.ts index 4d524f1..6970ce7 100644 --- a/src/room-bridge.ts +++ b/src/room-bridge.ts @@ -46,6 +46,39 @@ function label(env: Envelope): string { return env.from?.name || (typeof dn === "string" ? dn : "") || env.from?.agentId || "某成员"; } +/** + * Render the on-join whiteboard snapshot (§4.4) into a one-line Chinese summary + * for the joining agent — counts + the few most-recent items, never the full + * (capped-50) slots. Returns null for an empty/absent board (nothing to inject). + */ +export function renderWhiteboard(wb: unknown): string | null { + if (!wb || typeof wb !== "object") return null; + const w = wb as { + contractsReady?: unknown[]; + inProgress?: unknown[]; + blockers?: unknown[]; + recentMilestones?: unknown[]; + }; + const arr = (x: unknown[] | undefined): Array> => + Array.isArray(x) ? (x as Array>) : []; + const contracts = arr(w.contractsReady); + const inProgress = arr(w.inProgress); + const blockers = arr(w.blockers); + const milestones = arr(w.recentMilestones); + if (contracts.length + inProgress.length + blockers.length + milestones.length === 0) return null; + const names = (items: Array>, key: string): string => + items + .slice(-3) + .map((it) => (typeof it[key] === "string" ? (it[key] as string) : "?")) + .join(key === "summary" ? " / " : ", "); + const parts = ["📋 房间白板"]; + if (contracts.length) parts.push(`已就绪契约 ${contracts.length}(${names(contracts, "contract")})`); + if (inProgress.length) parts.push(`进行中 ${inProgress.length}`); + if (blockers.length) parts.push(`阻塞 ${blockers.length}`); + if (milestones.length) parts.push(`最近:${names(milestones, "summary")}`); + return parts.join(" · "); +} + /** * Render a room Envelope into a one-line Chinese notice, or null for kinds the * MVP doesn't surface (those are simply not injected — never a raw payload dump). @@ -127,6 +160,11 @@ export async function startRoomBridge(deps: RoomBridgeDeps): Promise { + const text = renderWhiteboard(wb); + if (text) deps.emit(text); + }); client.subscribe(room); // queued in the subscription set; sent on the first welcome // Fire the connection but don't block daemon boot on it; BrokerClient reconnects // on its own, so a broker that isn't up yet will be picked up later. A bad token diff --git a/src/unit-test/room-bridge-render.test.ts b/src/unit-test/room-bridge-render.test.ts index c1b18d5..63daee2 100644 --- a/src/unit-test/room-bridge-render.test.ts +++ b/src/unit-test/room-bridge-render.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from "bun:test"; -import { renderRoomEvent } from "../room-bridge"; +import { renderRoomEvent, renderWhiteboard } from "../room-bridge"; import { buildTaskCompletedEnvelope } from "../task-completed"; import { buildPresenceEnvelope } from "../presence"; import type { Envelope } from "../backbone/envelope"; @@ -65,6 +65,25 @@ describe("renderRoomEvent — broker Envelope → one-line Claude notice", () => expect(renderRoomEvent(env)).toBeNull(); }); + test("renderWhiteboard summarizes counts + recent items; empty/absent ⇒ null", () => { + expect(renderWhiteboard(null)).toBeNull(); + expect(renderWhiteboard("nope")).toBeNull(); + expect( + renderWhiteboard({ contractsReady: [], inProgress: [], blockers: [], recentMilestones: [] }), + ).toBeNull(); + const text = renderWhiteboard({ + contractsReady: [{ contract: "auth/v1" }, { contract: "checkout/v1" }], + inProgress: [{ summary: "x" }], + blockers: [], + recentMilestones: [{ summary: "auth done" }, { summary: "checkout shipped" }], + })!; + expect(text).toContain("📋 房间白板"); + expect(text).toContain("已就绪契约 2"); + expect(text).toContain("auth/v1"); + expect(text).toContain("进行中 1"); + expect(text).toContain("checkout shipped"); + }); + test("label falls back from.name → payload.displayName → agentId → 某成员", () => { const base = { roomId: "r1", diff --git a/src/unit-test/whiteboard.test.ts b/src/unit-test/whiteboard.test.ts new file mode 100644 index 0000000..5312694 --- /dev/null +++ b/src/unit-test/whiteboard.test.ts @@ -0,0 +1,60 @@ +import { describe, test, expect } from "bun:test"; +import { mergeWhiteboard, emptyWhiteboard, MAX_WHITEBOARD_SLOT } from "../whiteboard"; +import { buildTaskCompletedEnvelope } from "../task-completed"; +import { buildPresenceEnvelope } from "../presence"; +import type { Envelope } from "../backbone/envelope"; +import type { WhiteboardRecord } from "../backbone/store"; + +function tc(opts: { summary: string; contract?: string; repo?: string; branch?: string; agentId?: string }): Envelope { + return buildTaskCompletedEnvelope({ + roomId: "r1", + from: { agentId: opts.agentId ?? "bob@x.com", agentType: "codex" }, + summary: opts.summary, + contract: opts.contract, + repo: opts.repo, + branch: opts.branch, + now: () => 100, + }); +} + +describe("mergeWhiteboard — zero-LLM mechanical merge (§4.2)", () => { + test("task_completed appends a milestone; a named contract also lands in contractsReady", () => { + const wb = mergeWhiteboard(null, tc({ summary: "auth done", contract: "auth/v1", repo: "app", branch: "main" }), () => 7)!; + expect(wb.recentMilestones).toHaveLength(1); + expect(wb.recentMilestones[0]).toMatchObject({ summary: "auth done", by: "bob@x.com", repo: "app", branch: "main" }); + expect(wb.contractsReady).toHaveLength(1); + expect(wb.contractsReady[0]).toMatchObject({ contract: "auth/v1", by: "bob@x.com", summary: "auth done" }); + expect(wb.updatedAt).toBe(7); + }); + + test("a completion without a contract only touches recentMilestones", () => { + const wb = mergeWhiteboard(null, tc({ summary: "wip" }))!; + expect(wb.recentMilestones).toHaveLength(1); + expect(wb.contractsReady).toHaveLength(0); + }); + + test("unmergeable kinds return the SAME reference (caller skips the Store write)", () => { + const prev: WhiteboardRecord = emptyWhiteboard("r1", () => 1); + const joined = buildPresenceEnvelope({ kind: "member_joined", roomId: "r1", agentId: "a", displayName: "A" }); + expect(mergeWhiteboard(prev, joined)).toBe(prev); // same ref + expect(mergeWhiteboard(null, joined)).toBeNull(); // null stays null + }); + + test("does not mutate the input record (immutable)", () => { + const prev = mergeWhiteboard(null, tc({ summary: "first" }))!; + const before = JSON.stringify(prev); + const next = mergeWhiteboard(prev, tc({ summary: "second" }))!; + expect(JSON.stringify(prev)).toBe(before); // prev untouched + expect(next.recentMilestones).toHaveLength(2); + }); + + test("each slot is capped at MAX_WHITEBOARD_SLOT, keeping the newest", () => { + let wb: WhiteboardRecord | null = null; + for (let i = 0; i < MAX_WHITEBOARD_SLOT + 10; i++) { + wb = mergeWhiteboard(wb, tc({ summary: `s${i}` })); + } + expect(wb!.recentMilestones).toHaveLength(MAX_WHITEBOARD_SLOT); + expect(wb!.recentMilestones[0]!.summary).toBe(`s10`); // oldest 10 dropped + expect(wb!.recentMilestones.at(-1)!.summary).toBe(`s${MAX_WHITEBOARD_SLOT + 9}`); + }); +}); diff --git a/src/whiteboard.ts b/src/whiteboard.ts new file mode 100644 index 0000000..66cf266 --- /dev/null +++ b/src/whiteboard.ts @@ -0,0 +1,67 @@ +import type { Envelope } from "./backbone/envelope"; +import type { WhiteboardItem, WhiteboardRecord } from "./backbone/store"; + +/** Per-slot cap (§4.3): keep the newest items, drop the oldest, so a slot can't grow unbounded. */ +export const MAX_WHITEBOARD_SLOT = 50; + +type SlotName = "contractsReady" | "inProgress" | "blockers" | "recentMilestones"; + +export function emptyWhiteboard(roomId: string, now: () => number = Date.now): WhiteboardRecord { + return { roomId, contractsReady: [], inProgress: [], blockers: [], recentMilestones: [], updatedAt: now() }; +} + +/** Derive the slot additions for an event. Empty ⇒ the kind doesn't touch the whiteboard. */ +function additionsFor(env: Envelope): Array<{ slot: SlotName; item: WhiteboardItem }> { + if (env.kind !== "task_completed") return []; // only completions distill today (note/etc. = future) + const p = (env.payload ?? {}) as { + summary?: string; + contract?: string; + repo?: string; + branch?: string; + commit?: string; + }; + const by = env.from?.agentId ?? "unknown"; + const ts = env.timestamp; + const milestone: WhiteboardItem = { summary: p.summary ?? "", by, ts }; + if (p.repo) milestone.repo = p.repo; + if (p.branch) milestone.branch = p.branch; + if (p.commit) milestone.commit = p.commit; + const out: Array<{ slot: SlotName; item: WhiteboardItem }> = [{ slot: "recentMilestones", item: milestone }]; + // A completion that names the contract it provides also lands in contractsReady, + // the "you can build on this now" slot (§4.2). + if (p.contract) out.push({ slot: "contractsReady", item: { contract: p.contract, by, ts, summary: p.summary ?? "" } }); + return out; +} + +function capSlot(items: WhiteboardItem[]): WhiteboardItem[] { + return items.length > MAX_WHITEBOARD_SLOT ? items.slice(items.length - MAX_WHITEBOARD_SLOT) : items; +} + +/** + * Mechanical, zero-LLM whiteboard merge (§4.2). Appends the event's distilled + * items to their slots (newest kept, per-slot capped), returning a NEW record — + * the input `prev` is never mutated. For a kind that doesn't touch the whiteboard + * it returns `prev` UNCHANGED (same reference, possibly null), so the caller can + * skip the Store write via an identity check. + */ +export function mergeWhiteboard( + prev: WhiteboardRecord | null, + env: Envelope, + now: () => number = Date.now, +): WhiteboardRecord | null { + const additions = additionsFor(env); + if (additions.length === 0) return prev; // unmergeable kind → no change (caller skips save) + const base = prev ?? emptyWhiteboard(env.roomId, now); + const next: WhiteboardRecord = { + roomId: env.roomId, + contractsReady: [...base.contractsReady], + inProgress: [...base.inProgress], + blockers: [...base.blockers], + recentMilestones: [...base.recentMilestones], + updatedAt: now(), + }; + for (const { slot, item } of additions) { + next[slot] = capSlot([...next[slot], item]); + } + return next; +}