summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--backend-handoff.md39
-rw-r--r--scripts/live-probe.ts150
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)));