summaryrefslogtreecommitdiffhomepage
path: root/scripts
diff options
context:
space:
mode:
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/fix-dist-perms.sh25
-rw-r--r--scripts/live-probe-provider-retry.ts188
-rw-r--r--scripts/live-probe.ts479
-rw-r--r--scripts/probe-cache-warming.ts277
4 files changed, 851 insertions, 118 deletions
diff --git a/scripts/fix-dist-perms.sh b/scripts/fix-dist-perms.sh
new file mode 100755
index 0000000..471cbdf
--- /dev/null
+++ b/scripts/fix-dist-perms.sh
@@ -0,0 +1,25 @@
+#!/usr/bin/env bash
+# Fix ownership of dist/ so Vite can clean + rebuild it.
+# The dist/assets/ dir was created as root (likely a Docker build) and Vite
+# can't rmSync it as a non-root user → EACCES on `bun run build`.
+#
+# Usage: sudo ./scripts/fix-dist-perms.sh
+set -euo pipefail
+
+DIST_DIR="$(cd "$(dirname "$0")/.." && pwd)/dist"
+
+if [ ! -d "$DIST_DIR" ]; then
+ echo "No dist/ directory found — nothing to fix."
+ exit 0
+fi
+
+OWNER="$(stat -c '%U:%G' "$DIST_DIR")"
+echo "dist/ is currently owned by: $OWNER"
+
+if [ "$OWNER" = "root:root" ]; then
+ echo "Fixing ownership to $(stat -c '%U:%G' "$(dirname "$DIST_DIR")") ..."
+fi
+
+# chown the whole dist/ tree to the same owner as the repo root
+chown -R --reference="$(dirname "$DIST_DIR")" "$DIST_DIR"
+echo "Done. dist/ is now owned by: $(stat -c '%U:%G' "$DIST_DIR")"
diff --git a/scripts/live-probe-provider-retry.ts b/scripts/live-probe-provider-retry.ts
new file mode 100644
index 0000000..952163c
--- /dev/null
+++ b/scripts/live-probe-provider-retry.ts
@@ -0,0 +1,188 @@
+/**
+ * scripts/live-probe-provider-retry.ts — FOCUSED live probe of the transient
+ * `provider-retry` AgentEvent seam, run against a RUNNING backend (bin/up).
+ * NOT part of `bun run test`.
+ *
+ * A real `provider-retry` only fires on an upstream 429/5xx, which we can't
+ * force from here. So this probe verifies the two things unit tests CAN'T:
+ *
+ * 1. REGRESSION (real wire): a normal text turn through the REAL WS socket +
+ * the updated `foldEvent` (provider-retry case + the reduceEvent wrapper)
+ * seals cleanly and `providerRetry` stays NULL throughout — no spurious
+ * banner, and the wrapper's re-spread didn't break streaming.
+ * 2. PARSER + REDUCER SEAM (the new event's effectful boundary): feed a
+ * synthetic `provider-retry` `chat.delta` JSON string through the REAL
+ * `parseServerMessage` wire parser (the function that runs on every inbound
+ * WS frame) → confirm it is ACCEPTED (not rejected as an unknown event) →
+ * `foldEvent` SETS `providerRetry` (coalesces on a 2nd) and adds NO chunk →
+ * a subsequent `text-delta` CLEARS it. This proves the new variant survives
+ * the JSON-parse boundary the unit tests skip (they pass constructed events).
+ *
+ * bun scripts/live-probe-provider-retry.ts
+ * PROBE_MODEL=opencode/glm-5.2 bun scripts/live-probe-provider-retry.ts
+ */
+import type { ChatDeltaMessage, ChatErrorMessage } from "@dispatch/transport-contract";
+import type { SurfaceServerMessage } from "@dispatch/ui-contract";
+import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
+import { parseServerMessage } from "../src/adapters/ws/logic.ts";
+import {
+ foldEvent,
+ initialState,
+ selectChunks,
+ selectProviderRetry,
+} from "../src/core/chunks/index.ts";
+
+const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
+const MODEL = process.env.PROBE_MODEL ?? "opencode/deepseek-v4-flash";
+const PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: ok";
+
+type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
+
+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}` : ""}`);
+};
+const fail = (msg: string): never => {
+ console.error(`\n[probe] FATAL: ${msg}`);
+ process.exit(1);
+};
+
+/** A chat.delta JSON frame carrying the given AgentEvent, exactly as the backend sends. */
+function deltaFrame(event: ChatDeltaMessage["event"]): string {
+ return JSON.stringify({ type: "chat.delta", event } satisfies ChatDeltaMessage);
+}
+
+async function main() {
+ console.log(`[probe] provider-retry seam · model=${MODEL} · WS=${WS_URL}\n`);
+
+ // ─── 1. REGRESSION: a real text turn through the updated foldEvent ──────────
+ // Routed by conversationId via a per-conv handler map (same pattern as live-probe.ts).
+ const handlers = new Map<string, (msg: ChatMsg) => void>();
+ const socket = createSurfaceSocket({
+ url: WS_URL,
+ onMessage: (_m: SurfaceServerMessage) => {},
+ onChat: (msg: ChatMsg) => {
+ const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+ const h = id !== undefined ? handlers.get(id) : undefined;
+ h?.(msg);
+ },
+ });
+ await new Promise((r) => setTimeout(r, 500));
+
+ const conversationId = crypto.randomUUID();
+ let state = initialState();
+ let deltas = 0;
+ let sealed = false;
+ let error: string | null = null;
+ const done = Promise.withResolvers<void>();
+ handlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ error = msg.message;
+ done.resolve();
+ return;
+ }
+ deltas++;
+ state = foldEvent(state, msg.event);
+ if (msg.event.type === "turn-sealed") {
+ sealed = true;
+ done.resolve();
+ }
+ });
+
+ socket.send({ type: "chat.send", conversationId, message: PROMPT, model: MODEL });
+ const timeout = setTimeout(() => done.resolve(), 90_000);
+ await done.promise;
+ clearTimeout(timeout);
+ handlers.delete(conversationId);
+
+ record(
+ "regression: a real text turn sealed cleanly",
+ sealed && error === null,
+ `${deltas} deltas${error ? ` err=${error}` : ""}`,
+ );
+ record(
+ "regression: providerRetry stayed NULL through a normal turn (no spurious banner)",
+ selectProviderRetry(state) === null,
+ );
+
+ // ─── 2. PARSER + REDUCER SEAM: synthetic provider-retry through the REAL parser ─
+ console.log("\n[probe] parser+reducer seam (synthetic provider-retry)");
+ let s = initialState();
+ s = foldEvent(s, { type: "turn-start", conversationId, turnId: "t1" });
+
+ const retryJson = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 0,
+ delayMs: 5000,
+ message: 'HTTP 429: {"error":{"type":"overloaded_error","message":"overloaded"}}',
+ code: "429",
+ });
+ const parsed1 = parseServerMessage(retryJson);
+ record(
+ "REAL parseServerMessage ACCEPTS a provider-retry chat.delta (not rejected as unknown)",
+ parsed1 !== null && parsed1.type === "chat.delta" && parsed1.event.type === "provider-retry",
+ parsed1 ? `event.type=${(parsed1 as { event: { type: string } }).event.type}` : "parsed=null",
+ );
+
+ if (parsed1 !== null && parsed1.type === "chat.delta") s = foldEvent(s, parsed1.event);
+ const retry1 = selectProviderRetry(s);
+ record(
+ "foldEvent SETS providerRetry from the PARSED event",
+ retry1 !== null && retry1.attempt === 0 && retry1.delayMs === 5000 && retry1.code === "429",
+ retry1 ? `attempt=${retry1.attempt} delay=${retry1.delayMs}ms code=${retry1.code}` : "null",
+ );
+ record(
+ "provider-retry adds NO chunk (never persisted — never pollutes the prompt)",
+ selectChunks(s).length === 0,
+ `${selectChunks(s).length} chunk(s)`,
+ );
+
+ const retry2Json = deltaFrame({
+ type: "provider-retry",
+ conversationId,
+ turnId: "t1",
+ attempt: 1,
+ delayMs: 10000,
+ message: "HTTP 429: still overloaded",
+ code: "429",
+ });
+ const parsed2 = parseServerMessage(retry2Json);
+ if (parsed2 !== null && parsed2.type === "chat.delta") s = foldEvent(s, parsed2.event);
+ const retry2 = selectProviderRetry(s);
+ record(
+ "a 2nd provider-retry COALESCES (latest attempt + delay replaces previous)",
+ retry2 !== null &&
+ retry2.attempt === 1 &&
+ retry2.delayMs === 10000 &&
+ retry2.message === "HTTP 429: still overloaded",
+ retry2 ? `attempt=${retry2.attempt} delay=${retry2.delayMs}ms` : "null",
+ );
+
+ const textJson = deltaFrame({
+ type: "text-delta",
+ conversationId,
+ turnId: "t1",
+ delta: "here is the reply",
+ });
+ const parsedText = parseServerMessage(textJson);
+ if (parsedText !== null && parsedText.type === "chat.delta") s = foldEvent(s, parsedText.event);
+ record(
+ "a subsequent text-delta CLEARS the banner (retry succeeded → live reply)",
+ selectProviderRetry(s) === null,
+ );
+ record(
+ "…and the text-delta content DID land as a chunk (the reply streams normally after retries)",
+ selectChunks(s).some((c) => c.chunk.type === "text"),
+ );
+
+ socket.close();
+ const passed = checks.filter((c) => c.ok).length;
+ const total = checks.length;
+ console.log(`\n[probe] ${passed}/${total} checks passed`);
+ process.exit(passed === total ? 0 : 1);
+}
+
+main().catch((e) => fail(String(e)));
diff --git a/scripts/live-probe.ts b/scripts/live-probe.ts
index bc654dd..6121eac 100644
--- a/scripts/live-probe.ts
+++ b/scripts/live-probe.ts
@@ -5,157 +5,400 @@
*
* bun scripts/live-probe.ts # default model
* PROBE_MODEL=opencode/glm-5 bun scripts/live-probe.ts
+ * PROBE_TOOL_PROMPT="..." bun scripts/live-probe.ts # override the tool turn
*
* 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.
+ * the surface `catalog` AND chat ops (deltas routed by conversationId).
+ * - core/chunks foldEvent/applyHistory/groupRenderedChunks → fold REAL
+ * chat.delta AgentEvents and group batched tool calls by stepId.
* - 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).
+ *
+ * Turn 1 verifies the text streaming + cache + replay path.
+ * Turn 2 verifies the tool-call BATCHING path ([email protected] `stepId`): that live
+ * tool events AND replayed tool chunks carry `stepId`, and that the pure grouping
+ * selector folds a parallel batch into one group.
*/
// 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,
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ ConversationHistoryResponse,
+ ConversationMetricsResponse,
} 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 {
+ applyHistory,
+ foldEvent,
+ groupRenderedChunks,
+ initialState,
+ selectChunks,
+ selectMessages,
+ type TranscriptState,
+} from "../src/core/chunks/index.ts";
+import {
+ applyDurableMetrics,
+ foldMetricsEvent,
+ initialMetricsState,
+ type MetricsState,
+ selectOrderedTurnMetrics,
+} from "../src/core/metrics/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 TEXT_PROMPT = process.env.PROBE_PROMPT ?? "Reply with exactly: hello from dispatch";
+const TOOL_PROMPT =
+ process.env.PROBE_TOOL_PROMPT ??
+ "Make two tool calls AT THE SAME TIME in a single step (parallel tool calls). " +
+ "For example, run two independent shell commands together: `echo alpha` and `echo beta`. " +
+ "If you have no shell tool, invoke any two of your available read-only tools simultaneously.";
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}` : ""}`);
+ checks.push({ name, ok, ...(detail !== undefined ? { detail } : {}) });
+ console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
};
+const note = (msg: string) => console.log(` ℹ️ ${msg}`);
function fail(msg: string): never {
- console.error(`\n[live-probe] FATAL: ${msg}`);
- process.exit(1);
+ console.error(`\n[live-probe] FATAL: ${msg}`);
+ process.exit(1);
+}
+
+async function historySync(
+ id: string,
+ sinceSeq: number,
+ window?: { limit?: number; beforeSeq?: number },
+): Promise<ConversationHistoryResponse> {
+ let url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?sinceSeq=${sinceSeq}`;
+ if (window?.limit !== undefined) url += `&limit=${window.limit}`;
+ if (window?.beforeSeq !== undefined) url += `&beforeSeq=${window.beforeSeq}`;
+ 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;
+}
+
+/** Raw history GET that returns the status (for the CR-5 validation checks). */
+async function historyStatus(id: string, query: string): Promise<number> {
+ const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}?${query}`;
+ const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
+ await res.arrayBuffer(); // drain
+ return res.status;
+}
+
+/** Durable metrics fetch — returns the response, or the HTTP status when not OK
+ * (the endpoint is being implemented backend-side; the FE tolerates a 404). */
+async function metricsSync(id: string): Promise<ConversationMetricsResponse | { status: number }> {
+ const url = `${HTTP_BASE}/conversations/${encodeURIComponent(id)}/metrics`;
+ const res = await fetch(url, { headers: { Origin: "http://localhost:24204" } });
+ if (!res.ok) return { status: res.status };
+ return (await res.json()) as ConversationMetricsResponse;
}
-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;
+type ChatMsg = ChatDeltaMessage | ChatErrorMessage;
+type Socket = ReturnType<typeof createSurfaceSocket>;
+
+const handlers = new Map<string, (msg: ChatMsg) => void>();
+function convOf(msg: ChatMsg): string | undefined {
+ return msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+}
+
+/** Drive one turn to turn-sealed (or error), folding events into a fresh state. */
+async function runTurn(
+ socket: Socket,
+ conversationId: string,
+ prompt: string,
+): Promise<{
+ state: TranscriptState;
+ metrics: MetricsState;
+ deltas: number;
+ sealed: boolean;
+ error: string | null;
+}> {
+ let state = initialState();
+ let metrics = initialMetricsState();
+ let deltas = 0;
+ let sealed = false;
+ let error: string | null = null;
+ const done = Promise.withResolvers<void>();
+
+ handlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ error = msg.message;
+ done.resolve();
+ return;
+ }
+ deltas++;
+ state = foldEvent(state, msg.event);
+ metrics = foldMetricsEvent(metrics, msg.event);
+ if (msg.event.type === "turn-sealed") {
+ sealed = true;
+ done.resolve();
+ }
+ });
+
+ socket.send({ type: "chat.send", conversationId, message: prompt, model: MODEL });
+ const timeout = setTimeout(() => done.resolve(), 90_000);
+ await done.promise;
+ clearTimeout(timeout);
+ handlers.delete(conversationId);
+ return { state, metrics, deltas, sealed, error };
+}
+
+function toolChunksOf(state: TranscriptState) {
+ return selectChunks(state).filter(
+ (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result",
+ );
}
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);
+ console.log(`[live-probe] model=${MODEL}`);
+ console.log(`[live-probe] WS=${WS_URL} HTTP=${HTTP_BASE}\n`);
+
+ const cache = createConversationCache(createIdbChunkStore());
+
+ let gotCatalog = false;
+ 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: (msg: ChatMsg) => {
+ const id = convOf(msg);
+ const h = id !== undefined ? handlers.get(id) : undefined;
+ if (h) h(msg);
+ },
+ });
+
+ await new Promise((r) => setTimeout(r, 500));
+ record("WS connected + surface catalog received", gotCatalog);
+
+ // ─── Turn 1: text streaming + cache + replay ────────────────────────────────
+ console.log(`\n[live-probe] TURN 1 (text): "${TEXT_PROMPT}"`);
+ const textConv = crypto.randomUUID();
+ const t1 = await runTurn(socket, textConv, TEXT_PROMPT);
+ if (t1.error !== null) record("turn 1 had no chat.error", false, t1.error);
+ record("turn 1 received chat.delta events", t1.deltas > 0, `${t1.deltas} deltas`);
+ record("turn 1 reached turn-sealed", t1.sealed);
+
+ let state = t1.state;
+ const sinceSeq = await cache.sinceSeq(textConv);
+ const hist = await historySync(textConv, sinceSeq);
+ record(
+ "turn 1 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("turn 1 history chunks are seq-monotonic", monotonic);
+ const merged = await cache.commit(textConv, hist.chunks);
+ state = applyHistory(state, merged);
+ record("turn 1 provisional superseded (sealedTurnId cleared)", state.sealedTurnId === null);
+ const cached = await cache.load(textConv);
+ record("turn 1 IndexedDB cache persisted the turn", cached.length === hist.chunks.length);
+ 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("");
+ record("turn 1 committed transcript has assistant text", committedText.length > 0);
+
+ // ─── CR-5: history windowing (?limit= / ?beforeSeq=, [email protected]) ───────
+ const logLen = hist.chunks.length;
+ record(
+ "CR-5 seq origin: first chunk is seq 1 (1-based gap-free contract)",
+ hist.chunks[0]?.seq === 1,
+ `first seq=${hist.chunks[0]?.seq}`,
+ );
+ const win = await historySync(textConv, 0, { limit: 2 });
+ record(
+ "CR-5 ?limit=2 returns the NEWEST 2, ascending, latestSeq = window tail",
+ win.chunks.length === Math.min(2, logLen) &&
+ win.chunks[0]?.seq === Math.max(1, logLen - 1) &&
+ win.chunks[win.chunks.length - 1]?.seq === logLen &&
+ win.latestSeq === logLen,
+ `seqs=[${win.chunks.map((c) => c.seq).join(",")}] latestSeq=${win.latestSeq}`,
+ );
+ const whole = await historySync(textConv, 0, { limit: 200 });
+ record(
+ "CR-5 ?limit= larger than the log returns everything (short-chat flow exact)",
+ whole.chunks.length === logLen,
+ `${whole.chunks.length}/${logLen} chunks`,
+ );
+ const oldestLoaded = win.chunks[0]?.seq ?? 0;
+ if (oldestLoaded > 1) {
+ const back = await historySync(textConv, 0, { beforeSeq: oldestLoaded, limit: 50 });
+ record(
+ "CR-5 ?beforeSeq= pages the older run (seq < bound, ascending from 1)",
+ back.chunks.length === oldestLoaded - 1 &&
+ back.chunks[0]?.seq === 1 &&
+ back.chunks.every((c) => c.seq < oldestLoaded),
+ `seqs=[${back.chunks.map((c) => c.seq).join(",")}]`,
+ );
+ }
+ record("CR-5 limit=0 rejected with 400", (await historyStatus(textConv, "limit=0")) === 400);
+ record(
+ "CR-5 beforeSeq=-1 rejected with 400",
+ (await historyStatus(textConv, "beforeSeq=-1")) === 400,
+ );
+
+ // ─── Metrics: LIVE token + timing ([email protected] usage/step-complete/done) ──────
+ // (TurnMetricsEntry is `{ turnId, steps, total }` — the turn aggregate lives on
+ // `total`, present once the live `done` folded.)
+ const liveTurns = selectOrderedTurnMetrics(t1.metrics);
+ const m1 = liveTurns[0];
+ const m1Total = m1?.total ?? null;
+ record(
+ "turn 1 LIVE metrics: a turn with output tokens",
+ m1Total !== null && m1Total.usage.outputTokens > 0,
+ m1Total
+ ? `in=${m1Total.usage.inputTokens} out=${m1Total.usage.outputTokens} steps=${m1?.steps.length}`
+ : "no finalized turn total",
+ );
+ if (m1 !== undefined) {
+ const anyGen = m1.steps.some((s) => s.genTotalMs !== undefined);
+ const anyTtft = m1.steps.some((s) => s.ttftMs !== undefined);
+ note(
+ `live timing: durationMs=${m1Total?.durationMs ?? "—"}, ` +
+ `genTotalMs present=${anyGen}, ttftMs present=${anyTtft}`,
+ );
+ record(
+ "turn 1 LIVE metrics carries timing (durationMs or step genTotalMs)",
+ m1Total?.durationMs !== undefined || anyGen,
+ "requires the backend runtime to have a clock",
+ );
+ }
+
+ // ─── Metrics: DURABLE endpoint (GET /conversations/:id/metrics) ──────────────
+ const dm = await metricsSync(textConv);
+ if ("status" in dm) {
+ note(
+ `durable /metrics not available yet (HTTP ${dm.status}) — FE degrades to live-only, as designed`,
+ );
+ record(
+ "durable /metrics is implemented OR gracefully absent (404)",
+ dm.status === 404 || dm.status === 405,
+ `HTTP ${dm.status}`,
+ );
+ } else {
+ record(
+ "durable /metrics returned TurnMetrics[]",
+ Array.isArray(dm.turns),
+ `${dm.turns.length} turn(s)`,
+ );
+ const durableMerged = selectOrderedTurnMetrics(
+ applyDurableMetrics(initialMetricsState(), dm.turns),
+ );
+ const d1 = durableMerged[0];
+ const d1Total = d1?.total ?? null;
+ record(
+ "durable /metrics turn has token usage",
+ d1Total !== null && d1Total.usage.outputTokens > 0,
+ d1Total ? `out=${d1Total.usage.outputTokens} steps=${d1?.steps.length}` : "no turn total",
+ );
+ }
+
+ // ─── Turn 2: tool-call batching ([email protected] stepId) ─────────────────────────
+ console.log(`\n[live-probe] TURN 2 (tools): "${TOOL_PROMPT}"`);
+ const toolConv = crypto.randomUUID();
+ const t2 = await runTurn(socket, toolConv, TOOL_PROMPT);
+ if (t2.error !== null) record("turn 2 had no chat.error", false, t2.error);
+ record("turn 2 reached turn-sealed", t2.sealed);
+
+ const liveTool = toolChunksOf(t2.state);
+ const liveCalls = liveTool.filter((c) => c.chunk.type === "tool-call");
+
+ if (liveCalls.length === 0) {
+ note(
+ "INCONCLUSIVE: the model issued no tool calls this run — cannot verify stepId grouping live. " +
+ "Re-run with a stronger PROBE_TOOL_PROMPT or one tailored to the backend's tool set.",
+ );
+ record("turn 2 tool-call batching (live)", true, "skipped — no tool calls issued");
+ } else {
+ // Every live tool chunk must carry stepId (foldEvent copies it from the event).
+ const allLiveHaveStep = liveTool.every(
+ (c) =>
+ (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") &&
+ typeof c.chunk.stepId === "string" &&
+ c.chunk.stepId.length > 0,
+ );
+ record(
+ "turn 2 every LIVE tool event carries stepId",
+ allLiveHaveStep,
+ `${liveCalls.length} call(s), ${liveTool.length - liveCalls.length} result(s)`,
+ );
+
+ const liveGroups = groupRenderedChunks(selectChunks(t2.state));
+ const liveBatches = liveGroups.filter((g) => g.kind === "tool-batch");
+ const distinctSteps = new Set(
+ liveCalls.map((c) => (c.chunk.type === "tool-call" ? c.chunk.stepId : undefined)),
+ );
+ note(
+ `live grouping: ${liveCalls.length} call(s) across ${distinctSteps.size} step(s) → ` +
+ `${liveBatches.length} batch group(s)`,
+ );
+ if (liveBatches.length > 0) {
+ record(
+ "turn 2 grouping produced a parallel batch (2+ calls in one step)",
+ true,
+ `${liveBatches.length} batch(es)`,
+ );
+ } else {
+ note(
+ "the model used tools but did NOT parallelize (each call its own step) — stepId is verified, " +
+ "but no multi-call batch occurred to render as a list this run.",
+ );
+ }
+
+ // Replay path: persisted tool chunks must also carry chunk.stepId.
+ const histTool = await historySync(toolConv, 0);
+ const replayTool = histTool.chunks.filter(
+ (c) => c.chunk.type === "tool-call" || c.chunk.type === "tool-result",
+ );
+ const allReplayHaveStep = replayTool.every(
+ (c) =>
+ (c.chunk.type === "tool-call" || c.chunk.type === "tool-result") &&
+ typeof c.chunk.stepId === "string" &&
+ c.chunk.stepId.length > 0,
+ );
+ record(
+ "turn 2 every REPLAYED tool chunk carries chunk.stepId",
+ replayTool.length > 0 && allReplayHaveStep,
+ `${replayTool.length} tool chunk(s) in history`,
+ );
+
+ // Grouping on the authoritative replayed history matches the live shape.
+ const replayState = applyHistory(initialState(), await cache.commit(toolConv, histTool.chunks));
+ const replayBatches = groupRenderedChunks(selectChunks(replayState)).filter(
+ (g) => g.kind === "tool-batch",
+ );
+ record(
+ "turn 2 replay grouping matches live (batch count)",
+ replayBatches.length === liveBatches.length,
+ `live=${liveBatches.length} replay=${replayBatches.length}`,
+ );
+ }
+
+ 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)));
diff --git a/scripts/probe-cache-warming.ts b/scripts/probe-cache-warming.ts
new file mode 100644
index 0000000..1bf1f9b
--- /dev/null
+++ b/scripts/probe-cache-warming.ts
@@ -0,0 +1,277 @@
+/**
+ * scripts/probe-cache-warming.ts — LIVE probe of the `cache-warming` surface +
+ * conversation-close lifecycle against a RUNNING backend (bin/up: HTTP :24203 +
+ * surface WS :24205; override with PROBE_HTTP / PROBE_WS for bin/up2's +1000
+ * ports). NOT part of `bun run test`. Verifies the CR-4 handoff end-to-end:
+ *
+ * A. draft subscribe (no conversationId) → degenerate "no conversation" spec
+ * B. fresh conversation → warming defaults OFF, nothing scheduled (CR-4a)
+ * C. toggle on + 10s interval → repeated automatic warms, each update carrying
+ * a FUTURE nextWarmAt (CR-4b), initial `surface` echoes conversationId (CR-4d)
+ * D. POST /conversations/:id/close mid-turn → abortedTurn, done.reason
+ * "aborted", turn-sealed, warming disabled + unscheduled (CR-4c)
+ *
+ * bun scripts/probe-cache-warming.ts
+ */
+import type {
+ ChatDeltaMessage,
+ ChatErrorMessage,
+ CloseConversationResponse,
+} from "@dispatch/transport-contract";
+import type { SurfaceServerMessage, SurfaceSpec } from "@dispatch/ui-contract";
+import { createSurfaceSocket } from "../src/adapters/ws/index.ts";
+import { parseControls } from "../src/features/cache-warming/logic/view-model.ts";
+
+const WS_URL = process.env.PROBE_WS ?? "ws://localhost:24205";
+const HTTP_BASE = process.env.PROBE_HTTP ?? "http://localhost:24203";
+const SURFACE_ID = "cache-warming";
+
+const checks: { name: string; ok: boolean }[] = [];
+const record = (name: string, ok: boolean, detail?: string) => {
+ checks.push({ name, ok });
+ console.log(` ${ok ? "✅" : "❌"} ${name}${detail ? ` — ${detail}` : ""}`);
+};
+const log = (msg: string) => console.log(`[${new Date().toISOString().slice(11, 19)}] ${msg}`);
+const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
+
+function summarize(spec: SurfaceSpec | null): string {
+ const c = parseControls(spec);
+ const next =
+ c.nextWarmAt === null ? "null" : `${Math.round((c.nextWarmAt - Date.now()) / 1000)}s`;
+ return `enabled=${c.enabled} interval=${c.intervalSeconds}s lastPct=${c.lastPct} next=${next} lastWarmAt=${c.lastWarmAt}`;
+}
+
+let catalog: { id: string; scope?: string }[] = [];
+let latestSpec: SurfaceSpec | null = null;
+let latestSpecConv: string | undefined;
+let specWaiter: (() => void) | null = null;
+
+const chatHandlers = new Map<string, (msg: ChatDeltaMessage | ChatErrorMessage) => void>();
+
+const socket = createSurfaceSocket({
+ url: WS_URL,
+ onMessage: (m: SurfaceServerMessage) => {
+ if (m.type === "catalog") {
+ catalog = [...m.catalog];
+ log(`catalog: ${m.catalog.map((e) => `${e.id}(scope=${e.scope ?? "—"})`).join(", ")}`);
+ } else if (m.type === "surface") {
+ latestSpec = m.spec;
+ latestSpecConv = m.conversationId;
+ log(`surface(initial) conv=${m.conversationId ?? "—"}: ${summarize(m.spec)}`);
+ specWaiter?.();
+ } else if (m.type === "update") {
+ if (m.update.surfaceId !== SURFACE_ID) return;
+ latestSpec = m.update.spec;
+ latestSpecConv = m.update.conversationId;
+ log(`update conv=${m.update.conversationId ?? "—"}: ${summarize(m.update.spec)}`);
+ specWaiter?.();
+ } else if (m.type === "error") {
+ log(`surface ERROR: ${m.surfaceId ?? "—"}: ${m.message}`);
+ }
+ },
+ onChat: (msg) => {
+ const id = msg.type === "chat.error" ? msg.conversationId : msg.event.conversationId;
+ if (id !== undefined) chatHandlers.get(id)?.(msg);
+ },
+});
+
+/** Wait for the next surface/update message (or time out). */
+function nextSpec(timeoutMs: number): Promise<boolean> {
+ return new Promise((resolve) => {
+ const t = setTimeout(() => {
+ specWaiter = null;
+ resolve(false);
+ }, timeoutMs);
+ specWaiter = () => {
+ clearTimeout(t);
+ specWaiter = null;
+ resolve(true);
+ };
+ });
+}
+
+async function runTinyTurn(conversationId: string, prompt: string): Promise<boolean> {
+ const done = Promise.withResolvers<boolean>();
+ chatHandlers.set(conversationId, (msg) => {
+ if (msg.type === "chat.error") {
+ log(`chat.error: ${msg.message}`);
+ done.resolve(false);
+ } else if (msg.event.type === "turn-sealed") {
+ done.resolve(true);
+ }
+ });
+ socket.send({ type: "chat.send", conversationId, message: prompt });
+ const t = setTimeout(() => done.resolve(false), 90_000);
+ const ok = await done.promise;
+ clearTimeout(t);
+ chatHandlers.delete(conversationId);
+ return ok;
+}
+
+function invoke(actionId: string, conversationId: string, payload?: unknown): void {
+ socket.send(
+ payload === undefined
+ ? { type: "invoke", surfaceId: SURFACE_ID, actionId, conversationId }
+ : { type: "invoke", surfaceId: SURFACE_ID, actionId, payload, conversationId },
+ );
+}
+
+async function main() {
+ await sleep(600);
+ record(
+ "catalog includes cache-warming with scope=conversation",
+ catalog.some((e) => e.id === SURFACE_ID && e.scope === "conversation"),
+ );
+
+ // ── A: the DRAFT/new-tab path — subscribe with NO conversationId ───────────
+ log("PHASE A: subscribe with NO conversationId (draft / new tab)");
+ socket.send({ type: "subscribe", surfaceId: SURFACE_ID });
+ await nextSpec(3000);
+ record(
+ "draft subscribe → degenerate spec (no toggle parsed)",
+ !parseControls(latestSpec).enabled,
+ );
+ socket.send({ type: "unsubscribe", surfaceId: SURFACE_ID });
+ await sleep(300);
+
+ // ── B: a FRESH conversation defaults OFF (CR-4a) + echo (CR-4d) ────────────
+ const conv = crypto.randomUUID();
+ log(`PHASE B: creating conversation ${conv}`);
+ if (!(await runTinyTurn(conv, "Reply with exactly: ok"))) {
+ log("FATAL: could not create a conversation");
+ process.exit(1);
+ }
+ socket.send({ type: "subscribe", surfaceId: SURFACE_ID, conversationId: conv });
+ await nextSpec(3000);
+ const fresh = parseControls(latestSpec);
+ record("CR-4d: initial surface message echoes conversationId", latestSpecConv === conv);
+ record("CR-4a: fresh conversation defaults to warming OFF", fresh.enabled === false);
+ record("CR-4a: nothing scheduled while off (nextWarmAt null)", fresh.nextWarmAt === null);
+
+ // ── C: opt in + 10s interval → repeated warms, FUTURE nextWarmAt (CR-4b) ───
+ log("PHASE C: toggling warming ON");
+ const toggleId = fresh.toggleActionId;
+ if (toggleId === null) {
+ record("toggle action present", false);
+ process.exit(1);
+ }
+ invoke(toggleId, conv);
+ await nextSpec(3000);
+ let c = parseControls(latestSpec);
+ record("toggle-on update arrived (enabled)", c.enabled === true);
+ record(
+ "CR-4b: enable schedules a FUTURE nextWarmAt",
+ c.nextWarmAt !== null && c.nextWarmAt > Date.now(),
+ );
+
+ const setIntervalId = c.setIntervalActionId;
+ if (setIntervalId !== null) {
+ log("PHASE C: set-interval = 10s");
+ invoke(setIntervalId, conv, 10);
+ await nextSpec(3000);
+ c = parseControls(latestSpec);
+ record(
+ "set-interval update: interval=10 + FUTURE nextWarmAt",
+ c.intervalSeconds === 10 && c.nextWarmAt !== null && c.nextWarmAt > Date.now(),
+ );
+ }
+
+ log("PHASE C: waiting up to 45s for 2 automatic warms…");
+ const deadline = Date.now() + 45_000;
+ let lastSeen = c.lastWarmAt;
+ let warms = 0;
+ let allFuture = true;
+ while (Date.now() < deadline && warms < 2) {
+ await nextSpec(Math.max(1, deadline - Date.now()));
+ const now = parseControls(latestSpec);
+ if (now.lastWarmAt !== null && now.lastWarmAt !== lastSeen) {
+ lastSeen = now.lastWarmAt;
+ warms++;
+ const future = now.nextWarmAt !== null && now.nextWarmAt > Date.now() - 1000;
+ if (!future) allFuture = false;
+ log(
+ ` automatic warm #${warms}: pct=${now.lastPct} retention=${now.retentionPct} ` +
+ `nextWarmAt ${future ? "FUTURE" : "STALE/PAST"}`,
+ );
+ }
+ }
+ record("automatic warms repeat (2 observed @10s)", warms >= 2, `${warms} warm(s)`);
+ record("CR-4b: every post-warm update carries a FUTURE nextWarmAt", warms >= 2 && allFuture);
+
+ // ── D: close mid-turn → abort + warming disabled (CR-4c) ───────────────────
+ log("PHASE D: starting a long turn, then closing the conversation mid-turn…");
+ const seenDone = Promise.withResolvers<string>(); // resolves with done.reason
+ const seenSealed = Promise.withResolvers<void>();
+ let turnStarted = false;
+ const started = Promise.withResolvers<void>();
+ chatHandlers.set(conv, (msg) => {
+ if (msg.type === "chat.error") {
+ log(`chat.error: ${msg.message}`);
+ return;
+ }
+ const ev = msg.event;
+ if (ev.type === "turn-start") {
+ turnStarted = true;
+ started.resolve();
+ } else if (ev.type === "done") {
+ seenDone.resolve(ev.reason);
+ } else if (ev.type === "turn-sealed") {
+ seenSealed.resolve();
+ }
+ });
+ socket.send({
+ type: "chat.send",
+ conversationId: conv,
+ message:
+ "Write a detailed 1000-word essay about the history of computing. Take your time and be thorough.",
+ });
+ const startTimeout = setTimeout(() => started.resolve(), 15_000);
+ await started.promise;
+ clearTimeout(startTimeout);
+ record("turn started (watcher saw turn-start)", turnStarted);
+ await sleep(1000); // let it generate a moment
+
+ const res = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, {
+ method: "POST",
+ headers: { Origin: "http://localhost:24204" },
+ });
+ record("POST /conversations/:id/close → 200", res.ok, `HTTP ${res.status}`);
+ const body = (await res.json()) as CloseConversationResponse;
+ record("close aborted the in-flight turn (abortedTurn)", body.abortedTurn === true);
+
+ const doneReason = await Promise.race([seenDone.promise, sleep(15_000).then(() => "(timeout)")]);
+ record('watcher received done with reason "aborted"', doneReason === "aborted", doneReason);
+ const sealed = await Promise.race([
+ seenSealed.promise.then(() => true),
+ sleep(15_000).then(() => false),
+ ]);
+ record("turn sealed normally after abort", sealed);
+ chatHandlers.delete(conv);
+
+ // The close also pushed a surface update: warming disabled + unscheduled.
+ await sleep(1500);
+ const closed = parseControls(latestSpec);
+ record(
+ "CR-4c: close disabled warming + cleared the schedule",
+ closed.enabled === false && closed.nextWarmAt === null,
+ summarize(latestSpec),
+ );
+
+ // Idempotency: closing again (now idle) succeeds with abortedTurn false.
+ const res2 = await fetch(`${HTTP_BASE}/conversations/${encodeURIComponent(conv)}/close`, {
+ method: "POST",
+ headers: { Origin: "http://localhost:24204" },
+ });
+ const body2 = (await res2.json()) as CloseConversationResponse;
+ record("close is idempotent (200 + abortedTurn:false)", res2.ok && body2.abortedTurn === false);
+
+ socket.close();
+ const passed = checks.filter((x) => x.ok).length;
+ console.log(`\n[probe-cache-warming] ${passed}/${checks.length} checks passed`);
+ process.exit(passed === checks.length ? 0 : 1);
+}
+
+main().catch((e) => {
+ console.error(`[probe] FATAL: ${e}`);
+ process.exit(1);
+});