import assert from "assert"; import { allVectors } from "./event-vectors/index.js"; // The reply-thread rollup is tested against the REAL implementation (chat.js // exports both as pure functions), not a reconstruction — grouping rules are // too easy to fork silently. import { viewItems, quoteTarget, threadTarget, threadItems, threadListItems, threadListSignature, inMainFeed, bySidIndex, claimConverts, applyConverts, inlineChainIds } from "../web/static/apps/chat.js"; // Views fold against the REAL app (views.js exports it) — its whole job is // clamping hostile events, so a reconstruction would test the wrong thing. import { viewsApp } from "../web/static/apps/views.js"; // 🤖 Vault folds against the REAL reducer too — these vectors are the // cross-IMPLEMENTATION contract with the Go fold (internal/vault), which reads // the same test/event-vectors/vault.json in its own test suite. import { app as vaultApp } from "../web/static/apps/vault.js"; // 🤖 Webhooks: the ignore-unclaimed-types contract runs against the REAL // dispatch gate, with the shipped webhooks app loaded (it must claim nothing). import { isAppType, foldEvent } from "../web/static/apps.js"; import "../web/static/apps/webhooks.js"; // 🤖 Agents fold against the REAL reducer too — the session.* vocabulary is // the contract the workstation bridge (`lchat agents run`) builds against. import { app as agentsApp } from "../web/static/apps/agents.js"; // Import chat.js fold handlers — the ones we're testing. // The app.hostEvents from web/static/apps/chat.js. // For testing purposes, we'll reconstruct them here so the test is self-contained. // Mirrors chat.js's chat.msg hostEvent — the SPEC, not a snapshot. Includes the // optimistic-send lifecycle: an emitted message folds PENDING (meta.pending, a // client id `_cid`, no server id) so it shows instantly; its own broadcast echo // (same _cid, now with a server id) UPGRADES that record in place rather than // duplicating it. Keep this in step with the real fold in web/static/apps/chat.js. function bySidPendingLast(a, b) { if (a.sid && b.sid) return a.sid - b.sid; if (!a.sid && !b.sid) return a.ts - b.ts; return a.sid ? -1 : 1; } function testChatMsgFold(state, event, meta) { const text = typeof event.text === "string" ? event.text : ""; const atts = Array.isArray(event.attachments) ? event.attachments.filter((a) => a && a.id) : []; const sid = (meta && meta.id) || 0; const cid = typeof event._cid === "string" ? event._cid : ""; const pending = !!(meta && meta.pending); if ((!text.trim() && !atts.length) || (!sid && !pending)) return state; if (sid && state.msgs.some((m) => m.sid === sid)) return state; if (cid) { const idx = state.msgs.findIndex((m) => m.cid === cid); if (idx >= 0) { if (!sid) return state; const upgraded = state.msgs.slice(); upgraded[idx] = { ...upgraded[idx], sid, ts: event.ts || upgraded[idx].ts, pending: false, slow: false }; upgraded.sort(bySidPendingLast); return { msgs: upgraded }; } } const raw = { name: event.name || "", text, ts: event.ts || ((meta && meta.ts) || 0) }; if (event.uid) raw.uid = event.uid; if (atts.length) raw.attachments = atts.map((a) => ({ ...a })); if (event.replyTo) raw.replyTo = { ...event.replyTo }; if (Array.isArray(event.actions) && event.actions.length) raw.actions = event.actions; if (event.action) raw.action = true; if (event.warn) raw.warn = true; const msgs = [ ...state.msgs, { id: sid ? "m" + sid : "c" + cid, sid, cid, pending, slow: false, text, name: raw.name, uid: event.uid || "", ts: raw.ts, attachments: raw.attachments || [], reply: event.replyTo ? { id: event.replyTo.id || 0, name: event.replyTo.name || "", snippet: event.replyTo.snippet || "", thread: !!event.replyTo.thread } : null, actions: raw.actions || [], action: !!event.action, warn: !!event.warn, reactions: [], edited: false, raw, rev: 0, }, ]; msgs.sort(bySidPendingLast); return { msgs }; } // Fold a vector's message_sequence exactly as shell.js routes it: an ordinary // payload folds as chat.msg, a "threadconvert" payload as chat.convert — the // claim map (the REAL claimConverts, so the oldest-wins rule can't fork here) // projected onto the loaded window after EVERY step, which is what makes a // message arriving after its own conversion land in the thread anyway. // // `skipConverts` folds the same sequence the way a client too old to know the // payload type does: it drops the event entirely and the chain stays inline. function foldSequence(sequence, { skipConverts = false } = {}) { let state = { msgs: [] }; let claims = {}; for (const step of sequence) { const payload = step.decrypted_payload; const meta = { id: step.server_message.id, ts: step.server_message.created_at * 1000 }; if (payload.type === "threadconvert") { if (skipConverts) continue; const next = claimConverts(claims, payload.root, payload.ids || [], meta.id); if (next) claims = next; } else { state = testChatMsgFold(state, { type: "chat.msg", ...payload }, meta); } state = { ...state, msgs: applyConverts(state.msgs, claims) }; } return state; } function testChatReactionFold(state, event, meta, ctx) { if (event.targetSid === undefined || event.emoji === undefined || event.uid === undefined || event.on === undefined) return state; const me = ctx && ctx.me; const msgs = state.msgs.map((m) => { if (m.sid !== event.targetSid) return m; const reactionEvents = m.reactionEvents || []; let found = false; const updated = reactionEvents.map((ev) => { if (ev.emoji === event.emoji && ev.uid === event.uid) { found = true; return { ...ev, on: event.on, ts: event.ts }; } return ev; }); if (!found && event.on) { updated.push({ emoji: event.emoji, uid: event.uid, on: true, ts: event.ts }); } const currentReactions = updated.filter((ev) => ev.on); const derived = []; const emojiMap = new Map(); for (const ev of currentReactions) { if (!emojiMap.has(ev.emoji)) { emojiMap.set(ev.emoji, []); } emojiMap.get(ev.emoji).push(ev.uid); } for (const [emoji, uids] of emojiMap) { derived.push({ emoji, count: uids.length, mine: uids.includes(me) }); } derived.sort((a, b) => a.emoji.localeCompare(b.emoji)); return { ...m, reactions: derived, reactionEvents: updated, rev: m.rev + 1 }; }); return { msgs }; } function testChatEditFold(state, event) { return { msgs: state.msgs.map((m) => { if (m.sid !== event.msgId) return m; const text = typeof event.text === "string" ? event.text : m.text; return { ...m, text, raw: { ...m.raw, text }, edited: true, rev: m.rev + 1 }; }), }; } function testChatDeleteFold(state, event) { return { msgs: state.msgs.filter((m) => m.sid !== event.msgId) }; } // Todo app fold handlers (web/static/apps/todo.js) function addItem(items, item) { if (items.some((i) => i.id === item.id)) return items; return [...items, item]; } function updateItem(items, id, updates) { return items.map((i) => (i.id === id ? { ...i, ...updates } : i)); } function removeItem(items, id) { return items.filter((i) => i.id !== id); } function testTodoAddFold(state, event) { const text = typeof event.text === "string" ? event.text : ""; if (!text || !event.id) return state; return { items: addItem(state.items, { id: event.id, text, done: false, doneAt: 0, by: event.name || "" }) }; } function testTodoDoneFold(state, event) { return { items: updateItem(state.items, event.id, { done: true, doneAt: event.ts || 0 }) }; } function testTodoReopenFold(state, event) { return { items: updateItem(state.items, event.id, { done: false, doneAt: 0 }) }; } function testTodoRemoveFold(state, event) { return { items: removeItem(state.items, event.id) }; } // Remind app fold handlers (web/static/apps/remind.js) function testRemindAddFold(state, event) { if (!event.id || !event.text || !event.when) return state; const items = addItem(state.items, { id: event.id, text: event.text, when: event.when, ts: event.ts || 0, sid: event.sid || "", fired: false, firedAt: 0, }); items.sort((a, b) => a.when - b.when); return { items }; } function testRemindFireFold(state, event) { return { items: updateItem(state.items, event.id, { fired: true, firedAt: event.ts || 0 }) }; } function testRemindDoneFold(state, event) { return { items: removeItem(state.items, event.id) }; } // Group-apps registry fold handlers (web/static/apps.js — the groupapps app) function testGroupAppsAddFold(state, event) { if (typeof event.app !== "string" || !event.app) return state; return { apps: addItem(state.apps, { id: event.app, by: event.name || "", ts: event.ts || 0 }) }; } function testGroupAppsRemoveFold(state, event) { return { apps: removeItem(state.apps, event.app) }; } // Test each vector let passes = 0; let fails = 0; function test(name, fn) { try { fn(); console.log(`PASS ${name}`); passes++; } catch (e) { console.error(`FAIL ${name}`); console.error(` ${e.message}`); fails++; } } // Optimistic-send lifecycle (the web/native contract for `_cid`). A message // folds PENDING the instant it's emitted, then its own echo silently upgrades // that same record — never a duplicate. Native must carry `_cid` through and // apply the same pairing. test("chat.optimistic: an emitted message folds pending, then its echo upgrades it in place", () => { const cid = "cid-abc"; // 1. Optimistic local echo: no server id yet, pending, keyed by cid. let state = testChatMsgFold({ msgs: [] }, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { cid, ts: 1000, pending: true }); assert.strictEqual(state.msgs.length, 1, "pending message shows immediately"); let m = state.msgs[0]; assert.strictEqual(m.sid, 0, "no server id while pending"); assert.strictEqual(m.pending, true, "flagged pending"); assert.strictEqual(m.cid, cid, "keyed by the client id"); assert.strictEqual(m.id, "c" + cid, "keyedList id is cid-stable so the bubble is never rebuilt"); // 2. The server broadcasts our own message back (same _cid, now a real id). state = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 }); assert.strictEqual(state.msgs.length, 1, "echo upgrades in place — no duplicate"); m = state.msgs[0]; assert.strictEqual(m.sid, 42, "adopts the server id"); assert.strictEqual(m.pending, false, "no longer pending"); assert.strictEqual(m.slow, false, "slow cleared"); assert.strictEqual(m.id, "c" + cid, "keyedList id stays stable across the upgrade"); // 3. A duplicate echo (catch-up on reopen) is idempotent. const again = testChatMsgFold(state, { text: "hi there", name: "Ada", uid: "u1", ts: 1000, _cid: cid }, { id: 42, ts: 1000 }); assert.strictEqual(again.msgs.length, 1, "re-folding the confirmed message changes nothing"); }); test("chat.optimistic: a pending copy sorts after confirmed messages, then slots into id order on confirm", () => { let state = testChatMsgFold({ msgs: [] }, { text: "first", ts: 10 }, { id: 100, ts: 10 }); state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { cid: "z", ts: 20, pending: true }); assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine"], "pending sits at the bottom"); // A higher-id message arriving before our echo still lands before the pending copy. state = testChatMsgFold(state, { text: "later", ts: 30 }, { id: 200, ts: 30 }); assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "later", "mine"], "pending stays last until confirmed"); state = testChatMsgFold(state, { text: "mine", ts: 20, _cid: "z" }, { id: 150, ts: 20 }); assert.deepStrictEqual(state.msgs.map((m) => m.text), ["first", "mine", "later"], "on confirm it slots into server order"); }); test("chat.optimistic: a normal server message carrying _cid still folds its text (native carry-and-ignore)", () => { const state = testChatMsgFold({ msgs: [] }, { text: "hello", name: "Bo", ts: 5, _cid: "unmatched" }, { id: 7, ts: 5 }); assert.strictEqual(state.msgs.length, 1, "folds like any message"); assert.strictEqual(state.msgs[0].text, "hello", "_cid never leaks into the text"); assert.strictEqual(state.msgs[0].sid, 7, "real id"); assert.strictEqual(state.msgs[0].pending, false, "not pending — it arrived confirmed"); }); allVectors.chat.forEach((vector) => { if (vector.expected_fold_event?.type === "chat.msg") { test(`chat.msg: ${vector.name} — folds correctly and produces expected event shape`, () => { const state = { msgs: [] }; const event = { type: "chat.msg", ...vector.decrypted_payload, }; const meta = { id: vector.server_message.id, ts: vector.server_message.created_at * 1000 }; const result = testChatMsgFold(state, event, meta); assert.strictEqual(result.msgs.length, 1, "should have one message"); const msg = result.msgs[0]; assert.strictEqual(msg.name, vector.expected_fold_event.name, `name should be '${vector.expected_fold_event.name}'`); assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match"); assert.strictEqual(msg.uid, vector.expected_fold_event.uid || "", "uid should match"); assert.strictEqual(msg.ts, vector.expected_fold_event.ts, "ts should match (ms)"); assert.strictEqual(msg.action, vector.expected_fold_event.action || false, "action flag should match"); assert.strictEqual(msg.warn, vector.expected_fold_event.warn || false, "warn flag should match"); // Attachments if (vector.expected_fold_event.attachments) { assert.deepStrictEqual(msg.attachments, vector.expected_fold_event.attachments, "attachments should match exactly"); } else { assert.strictEqual(msg.attachments.length, 0, "attachments should be empty"); } // Reply quote if (vector.expected_fold_event.replyTo) { assert.notStrictEqual(msg.reply, null, "reply should be present"); assert.strictEqual(msg.reply.id, vector.expected_fold_event.replyTo.id, "reply id should match"); assert.strictEqual(msg.reply.name, vector.expected_fold_event.replyTo.name, "reply name should match"); assert.strictEqual(msg.reply.snippet, vector.expected_fold_event.replyTo.snippet, "reply snippet should match"); // The thread flag (additive): true only when the payload says so — // absent folds to false, so pre-flag history stays an inline reply. assert.strictEqual(msg.reply.thread, !!vector.expected_fold_event.replyTo.thread, "reply.thread should match the payload's flag"); } else { assert.strictEqual(msg.reply, null, "reply should be null"); } // Main-feed visibility — against the REAL predicate and the REAL // transcript filter. expected_main_feed_visible is evaluated WITH the // quoted root in the loaded window (seeded here from the replyTo // snapshot): a thread-only reply then never lists in the feed (its // root's tally is its only trace there); a quote reply lists as always. // With the root paged out EVERY reply shows — the degradation the // thread_reply_root_unloaded vector pins. if (vector.expected_main_feed_visible !== undefined) { const rt = vector.decrypted_payload.replyTo; const seeded = rt ? testChatMsgFold(result, { type: "chat.msg", name: rt.name, text: rt.snippet, ts: msg.ts - 1000 }, { id: rt.id, ts: msg.ts - 1000 }) : result; assert.strictEqual(inMainFeed(msg, bySidIndex(seeded.msgs)), vector.expected_main_feed_visible, "inMainFeed (root loaded) should match expected_main_feed_visible"); const shown = viewItems(seeded.msgs, { mainFeed: true }).some((i) => i.sid === msg.sid); assert.strictEqual(shown, vector.expected_main_feed_visible, "the transcript filter (root loaded) should agree with expected_main_feed_visible"); assert.strictEqual(viewItems(seeded.msgs).some((i) => i.sid === msg.sid), true, "the unfiltered list (thread view) always keeps the reply"); assert.strictEqual(inMainFeed(msg, bySidIndex(result.msgs)), true, "root NOT loaded — every reply shows inline (nowhere else could carry it)"); assert.strictEqual(viewItems(result.msgs, { mainFeed: true }).some((i) => i.sid === msg.sid), true, "the transcript filter agrees: rootless → shown"); } }); } // Main-feed visibility over a folded SEQUENCE, against the REAL filter: a // thread-flagged reply hides only while its resolved root is in the // sequence (root loaded → its bubble carries the tally); rootless it shows // inline; every other message shows. if (vector.message_sequence && vector.expected_main_feed) { test(`chat main feed: ${vector.name} — thread replies hide only under a loaded root`, () => { const state = foldSequence(vector.message_sequence); const bySid = bySidIndex(state.msgs); const main = viewItems(state.msgs, { mainFeed: true }); const shown = new Set(main.map((i) => i.sid)); for (const [sid, visible] of Object.entries(vector.expected_main_feed)) { assert.strictEqual(shown.has(Number(sid)), visible, `message ${sid} should be ${visible ? "shown in" : "hidden from"} the main feed`); assert.strictEqual(inMainFeed(state.msgs.find((x) => x.sid === Number(sid)), bySid), visible, `inMainFeed(${sid}) should be ${visible}`); } assert.strictEqual(viewItems(state.msgs).length, state.msgs.length, "the unfiltered list (thread view) keeps every message"); if (vector.expected_threads) { const mainBySid = new Map(main.map((i) => [i.sid, i])); for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) { // A message hidden in its own thread has no transcript bubble to // carry a tally — the rollup itself is checked below, unfiltered. const row = mainBySid.get(Number(root)); if (!row) continue; assert.strictEqual(row.replyCount, count, `root ${root}'s transcript tally should count its thread replies (and only those)`); } } // The degradation an OLD client shows: it doesn't know the payload type, // so it drops the conversion and keeps rendering the chain inline — // exactly the feed it rendered before this shipped. Nothing breaks, and // it collapses the moment that client updates. if (vector.expected_main_feed_unconverted) { const old = foldSequence(vector.message_sequence, { skipConverts: true }); const oldShown = new Set(viewItems(old.msgs, { mainFeed: true }).map((i) => i.sid)); for (const [sid, visible] of Object.entries(vector.expected_main_feed_unconverted)) { assert.strictEqual(oldShown.has(Number(sid)), visible, `ignoring the conversion, message ${sid} should be ${visible ? "shown" : "hidden"} — an old client's feed`); } } }); } // The Threads list, over the same folded sequences: one row per thread root, // newest activity first. Its own loop (not folded into the grouping test // below) so it also covers thread_reply_root_unloaded, which carries no // expected_threads — and that vector is the whole point of `root_loaded`. if (vector.expected_thread_list) { test(`chat threads list: ${vector.name} — one row per thread, freshest first`, () => { const state = foldSequence(vector.message_sequence); const rows = threadListItems(state.msgs); assert.deepStrictEqual( rows.map((r) => ({ root: r.rootSid, root_loaded: r.rootLoaded, name: r.name, snippet: r.snippet, count: r.count })), vector.expected_thread_list.rows, "the Threads list's rows, in the order it shows them"); // The open panel's repaint gate reads the SHOWN fields and nothing else: // a fold that only moves the row's `last` pointer (an ordinary reply // landing in a thread already top of the list) must not rebuild the // buttons under a keyboard user, while anything the row displays must. if (rows.length) { const sig = threadListSignature(rows); assert.strictEqual(threadListSignature(rows.map((r) => ({ ...r, last: { sid: 999999 } }))), sig, "a change to `last` alone — which the row never shows — must not repaint"); for (const [field, changed] of [["count", 42], ["name", "Someone else"], ["snippet", "different words"]]) { assert.notStrictEqual(threadListSignature([{ ...rows[0], [field]: changed }, ...rows.slice(1)]), sig, `a change to ${field} — which the row shows — must repaint`); } } }); } // Reply-thread grouping vectors: fold the whole sequence, then assert the // thread rollup. A thread is its root plus the replies FLAGGED into it — // an inline quote chain resolves to nothing and tallies nowhere — plus the // two compose verbs (quoteTarget = the exact bubble, threadTarget = the root). if (vector.expected_threads) { test(`chat threads: ${vector.name} — only flagged replies join a thread`, () => { const state = foldSequence(vector.message_sequence); const items = viewItems(state.msgs); const bySid = new Map(items.map((i) => [i.sid, i])); for (const [sid, root] of Object.entries(vector.expected_threads.roots)) { assert.strictEqual(bySid.get(Number(sid))?.replyRoot, root, root ? `message ${sid} should belong to thread ${root}` : `message ${sid} should belong to NO thread`); } for (const [root, count] of Object.entries(vector.expected_threads.reply_counts)) { assert.strictEqual(bySid.get(Number(root))?.replyCount, count, `${root} should tally ${count} thread replies`); } for (const [root, first] of Object.entries(vector.expected_threads.first_reply)) { const firstReply = items.find((i) => i.replyRoot === Number(root)); assert.strictEqual(firstReply && firstReply.sid, first, `first reply in root ${root}'s thread should be ${first}`); } // The thread view's own list, straight off the pure selector. for (const [root, ids] of Object.entries(vector.expected_thread_items || {})) { if (root === "note") continue; assert.deepStrictEqual(threadItems(state.msgs, Number(root)).map((m) => m.sid), ids, `threadItems(${root}) should be exactly ${JSON.stringify(ids)}`); } // What "Convert to thread" would move from here — the same question the // menus ask to decide whether to offer the verb at all. Empty means the // message has no inline chain under it (nothing to convert), which is // also the state a converted chain leaves behind. for (const [root, ids] of Object.entries(vector.expected_inline_chain || {})) { assert.deepStrictEqual(inlineChainIds(state.msgs, Number(root)), ids, `inlineChainIds(${root}) should be exactly ${JSON.stringify(ids)}`); } // The compose seam: plain Reply quotes the exact message, Reply in // thread names the thread's root (or the message itself when it's in // no thread — that message becomes the new root). const targets = vector.expected_reply_targets; if (targets) { for (const [sid, want] of Object.entries(targets.quote || {})) { const m = state.msgs.find((x) => x.sid === Number(sid)); assert.deepStrictEqual(quoteTarget(m), want, `quoteTarget(${sid}) — plain Reply targets the exact message clicked`); } for (const [sid, want] of Object.entries(targets.thread || {})) { const m = state.msgs.find((x) => x.sid === Number(sid)); assert.deepStrictEqual(threadTarget(state.msgs, m), want, `threadTarget(${sid}) — Reply in thread targets the thread's root`); } } }); } if (vector.expected_fold_event?.type === "chat.reaction") { test(`chat.reaction: ${vector.name} — folds the encrypted reaction event into the target message`, () => { const p = vector.decrypted_payload; // Seed the target message with the reactor's reaction already on when the // vector removes it, so on=false has something to fold away. const seeded = p.on ? [] : [{ emoji: p.emoji, uid: p.uid, on: true, ts: p.ts - 1 }]; const state = { msgs: [{ sid: p.targetSid, uid: p.targetUid || "", reactions: [], reactionEvents: seeded, rev: 0 }] }; const event = { type: "chat.reaction", targetSid: p.targetSid, emoji: p.emoji, uid: p.uid, on: p.on, ts: p.ts }; assert.deepStrictEqual( { type: event.type, targetSid: event.targetSid, emoji: event.emoji, uid: event.uid, on: event.on }, vector.expected_fold_event, "fold event derived from the payload should match the vector" ); const result = testChatReactionFold(state, event, {}, { me: "user-someone-else" }); assert.strictEqual(result.msgs.length, 1, "should have one message"); const msg = result.msgs[0]; if (p.on) { assert.deepStrictEqual(msg.reactions, [{ emoji: p.emoji, count: 1, mine: false }], "add derives a count of 1, not mine"); } else { assert.deepStrictEqual(msg.reactions, [], "remove folds the reaction away"); } assert.strictEqual(msg.rev, 1, "a reaction bumps the rev"); // Folding the same event again is idempotent on the derived reactions. const again = testChatReactionFold(result, event, {}, { me: "user-someone-else" }); assert.deepStrictEqual(again.msgs[0].reactions, msg.reactions, "re-fold is idempotent"); }); } if (vector.expected_fold_event?.type === "chat.edit") { test(`chat.edit: ${vector.name} — edits message text and marks as edited`, () => { const state = { msgs: [{ sid: vector.server_message.id, text: vector.original_payload.text, edited: false, rev: 0, raw: { text: vector.original_payload.text } }] }; const event = { type: "chat.edit", msgId: vector.server_message.id, text: vector.expected_fold_event.text, }; const result = testChatEditFold(state, event); assert.strictEqual(result.msgs.length, 1, "should have one message"); const msg = result.msgs[0]; assert.strictEqual(msg.text, vector.expected_fold_event.text, "text should match edit"); assert.strictEqual(msg.edited, true, "edited flag should be true"); assert.strictEqual(msg.rev, 1, "rev should increment"); }); } if (vector.name === "edit") { test("chat.edit over a pre-seeded store: history fold's text overwrites stale cached text (#457)", () => { // The offline-edit path over the local message store: the cache seeded // state with the ORIGINAL text; the reconcile refetch folds the edited // row — its chat.msg dedupes against the stale bubble (same sid), so // the synthetic chat.edit MUST carry the post-edit text or the stale // text survives forever. This is why shell.js's history fold includes // `text` — do not drop it as redundant (see the vector's note). const sid = vector.server_message.id; // 1. cached paint: the pre-edit payload folds in let state = testChatMsgFold({ msgs: [] }, { ...vector.original_payload }, { id: sid, ts: vector.original_payload.ts }); assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "cache seeded the original text"); // 2. reconcile: the refetched (edited) row's chat.msg is a dedupe no-op… state = testChatMsgFold(state, { ...vector.edited_payload }, { id: sid, ts: vector.edited_payload.ts }); assert.strictEqual(state.msgs[0].text, vector.original_payload.text, "chat.msg dedupes — stale text still shown"); // …so only the history chat.edit (WITH text) can heal the bubble. state = testChatEditFold(state, { type: "chat.edit", msgId: sid, text: vector.edited_payload.text }); assert.strictEqual(state.msgs[0].text, vector.expected_fold_event.text, "edit fold healed the stale text"); assert.strictEqual(state.msgs[0].edited, true, "marked edited"); }); } if (vector.expected_fold_event?.type === "chat.delete") { test(`chat.delete: ${vector.name} — removes message from state`, () => { const msgId = vector.expected_fold_event.msgId; const state = { msgs: [ { sid: msgId - 1, text: "before" }, { sid: msgId, text: "deleted" }, { sid: msgId + 1, text: "after" }, ], }; const event = { type: "chat.delete", msgId: msgId, }; const result = testChatDeleteFold(state, event); assert.strictEqual(result.msgs.length, 2, "should have two messages"); assert.strictEqual(result.msgs[0].sid, msgId - 1, "first message preserved"); assert.strictEqual(result.msgs[1].sid, msgId + 1, "last message preserved"); assert(!result.msgs.some((m) => m.sid === msgId), "deleted message should not be present"); }); } if (vector.name === "tombstone_delete") { test(`tombstone: ${vector.name} — stored row folds like the live delete frame`, () => { // The translation rule (shell.js chatAppFoldMessage / foldAppThreadMessage): // a history row carrying tombstone_of folds { type:"chat.delete", // msgId: tombstone_of } — byte-identical to the live frame's fold event, // so there is exactly one delete path. const row = vector.server_message; assert.strictEqual(row.blob, "", "tombstone rows carry no ciphertext"); assert(row.id > row.tombstone_of, "tombstone id is fresh — above the id it unsends"); const event = { type: "chat.delete", msgId: row.tombstone_of }; assert.deepStrictEqual(event, vector.expected_fold_event, "translation matches the pinned fold event"); const state = { msgs: [{ sid: row.tombstone_of, text: "unsent" }, { sid: row.tombstone_of + 1, text: "after" }] }; const result = testChatDeleteFold(state, event); assert(!result.msgs.some((m) => m.sid === row.tombstone_of), "target dropped from state"); assert.strictEqual(result.msgs.length, 1, "only the target dropped"); // A client that never held the message (fresh load) folds it as a no-op. const fresh = testChatDeleteFold({ msgs: [{ sid: 1, text: "x" }] }, event); assert.strictEqual(fresh.msgs.length, 1, "no-op on a client with no local copy"); }); } }); // The GROUP chat projections are cross-platform spec (AppChatProjection // mirrors the wording natively): run the REAL app's chatMessage against any // vector that declares one — the fold tests above use local handlers, but // wording must come from the shipping module. const { app: realTodoApp } = await import("../web/static/apps/todo.js"); allVectors.todo.forEach((vector) => { if (!("expected_group_chat_projection" in vector)) return; test(`todo group projection: ${vector.name}`, () => { const line = realTodoApp.chatMessage(vector.event, { group: true }); const expected = vector.expected_group_chat_projection; if (expected === null) { assert.strictEqual(line, null); return; } const got = typeof line === "string" ? { text: line, action: false } : { text: line.text, action: !!line.action }; assert.deepStrictEqual(got, expected); }); }); // Test todo vectors allVectors.todo.forEach((vector) => { if (vector.name === "todo_add") { test(`todo.add: ${vector.name} — folds correctly`, () => { const state = { items: [] }; const event = vector.event; const result = testTodoAddFold(state, event); assert.strictEqual(result.items.length, 1, "should have one item"); const item = result.items[0]; assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match"); assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match"); assert.strictEqual(item.done, false, "done should be false"); assert.strictEqual(item.doneAt, 0, "doneAt should be 0"); assert.strictEqual(item.by, vector.expected_state_update.items[0].by, "by should match"); }); } if (vector.name === "todo_done") { test(`todo.done: ${vector.name} — marks as done with timestamp`, () => { const state = { items: vector.initial_state.items }; const event = vector.event; const result = testTodoDoneFold(state, event); assert.strictEqual(result.items.length, 1, "should have one item"); const item = result.items[0]; assert.strictEqual(item.done, true, "done should be true"); assert.strictEqual(item.doneAt, vector.expected_state_update.items[0].doneAt, "doneAt should match"); }); } if (vector.name === "todo_reopen") { test(`todo.reopen: ${vector.name} — unmarks as done`, () => { const state = { items: vector.initial_state.items }; const event = vector.event; const result = testTodoReopenFold(state, event); assert.strictEqual(result.items.length, 1, "should have one item"); const item = result.items[0]; assert.strictEqual(item.done, false, "done should be false"); assert.strictEqual(item.doneAt, 0, "doneAt should be 0"); }); } if (vector.name === "todo_remove") { test(`todo.remove: ${vector.name} — deletes item`, () => { const state = { items: vector.initial_state.items }; const event = vector.event; const result = testTodoRemoveFold(state, event); assert.strictEqual(result.items.length, 1, "should have one item left"); assert.strictEqual(result.items[0].id, "todo-2", "remaining item should be todo-2"); }); } }); // Test remind vectors allVectors.remind.forEach((vector) => { if (vector.name === "remind_add") { test(`remind.add: ${vector.name} — folds correctly`, () => { const state = { items: [] }; const event = vector.event; const result = testRemindAddFold(state, event); assert.strictEqual(result.items.length, 1, "should have one item"); const item = result.items[0]; assert.strictEqual(item.id, vector.expected_state_update.items[0].id, "id should match"); assert.strictEqual(item.text, vector.expected_state_update.items[0].text, "text should match"); assert.strictEqual(item.when, vector.expected_state_update.items[0].when, "when should match"); assert.strictEqual(item.sid, vector.expected_state_update.items[0].sid, "sid should match"); assert.strictEqual(item.fired, false, "fired should be false"); }); } if (vector.name === "remind_fire") { test(`remind.fire: ${vector.name} — marks as fired`, () => { const state = { items: vector.initial_state.items }; const event = vector.event; const result = testRemindFireFold(state, event); assert.strictEqual(result.items.length, 1, "should have one item"); const item = result.items[0]; assert.strictEqual(item.fired, true, "fired should be true"); assert.strictEqual(item.firedAt, vector.expected_state_update.items[0].firedAt, "firedAt should match"); }); } if (vector.name === "remind_done") { test(`remind.done: ${vector.name} — removes reminder`, () => { const state = { items: vector.initial_state.items }; const event = vector.event; const result = testRemindDoneFold(state, event); assert.strictEqual(result.items.length, 0, "should have no items"); }); } if (vector.name === "remind_multiple_sorted") { test(`remind.multiple_sorted: ${vector.name} — keeps items sorted by when`, () => { let state = { items: [] }; for (const e of vector.event_sequence) { state = testRemindAddFold(state, e); } assert.strictEqual(state.items.length, 2, "should have two items"); assert.strictEqual(state.items[0].id, "remind-2", "first item should be remind-2 (soonest)"); assert.strictEqual(state.items[1].id, "remind-1", "second item should be remind-1 (latest)"); assert.strictEqual(state.items[0].when, 1700003600000, "first should be soonest"); assert.strictEqual(state.items[1].when, 1700010000000, "second should be latest"); }); } }); // Test group-apps registry vectors (which apps a group chat has added) allVectors.groupapps.forEach((vector) => { test(`groupapps: ${vector.name} — folds to the expected registry state`, () => { const state = { apps: (vector.initial_state && vector.initial_state.apps) || [] }; const result = vector.event.type === "groupapps.remove" ? testGroupAppsRemoveFold(state, vector.event) : testGroupAppsAddFold(state, vector.event); assert.deepStrictEqual(result.apps, vector.expected_state_update.apps, "registry state should match the vector exactly"); }); }); // Space-apps registry fold handlers (web/static/apps.js — the spaceapps app). // The groupapps contract one level up, plus the adder's uid on each item — // the admins-only install policy filters by it at read time. function testSpaceAppsAddFold(state, event) { if (typeof event.app !== "string" || !event.app) return state; return { apps: addItem(state.apps, { id: event.app, by: event.name || "", uid: event.uid || "", ts: event.ts || 0 }) }; } // Test space-apps registry vectors (which apps are installed for the whole space). allVectors.spaceapps.forEach((vector) => { test(`spaceapps: ${vector.name} — folds to the expected registry state`, () => { const state = { apps: (vector.initial_state && vector.initial_state.apps) || [] }; const result = vector.event.type === "spaceapps.remove" ? testGroupAppsRemoveFold(state, vector.event) : testSpaceAppsAddFold(state, vector.event); assert.deepStrictEqual(result.apps, vector.expected_state_update.apps, "registry state should match the vector exactly"); }); }); // Admin app fold handlers (web/static/apps/admin.js) — the update stream. // `seen` is the posted-entry watermark; note folds are idempotent, baselines merge. function testAdminNoteFold(state, event) { if (typeof event.id !== "string" || !event.id || (state.seen || []).includes(event.id)) return state; return { seen: [...(state.seen || []), event.id] }; } function testAdminBaselineFold(state, event) { if (!Array.isArray(event.ids)) return state; return { seen: [...new Set([...(state.seen || []), ...event.ids.filter((x) => typeof x === "string" && x)])] }; } // The chat projection: a note is a bubble from Admin (a plain string — never an // action line); a baseline is silent (null). function testAdminChatProjection(event) { if (event.type === "admin.note" && event.text) return event.text; return null; } // Test admin vectors (the update stream in place of the retired Announcer) allVectors.admin.forEach((vector) => { test(`admin: ${vector.name} — folds to the expected state`, () => { const state = { seen: (vector.initial_state && vector.initial_state.seen) || [] }; const result = vector.event.type === "admin.baseline" ? testAdminBaselineFold(state, vector.event) : testAdminNoteFold(state, vector.event); assert.deepStrictEqual(result.seen, vector.expected_state_update.seen, "seen watermark should match the vector exactly"); }); if ("expected_chat_projection" in vector) { test(`admin: ${vector.name} — chat projection matches`, () => { const line = testAdminChatProjection(vector.event); const expected = vector.expected_chat_projection; if (expected === null) { assert.strictEqual(line, null, "projection should be silent"); } else { assert.strictEqual(typeof line, "string", "a note projects as a message bubble (a string), not an action line"); assert.strictEqual(line, expected.text, "bubble text should match"); assert.strictEqual(expected.action, false, "a note is never an action line"); } }); } }); // Views vectors (docs/views.md) — folded by the real handlers, so the clamps // (url schemes, caps, update-never-creates) are pinned exactly as shipped. allVectors.views.forEach((vector) => { test(`views: ${vector.name} — folds to the expected state`, () => { const state = { views: (vector.initial_state && vector.initial_state.views) || [] }; const fold = viewsApp.events[vector.event.type]; assert.strictEqual(typeof fold, "function", `views app owns ${vector.event.type}`); const result = fold(state, vector.event); assert.deepStrictEqual(result.views, vector.expected_state_update.views, "views state should match the vector exactly"); }); }); // 🤖 Webhook vectors (docs/webhooks.md, #610) — the contract here is the // ABSENCE of handling: webhooks. events are unclaimed until a consumer app // registers the exact type, and unclaimed typed events must be silently // ignored. Tested against the REAL dispatch gate (isAppType/foldEvent from // apps.js) with the shipped webhooks management app loaded, so a future change // that accidentally claims (or crashes on) these types fails here. Native pins // the same contract in WebhooksVectorTests.swift (MessagePayload.isRenderableChat). allVectors.webhooks.forEach((vector) => { test(`webhooks: ${vector.name} — unclaimed type is silently ignored`, () => { assert.strictEqual(vector.expected, "ignored", "webhook vectors document the ignore contract"); assert.strictEqual(isAppType(vector.event.type), false, `${vector.event.type} must be unclaimed (no shipped app owns webhooks.* types)`); const folded = foldEvent("thread-1", vector.event, { id: 1, ts: vector.event.ts }); assert.strictEqual(folded, null, "foldEvent must drop an unclaimed type without error"); }); }); // 🤖 Vault vectors (docs/locked-vaults.md) — folded by the real reducer and // compared in CANONICAL form (every field present, ""/0 defaults, keyring null // when unlocked), the same normalisation the Go side applies, so web and CLI // pin each other through one file. function canonVault(state) { const s = state || {}; const item = (i) => ({ id: i.id || "", name: i.name || "", value: typeof i.value === "string" ? i.value : "", sealed: typeof i.sealed === "string" ? i.sealed : "", kind: i.kind || "text", by: i.by || "", byName: i.byName || "", at: i.at || 0, updatedBy: i.updatedBy || "", updatedByName: i.updatedByName || "", updatedAt: i.updatedAt || 0, }); const entry = (l) => ({ id: l.id || "", ref: l.ref || "", action: l.action || "", name: l.name || "", by: l.by || "", byName: l.byName || "", at: l.at || 0 }); const wrap = (w) => ({ id: w.id || "", kind: w.kind || "", label: w.label || "", uid: w.uid || "", credId: w.credId || "", wrapped: w.wrapped || "" }); return { items: (s.items || []).map(item), log: (s.log || []).map(entry), keyring: s.keyring ? { v: s.keyring.v || 0, pub: s.keyring.pub || "", wraps: (s.keyring.wraps || []).map(wrap) } : null, }; } allVectors.vault.forEach((vector) => { test(`vault: ${vector.name} — folds to the expected state`, () => { let state; // undefined — reduce seeds initialState on the first event for (const event of vector.event_sequence) { state = vaultApp.reduce(state, event); } assert.deepStrictEqual(canonVault(state), canonVault(vector.expected_state), "vault state should match the vector exactly"); }); }); // 🤖 Agents vectors (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md) // — folded by the REAL reducer: the fleet-board fold's whole job is clamping // what an unsigned group-thread payload claims, so a reconstruction would test // the wrong thing. The bridge (`lchat agents run`) builds against this file. allVectors.agents.forEach((vector) => { test(`agents: ${vector.name} — folds to the expected state`, () => { let state; if (vector.event_sequence) { for (const event of vector.event_sequence) state = agentsApp.reduce(state, event); assert.deepStrictEqual(state.sessions, vector.expected_state.sessions, "agents state should match the vector exactly"); } else { state = { sessions: (vector.initial_state && vector.initial_state.sessions) || [] }; const fold = agentsApp.events[vector.event.type]; assert.strictEqual(typeof fold, "function", `agents app owns ${vector.event.type}`); const result = fold(state, vector.event); assert.deepStrictEqual(result.sessions, vector.expected_state_update.sessions, "agents state should match the vector exactly"); } }); if ("expected_group_chat_projection" in vector) { test(`agents: ${vector.name} — group feed projection matches`, () => { const event = vector.expected_group_chat_projection_of || vector.event; const line = agentsApp.groupFeedMessage(event); const expected = vector.expected_group_chat_projection; if (expected === null) { assert.strictEqual(line, null, "projection should be silent"); return; } const text = typeof line === "string" ? line : line && line.text; const action = typeof line === "object" && line !== null && !!line.action; assert.strictEqual(text, expected.text, "projection text should match"); assert.strictEqual(action, !!expected.action, "action-vs-bubble should match"); }); } }); if (fails) process.exit(1);