diff options
| author | Adam Malczewski <[email protected]> | 2026-06-07 00:57:38 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-07 00:57:38 +0900 |
| commit | 08866b6c40be89f152a2dc5961d074666ea9d700 (patch) | |
| tree | 4b15d477935c5c4590f7d6df2ed1f827056162a2 | |
| parent | 7889918d23ffa428cf266e52d42b9683f16160fa (diff) | |
| download | dispatch-web-08866b6c40be89f152a2dc5961d074666ea9d700.tar.gz dispatch-web-08866b6c40be89f152a2dc5961d074666ea9d700.zip | |
Slice 2 live-verified: e2e chat probe 9/9 against running backend
- scripts/live-probe.ts: gated bun harness driving the real FE stack
(adapters/ws + core/chunks + conversation-cache + adapters/idb + HTTP
history) against bin/up; not part of `bun run test`
- backend-handoff.md: record the 9/9 live result; no backend mismatch
| -rw-r--r-- | backend-handoff.md | 39 | ||||
| -rw-r--r-- | scripts/live-probe.ts | 150 |
2 files changed, 171 insertions, 18 deletions
diff --git a/backend-handoff.md b/backend-handoff.md index d32ff7e..6a1b420 100644 --- a/backend-handoff.md +++ b/backend-handoff.md @@ -5,7 +5,7 @@ > **From:** dispatch-web orchestrator · **To:** arch-rewrite orchestrator · **Courier:** the user. > `lsp` does NOT span the repos (ORCHESTRATOR §5) — every cross-repo ask flows through here. -_Last updated: 2026-06-06 — Slice 2 FE-complete (unit + integration green); awaiting a LIVE probe._ +_Last updated: 2026-06-06 — Slice 2 LIVE-VERIFIED end-to-end against the running backend (9/9). ✅_ --- @@ -14,7 +14,7 @@ _Last updated: 2026-06-06 — Slice 2 FE-complete (unit + integration green); aw | Slice | State | |---|---| | **Slice 1** — surface system + WS + composition root | ✅ DONE, committed, green. | -| **Slice 2** — conversation transcript: cache + delta streaming (design §6) | ✅ FE-COMPLETE, committed — svelte-check 0/0, **218 vitest** (stable x2), biome clean, build ok. **Not yet live-probed against a running backend** (see §6). | +| **Slice 2** — conversation transcript: cache + delta streaming (design §6) | ✅ DONE + **LIVE-VERIFIED** — svelte-check 0/0, **221 vitest**, biome clean, build ok; live e2e probe **9/9** against `bin/up` (see §6). | **Slice 2 units built** (all pure-core / injected-shell, single-owner): `core/chunks` (the one transcript reducer) · `core/wire` (contract-conformance drift guard) · `adapters/ws` (now multiplexes @@ -58,22 +58,25 @@ Mirrored in-repo for headless agents: `.dispatch/ui-contract.reference.md`, `.di ### 3.3 Pending asks / roadblocks - _(none open)_ — Slice 2 needed no backend change. One coordination item below (§6). -## 6. Recommended next: a LIVE end-to-end probe (coordination, not a change) - -Slice 2 is unit/integration-green, but per the Slice-1 lesson (an effectful transport SHELL is -exactly where integration bugs hide — the WS-upgrade bug only surfaced live), the FE chat path should -be probed against a **running backend** before we call it done: -- WS `chat.send` → `chat.delta` stream over `:24205`, and the post-`turn-sealed` resync via - `GET /conversations/:id?sinceSeq` over `:24203` (CORS from the `:24204` page origin). -- FE expectations being validated: one socket multiplexes surface + chat; deltas fold into a - provisional turn; on `turn-sealed` the FE refetches `?sinceSeq` and the authoritative seq'd chunks - supersede the provisional ones; IndexedDB caches sealed turns. - -**Ask to the backend orchestrator (via courier):** confirm a known-good local boot (`bin/up`?) with -the HTTP `:24203` + WS `:24205` servers both up, and — if convenient — a minimal scripted chat turn -we can point the FE dev server (`bun run dev`, `:24204`) at. The FE can drive the probe from the -browser; we just need the backend running with a real model credential. Report any shape/behaviour -mismatch back here in §3.3. +## 6. LIVE end-to-end probe — DONE ✅ (9/9, against `bin/up`) + +Ran `bun scripts/live-probe.ts` (drives the FE's REAL network-facing stack — `adapters/ws` socket, +`core/chunks` reducer, `conversation-cache` + `adapters/idb`, and the HTTP history endpoint — against +the running backend). **All 9 checks passed:** +- one WS (`:24205`) delivered the surface `catalog` AND the chat stream; +- `chat.send` → ~33 `chat.delta` events (incl. `text-delta`) → folded to the expected assistant text + → `turn-sealed`; +- post-seal `GET :24203/conversations/:id?sinceSeq=0` → 3 seq-monotonic `StoredChunk`s + (`latestSeq=3`); `applyHistory` superseded the provisional turn (`sealedTurnId` cleared); +- IndexedDB cache persisted the sealed turn; committed transcript shows the assistant text. + +**No backend mismatch found — every confirmed invariant (C1–C4) held live.** One FE-internal note +(not a backend matter): the idb adapter relies on the global `IDBKeyRange` (fine in a browser; the +probe needed `fake-indexeddb/auto` to supply it under Bun). + +Also caught + fixed during browser bring-up (FE-only bug, not backend): a BLANK page on plain-HTTP +non-localhost origins (`http://arch-razer:24204`) because `crypto.randomUUID()` is secure-context-only +— now replaced with a `getRandomValues`-based fallback. ## 4. (history) Slice 2 unit map — delivered, see §1. diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts new file mode 100644 index 0000000..a78fb4f --- /dev/null +++ b/scripts/live-probe.ts @@ -0,0 +1,150 @@ +/** + * scripts/live-probe.ts — LIVE end-to-end probe of the FE chat path against a + * RUNNING backend (bin/up: HTTP :24203 + surface WS :24205). NOT part of + * `bun run test`. Run with the backend up: + * + * bun scripts/live-probe.ts # default model + * PROBE_MODEL=opencode/glm-5 bun scripts/live-probe.ts + * + * Drives the FE's REAL network-facing modules (the thin live integration test the + * methodology calls for — the analogue of the backend's server.bun.test.ts): + * - adapters/ws createSurfaceSocket → real WebSocket, one socket multiplexes + * the surface `catalog` AND chat ops. + * - core/chunks foldEvent/applyHistory → fold REAL chat.delta AgentEvents. + * - features/conversation-cache + adapters/idb (fake-indexeddb) → real cache. + * - HTTP GET /conversations/:id?sinceSeq → real ConversationHistoryResponse. + * Skips the runes chat store + svelte UI (need the Svelte compiler; thin wrappers). + */ + +// Provides globalThis.indexedDB + IDBKeyRange etc. for the idb adapter (a real +// browser has these natively; Bun does not). The product code is unchanged. +import "fake-indexeddb/auto"; +import type { + ChatDeltaMessage, + ChatErrorMessage, + ConversationHistoryResponse, +} from "@dispatch/transport-contract"; +import type { SurfaceServerMessage } from "@dispatch/ui-contract"; +import { createIdbChunkStore } from "../src/adapters/idb/index.ts"; +import { createSurfaceSocket } from "../src/adapters/ws/index.ts"; +import { applyHistory, foldEvent, initialState, selectMessages } from "../src/core/chunks/index.ts"; +import { createConversationCache } from "../src/features/conversation-cache/index.ts"; + +const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205"; +const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203"; +const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash"; +const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: hello from dispatch"; +const conversationId = crypto.randomUUID(); + +const checks: { name: string; ok: boolean; detail?: string }[] = []; +const record = (name: string, ok: boolean, detail?: string) => { + checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) }); + console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`); +}; + +function fail(msg: string): never { + console.error(`\n[live-probe] FATAL: ${msg}`); + process.exit(1); +} + +async function historySync(id: string, sinceSeq: number): Promise<ConversationHistoryResponse> { + const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`; + const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } }); + if (!res.ok) fail(`history fetch ${res.status} for ${url}`); + return (await res.json()) as ConversationHistoryResponse; +} + +async function main() { + console.log(`[live-probe] conversation=${conversationId} model=${MODEL}`); + console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`); + + const cache = createConversationCache(createIdbChunkStore()); + + let state = initialState(); + let gotCatalog = false; + let deltaCount = 0; + let sawTextDelta = false; + let sawSeal = false; + const done = Promise.withResolvers<void>(); + + const onChat = (msg: ChatDeltaMessage | ChatErrorMessage) => { + if (msg.type === "chat.error") { + record("no chat.error", false, msg.message); + done.resolve(); + return; + } + deltaCount++; + if (msg.event.type === "text-delta") sawTextDelta = true; + state = foldEvent(state, msg.event); + if (msg.event.type === "turn-sealed") { + sawSeal = true; + done.resolve(); + } + }; + + const socket = createSurfaceSocket({ + url: WS_URL, + onMessage: (m: SurfaceServerMessage) => { + if (m.type === "catalog") { + gotCatalog = true; + console.log(` ↳ surface catalog: ${m.catalog.length} surface(s)`); + } + }, + onChat, + }); + + // Give the socket a moment to open + deliver the catalog, then send the turn. + await new Promise((r) => setTimeout(r, 500)); + record("WS connected + surface catalog received", gotCatalog); + + console.log(`\n[live-probe] sending chat.send: "${PROMPT}"`); + socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL }); + + // Wait for turn-sealed (or error), with a hard timeout. + const timeout = setTimeout(() => done.resolve(), 90_000); + await done.promise; + clearTimeout(timeout); + + record("received chat.delta events", deltaCount > 0, `${deltaCount} deltas`); + record("saw text-delta", sawTextDelta); + record("turn reached turn-sealed", sawSeal); + + const provisionalText = selectMessages(state) + .flatMap((m) => m.chunks) + .filter((c) => c.type === "text") + .map((c) => (c as { text: string }).text) + .join(""); + console.log(`\n ↳ streamed assistant text (provisional): ${JSON.stringify(provisionalText)}`); + + // Post-seal: resync authoritative seq'd history + commit to cache (the real path). + const sinceSeq = await cache.sinceSeq(conversationId); + const hist = await historySync(conversationId, sinceSeq); + record("history endpoint returned chunks", hist.chunks.length > 0, `${hist.chunks.length} chunks, latestSeq=${hist.latestSeq}`); + const monotonic = hist.chunks.every((c, i) => i === 0 || c.seq > (hist.chunks[i - 1]?.seq ?? -1)); + record("history chunks are seq-monotonic", monotonic); + + const merged = await cache.commit(conversationId, hist.chunks); + state = applyHistory(state, merged); + record("provisional superseded after applyHistory (sealedTurnId cleared)", state.sealedTurnId === null); + + const cached = await cache.load(conversationId); + record("IndexedDB cache persisted the turn", cached.length === hist.chunks.length, `${cached.length} cached`); + + const committedText = selectMessages(state) + .filter((m) => m.role === "assistant") + .flatMap((m) => m.chunks) + .filter((c) => c.type === "text") + .map((c) => (c as { text: string }).text) + .join(""); + console.log(` ↳ committed assistant text (post-sync): ${JSON.stringify(committedText)}`); + record("committed transcript has assistant text", committedText.length > 0); + + socket.close(); + + const passed = checks.filter((c) => c.ok).length; + const total = checks.length; + console.log(`\n[live-probe] ${passed}/${total} checks passed`); + process.exit(passed === total ? 0 : 1); +} + +main().catch((e) => fail(String(e))); |
