1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
|
/**
* 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)));
|