summaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-07 00:57:38 +0900
committerAdam Malczewski <[email protected]>2026-06-07 00:57:38 +0900
commit08866b6c40be89f152a2dc5961d074666ea9d700 (patch)
tree4b15d477935c5c4590f7d6df2ed1f827056162a2 /scripts
parent7889918d23ffa428cf266e52d42b9683f16160fa (diff)
downloaddispatch-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
Diffstat (limited to 'scripts')
-rw-r--r--scripts/live-probe.ts150
1 files changed, 150 insertions, 0 deletions
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)));