yaml-flow 8.7.1 → 8.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (195) hide show
  1. package/browser/adapters/firebase-storage.js +2 -2
  2. package/browser/adapters/firestore-storage.js +2 -2
  3. package/browser/adapters/localstorage-storage.js +3 -3
  4. package/browser/asset-integrity.json +9 -13
  5. package/browser/live-cards.js +6 -6
  6. package/browser/server-runtime-controlface.js +5 -5
  7. package/examples/ARCHITECTURE.md +0 -27
  8. package/examples/board/server/board-server.js +150 -100
  9. package/examples/board/server/board-worker/source_def_flows.json +0 -8
  10. package/examples/board/server/board-worker/task-executor.js +1 -3
  11. package/examples/board/server/chat-flow/flow-steps.json +5 -5
  12. package/examples/board/test/server-http-test.js +726 -87
  13. package/examples/board-firestore/server/worker.js +8 -0
  14. package/examples/portfolio-tracker/portfolio-tracker.js +11 -1
  15. package/examples/portfolio-tracker/test/portfolio-t4.js +12 -2
  16. package/lib/{artifacts-store-lib-D9nMkVcE.d.cts → artifacts-store-lib-C6qBpMfU.d.cts} +1 -1
  17. package/lib/{artifacts-store-lib-DSSMqVL2.d.ts → artifacts-store-lib-D4qf7Q-7.d.ts} +1 -1
  18. package/lib/artifacts-store-public.d.cts +3 -3
  19. package/lib/artifacts-store-public.d.ts +3 -3
  20. package/lib/board-live-cards-mcp.cjs +1 -1
  21. package/lib/board-live-cards-mcp.d.cts +6 -7
  22. package/lib/board-live-cards-mcp.d.ts +6 -7
  23. package/lib/board-live-cards-mcp.js +1 -1
  24. package/lib/board-live-cards-node.cjs +8 -8
  25. package/lib/board-live-cards-node.d.cts +70 -15
  26. package/lib/board-live-cards-node.d.ts +70 -15
  27. package/lib/board-live-cards-node.js +8 -8
  28. package/lib/{board-live-cards-public-LlVUQPL2.d.cts → board-live-cards-public-BT5HrgqZ.d.cts} +82 -59
  29. package/lib/{board-live-cards-public-JNRKfBZy.d.ts → board-live-cards-public-DSRamFm8.d.ts} +82 -59
  30. package/lib/{board-live-cards-public-async-Di9QB141.d.cts → board-live-cards-public-async-CYjr4mgX.d.cts} +18 -8
  31. package/lib/{board-live-cards-public-async-fwd1QI82.d.ts → board-live-cards-public-async-DlyC3PgC.d.ts} +18 -8
  32. package/lib/board-live-cards-public.cjs +1 -1
  33. package/lib/board-live-cards-public.d.cts +2 -2
  34. package/lib/board-live-cards-public.d.ts +2 -2
  35. package/lib/board-live-cards-public.js +1 -1
  36. package/lib/board-live-cards-server-runtime.cjs +1 -1
  37. package/lib/board-live-cards-server-runtime.d.cts +6 -8
  38. package/lib/board-live-cards-server-runtime.d.ts +6 -8
  39. package/lib/board-live-cards-server-runtime.js +1 -1
  40. package/lib/board-livegraph-runtime/index.cjs +1 -1
  41. package/lib/board-livegraph-runtime/index.d.cts +1 -0
  42. package/lib/board-livegraph-runtime/index.d.ts +1 -0
  43. package/lib/board-livegraph-runtime/index.js +1 -1
  44. package/lib/{board-platform-adapter-async-BfHmHdx2.d.cts → board-platform-adapter-async-BZIftm36.d.cts} +18 -14
  45. package/lib/{board-platform-adapter-async-DYahVzIK.d.ts → board-platform-adapter-async-JP9V-U5E.d.ts} +18 -14
  46. package/lib/board-worker-adapter.cjs +1 -24
  47. package/lib/board-worker-adapter.d.cts +68 -3
  48. package/lib/board-worker-adapter.d.ts +68 -3
  49. package/lib/board-worker-adapter.js +1 -24
  50. package/lib/card-store-public.d.cts +2 -2
  51. package/lib/card-store-public.d.ts +2 -2
  52. package/lib/chat-store-public.cjs +1 -1
  53. package/lib/chat-store-public.d.cts +20 -20
  54. package/lib/chat-store-public.d.ts +20 -20
  55. package/lib/chat-store-public.js +1 -1
  56. package/lib/chunk-35N7ONTH.js +2 -0
  57. package/lib/chunk-36QUKFL7.cjs +3 -0
  58. package/lib/chunk-37HDEW26.cjs +2 -0
  59. package/lib/{chunk-PMUSJQSR.cjs → chunk-3CZCGNY4.cjs} +2 -2
  60. package/lib/{chunk-BQS3EIEK.js → chunk-44L64VQ2.js} +3 -3
  61. package/lib/{chunk-YGKDQLYP.js → chunk-4HIEOBJC.js} +2 -2
  62. package/lib/chunk-6OPXQPSC.js +2 -0
  63. package/lib/chunk-7BTZCOT5.js +2 -0
  64. package/lib/{chunk-U2N6MCD5.cjs → chunk-7JVHYHT2.cjs} +2 -2
  65. package/lib/chunk-7QQFDYBM.js +3 -0
  66. package/lib/chunk-7QZ267XP.cjs +2 -0
  67. package/lib/chunk-ABAVFLDP.js +7 -0
  68. package/lib/{chunk-XQRNDX4Q.js → chunk-ANKA7HEJ.js} +2 -2
  69. package/lib/{chunk-KAWQPLIE.cjs → chunk-BQUQTOPB.cjs} +2 -2
  70. package/lib/chunk-ETW3BXHD.cjs +2 -0
  71. package/lib/{chunk-SGV7PU4H.js → chunk-FOFGEABN.js} +2 -2
  72. package/lib/chunk-GPCMBPLK.cjs +2 -0
  73. package/lib/chunk-H22NK6KH.cjs +7 -0
  74. package/lib/chunk-H4TYOSMD.cjs +45 -0
  75. package/lib/chunk-HFW7E2Z7.cjs +4 -0
  76. package/lib/chunk-J4MHQ7JF.js +45 -0
  77. package/lib/chunk-MCPADH33.cjs +2 -0
  78. package/lib/chunk-NBJTYAYN.cjs +2 -0
  79. package/lib/chunk-NNSBBO5R.js +2 -0
  80. package/lib/chunk-NU5NO5NM.js +2 -0
  81. package/lib/chunk-O5UYCGIN.js +2 -0
  82. package/lib/chunk-O6II7S4M.js +3 -0
  83. package/lib/chunk-PN5D32NP.cjs +3 -0
  84. package/lib/chunk-Q3OTUDIE.js +2 -0
  85. package/lib/chunk-R44X3RQB.cjs +2 -0
  86. package/lib/chunk-RKKSVOP2.js +2 -0
  87. package/lib/chunk-UB54HZA4.cjs +2 -0
  88. package/lib/{chunk-CIAJNUR4.js → chunk-VGDLSS2H.js} +2 -2
  89. package/lib/{chunk-SFVO2LB2.cjs → chunk-VQCIOKJV.cjs} +3 -3
  90. package/lib/chunk-VS3BXEYK.js +4 -0
  91. package/lib/{chunk-S6DRP2HX.cjs → chunk-XQAHHUZO.cjs} +2 -2
  92. package/lib/chunk-Y4WK7HE4.js +2 -0
  93. package/lib/chunk-ZENTBLLA.cjs +3 -0
  94. package/lib/chunk-ZK3E7L4Y.cjs +2 -0
  95. package/lib/chunk-ZWVT24YW.js +3 -0
  96. package/lib/cloud-storage.cjs +1 -1
  97. package/lib/cloud-storage.d.cts +6 -6
  98. package/lib/cloud-storage.d.ts +6 -6
  99. package/lib/cloud-storage.js +1 -1
  100. package/lib/execution-refs.cjs +1 -1
  101. package/lib/execution-refs.js +1 -1
  102. package/lib/firebase-storage/index.cjs +2 -2
  103. package/lib/firebase-storage/index.d.cts +2 -2
  104. package/lib/firebase-storage/index.d.ts +2 -2
  105. package/lib/firebase-storage/index.js +2 -2
  106. package/lib/firestore-storage/index.cjs +2 -2
  107. package/lib/firestore-storage/index.d.cts +12 -21
  108. package/lib/firestore-storage/index.d.ts +12 -21
  109. package/lib/firestore-storage/index.js +2 -2
  110. package/lib/index.cjs +2 -2
  111. package/lib/index.d.cts +1 -1
  112. package/lib/index.d.ts +1 -1
  113. package/lib/index.js +1 -1
  114. package/lib/localstorage-storage/index.cjs +1 -1
  115. package/lib/localstorage-storage/index.d.cts +10 -6
  116. package/lib/localstorage-storage/index.d.ts +10 -6
  117. package/lib/localstorage-storage/index.js +1 -1
  118. package/lib/{mcp-tool-registries-W3TRj6O5.d.cts → mcp-tool-registries-CRtea2x4.d.cts} +3 -0
  119. package/lib/{mcp-tool-registries-BBObLYga.d.ts → mcp-tool-registries-D3rWSppt.d.ts} +3 -0
  120. package/lib/server-jobs-queue-runner/index.cjs +1 -1
  121. package/lib/server-jobs-queue-runner/index.d.cts +12 -9
  122. package/lib/server-jobs-queue-runner/index.d.ts +12 -9
  123. package/lib/server-jobs-queue-runner/index.js +1 -1
  124. package/lib/server-runtime/index.cjs +1 -1
  125. package/lib/server-runtime/index.d.cts +7 -9
  126. package/lib/server-runtime/index.d.ts +7 -9
  127. package/lib/server-runtime/index.js +1 -1
  128. package/lib/server-runtime-agentface/index.d.cts +7 -9
  129. package/lib/server-runtime-agentface/index.d.ts +7 -9
  130. package/lib/server-runtime-controlface/index.cjs +1 -1
  131. package/lib/server-runtime-controlface/index.d.cts +8 -9
  132. package/lib/server-runtime-controlface/index.d.ts +8 -9
  133. package/lib/server-runtime-controlface/index.js +1 -1
  134. package/lib/server-runtime-core/index.cjs +1 -1
  135. package/lib/server-runtime-core/index.d.cts +59 -21
  136. package/lib/server-runtime-core/index.d.ts +59 -21
  137. package/lib/server-runtime-core/index.js +1 -1
  138. package/lib/server-runtime-watchers/index.cjs +1 -1
  139. package/lib/server-runtime-watchers/index.d.cts +9 -65
  140. package/lib/server-runtime-watchers/index.d.ts +9 -65
  141. package/lib/server-runtime-watchers/index.js +1 -1
  142. package/lib/server-runtime-webhooks/index.d.cts +7 -9
  143. package/lib/server-runtime-webhooks/index.d.ts +7 -9
  144. package/lib/sse-hub-BDjWI7JR.d.cts +63 -0
  145. package/lib/sse-hub-DM8bw-dO.d.ts +63 -0
  146. package/lib/step-machine-public/index.cjs +1 -1
  147. package/lib/step-machine-public/index.d.cts +1 -1
  148. package/lib/step-machine-public/index.d.ts +1 -1
  149. package/lib/step-machine-public/index.js +1 -1
  150. package/lib/{storage-async-interface-BRR4eBjx.d.cts → storage-async-interface-CG0bMqvE.d.ts} +20 -1
  151. package/lib/{storage-async-interface-DhlOVPSp.d.ts → storage-async-interface-CyO-zwVQ.d.cts} +20 -1
  152. package/lib/{storage-interface-BFiD3kyB.d.ts → storage-interface-D-iEiTJA.d.cts} +45 -1
  153. package/lib/{storage-interface-BFiD3kyB.d.cts → storage-interface-D-iEiTJA.d.ts} +45 -1
  154. package/lib/stores/index.d.cts +1 -1
  155. package/lib/stores/index.d.ts +1 -1
  156. package/lib/stores/kv.d.cts +1 -1
  157. package/lib/stores/kv.d.ts +1 -1
  158. package/lib/{types-SO5OZm4s.d.ts → types-BsfXZyI3.d.ts} +64 -29
  159. package/lib/{types-Ba8H5_Wo.d.cts → types-CPnYv7RC.d.cts} +64 -29
  160. package/package.json +4 -5
  161. package/browser/board-livecards-client.js +0 -2
  162. package/examples/board/demo-shell-with-server.html +0 -272
  163. package/examples/board/doc.html +0 -465
  164. package/examples/board/server-config.json +0 -24
  165. package/examples/board/test/sse-worker.js +0 -49
  166. package/lib/chat-storage-lib-B9Q34Dyv.d.cts +0 -54
  167. package/lib/chat-storage-lib-DB9iSai2.d.ts +0 -54
  168. package/lib/chunk-5XHOHTLZ.cjs +0 -2
  169. package/lib/chunk-6APH25VI.js +0 -2
  170. package/lib/chunk-76C7N4YT.js +0 -3
  171. package/lib/chunk-76ON3V7R.js +0 -2
  172. package/lib/chunk-7ICPAABP.cjs +0 -7
  173. package/lib/chunk-ASR44K7H.cjs +0 -3
  174. package/lib/chunk-CPAXTVBQ.cjs +0 -2
  175. package/lib/chunk-EGRHWZRV.js +0 -2
  176. package/lib/chunk-EZENHAVZ.cjs +0 -2
  177. package/lib/chunk-GL2OHR2E.cjs +0 -2
  178. package/lib/chunk-GYQXDNNI.cjs +0 -2
  179. package/lib/chunk-HEEDJEKM.js +0 -2
  180. package/lib/chunk-IPLSRN6P.cjs +0 -4
  181. package/lib/chunk-J6EGN6S4.cjs +0 -3
  182. package/lib/chunk-JH37NJGP.js +0 -3
  183. package/lib/chunk-JJL5VOQZ.cjs +0 -3
  184. package/lib/chunk-NJJ7WEDT.cjs +0 -2
  185. package/lib/chunk-NKIQRCOM.cjs +0 -2
  186. package/lib/chunk-PBCDDO4V.cjs +0 -2
  187. package/lib/chunk-PBOQ4HYB.cjs +0 -2
  188. package/lib/chunk-PRKRXAVN.js +0 -3
  189. package/lib/chunk-QJVR3FWQ.js +0 -2
  190. package/lib/chunk-S44QZUDX.js +0 -2
  191. package/lib/chunk-TSN3RTXT.js +0 -4
  192. package/lib/chunk-VXJHBWK3.js +0 -2
  193. package/lib/chunk-WHDEBJLT.js +0 -7
  194. package/lib/chunk-YGALANRO.js +0 -2
  195. package/lib/chunk-ZCNN6XPV.js +0 -2
@@ -6,7 +6,7 @@
6
6
  * Targets the 'live' board with --cards-pattern cardT* to load only the 3
7
7
  * test cards (cardT-portfolio, cardT-market-prices, cardT-portfolio-value).
8
8
  *
9
- * T0: init-boardSSE initial payload → wait for all cards to complete
9
+ * T0: /sse streaming connect upsert fixtures → wait for all cards to complete
10
10
  * T1: PATCH holdings (+1 row) → verify recomputation (holdings +1, positions +1)
11
11
  *
12
12
  * Usage:
@@ -14,7 +14,6 @@
14
14
  */
15
15
 
16
16
  import { spawn, spawnSync } from 'node:child_process';
17
- import { Worker } from 'node:worker_threads';
18
17
  import { fileURLToPath } from 'node:url';
19
18
  import path from 'node:path';
20
19
  import http from 'node:http';
@@ -45,14 +44,24 @@ function isCopilotAvailable() {
45
44
 
46
45
  const skipT3a = cliArgs.includes('--skip-t3a') || !isCopilotAvailable();
47
46
  const skipT3b = cliArgs.includes('--skip-t3b');
47
+ const skipT3e = cliArgs.includes('--skip-t3e');
48
48
  const skipT3d = cliArgs.includes('--skip-t3d');
49
49
  const RUN_ID = `run-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
50
50
 
51
51
  const BOARD_ID = 'live';
52
52
  const BOARD_DIR = path.resolve(__dirname, '..');
53
53
  const SERVER_SCRIPT = path.resolve(BOARD_DIR, 'server', 'board-server.js');
54
- const SSE_WORKER_SCRIPT = path.join(__dirname, 'sse-worker.js');
55
- const CARD_PATTERN = 'cardT*';
54
+ // Force the board server to start with zero cards; the test upserts the
55
+ // three cardT-* fixtures itself in T0 so the SSE upsert/delta path is
56
+ // exercised end-to-end.
57
+ const CARD_PATTERN = '__none__*';
58
+ const CARDS_DIR = path.resolve(BOARD_DIR, 'cards');
59
+ const T0_CARD_FILES = [
60
+ 'cardT-portfolio.json',
61
+ 'cardT-market-prices.json',
62
+ 'cardT-portfolio-value.json',
63
+ ];
64
+ const T0_EXPECTED_CARD_IDS = ['card-market-prices', 'card-portfolio', 'card-portfolio-value'];
56
65
  const T2_FILE_CARD_ID = 'card-market-prices';
57
66
  const CHAT_CARD_ID = 'card-portfolio';
58
67
 
@@ -92,19 +101,16 @@ if (fs.existsSync(SETUP_DIR)) {
92
101
  // ---------------------------------------------------------------------------
93
102
 
94
103
  const NS = {
95
- initialPayload: null,
96
104
  statusSummary: null,
97
105
  statusGeneration: 0,
98
106
  computedValues: {},
99
107
  chatEvents: [],
108
+ allChatNotifications: [],
100
109
  boardEvents: [],
101
110
  };
102
111
 
103
112
  function applyFrame(payload) {
104
113
  if (payload && Array.isArray(payload.cardDefinitions)) {
105
- if (!NS.initialPayload && payload.cardDefinitions.length > 0) {
106
- NS.initialPayload = payload;
107
- }
108
114
  const summary = payload.statusSnapshot && payload.statusSnapshot.summary;
109
115
  if (summary) {
110
116
  NS.statusSummary = summary;
@@ -164,6 +170,39 @@ function parseSseBlocks(buffer) {
164
170
  return { payloads, remainder: buf };
165
171
  }
166
172
 
173
+ function parseRawSseBlocks(buffer) {
174
+ const frames = [];
175
+ let buf = buffer;
176
+ while (true) {
177
+ const idx = buf.indexOf('\n\n');
178
+ if (idx === -1) break;
179
+ const block = buf.slice(0, idx);
180
+ buf = buf.slice(idx + 2);
181
+ if (!block.trim()) continue;
182
+ const frame = { id: null, event: null, data: '', payload: null, raw: block };
183
+ const dataLines = [];
184
+ for (const line of block.split('\n')) {
185
+ if (line.startsWith(':')) continue;
186
+ if (line.startsWith('id:')) {
187
+ frame.id = line.slice(3).trim();
188
+ } else if (line.startsWith('event:')) {
189
+ frame.event = line.slice(6).trim();
190
+ } else if (line.startsWith('data:')) {
191
+ dataLines.push(line.slice(5).replace(/^ /, ''));
192
+ }
193
+ }
194
+ frame.data = dataLines.join('\n');
195
+ if (!frame.id && !frame.event && !frame.data) continue;
196
+ if (frame.data) {
197
+ try {
198
+ frame.payload = JSON.parse(frame.data);
199
+ } catch { /* ignore malformed */ }
200
+ }
201
+ frames.push(frame);
202
+ }
203
+ return { frames, remainder: buf };
204
+ }
205
+
167
206
  function startSseClient(sseUrl, onPayload) {
168
207
  const req = http.get(sseUrl, (res) => {
169
208
  let buf = '';
@@ -183,9 +222,127 @@ function startSseClient(sseUrl, onPayload) {
183
222
  };
184
223
  }
185
224
 
225
+ function startRawSseClient({ sseUrl, headers = {}, onResponse, onFrame, onClose, onError }) {
226
+ let closed = false;
227
+ const req = http.request(sseUrl, { headers }, (res) => {
228
+ let buf = '';
229
+ res.setEncoding('utf-8');
230
+ try { onResponse?.(res); } catch { /* ignore */ }
231
+ res.on('data', (chunk) => {
232
+ buf = normalizeSseChunkBuffer(buf, chunk);
233
+ const parsed = parseRawSseBlocks(buf);
234
+ buf = parsed.remainder;
235
+ for (const frame of parsed.frames) {
236
+ try { onFrame?.(frame, res); } catch { /* ignore */ }
237
+ }
238
+ });
239
+ const closeOnce = () => {
240
+ if (closed) return;
241
+ closed = true;
242
+ try { onClose?.(res); } catch { /* ignore */ }
243
+ };
244
+ res.on('end', closeOnce);
245
+ res.on('close', closeOnce);
246
+ res.on('error', (err) => {
247
+ try { onError?.(err); } catch { /* ignore */ }
248
+ });
249
+ });
250
+ req.on('error', (err) => {
251
+ try { onError?.(err); } catch { /* ignore */ }
252
+ });
253
+ req.end();
254
+ return {
255
+ close() {
256
+ try { req.destroy(); } catch { /* ignore */ }
257
+ },
258
+ };
259
+ }
260
+
261
+ function waitForRawSseFrames({ sseUrl, headers = {}, until, timeoutMs = 15_000, waitForClose = false }) {
262
+ return new Promise((resolve, reject) => {
263
+ const state = { statusCode: null, headers: {}, frames: [], closed: false };
264
+ let settled = false;
265
+ let client = null;
266
+ const predicate = typeof until === 'function' ? until : ((current) => current.frames.length > 0);
267
+
268
+ function maybeResolve() {
269
+ if (settled) return;
270
+ if (!predicate(state)) return;
271
+ if (waitForClose && !state.closed) return;
272
+ settled = true;
273
+ clearTimeout(timeout);
274
+ if (!waitForClose && client) client.close();
275
+ resolve(state);
276
+ }
277
+
278
+ const timeout = setTimeout(() => {
279
+ if (settled) return;
280
+ settled = true;
281
+ if (client) client.close();
282
+ reject(new Error(`Timeout (${timeoutMs}ms) waiting for raw SSE frames`));
283
+ }, timeoutMs);
284
+
285
+ client = startRawSseClient({
286
+ sseUrl,
287
+ headers,
288
+ onResponse(res) {
289
+ state.statusCode = res.statusCode ?? null;
290
+ state.headers = res.headers || {};
291
+ maybeResolve();
292
+ },
293
+ onFrame(frame) {
294
+ state.frames.push(frame);
295
+ maybeResolve();
296
+ },
297
+ onClose() {
298
+ state.closed = true;
299
+ maybeResolve();
300
+ },
301
+ onError(err) {
302
+ if (settled) return;
303
+ settled = true;
304
+ clearTimeout(timeout);
305
+ reject(err);
306
+ },
307
+ });
308
+ });
309
+ }
310
+
311
+ function waitForFirstSsePayload(sseUrl, timeoutMs = 15_000) {
312
+ return new Promise((resolve, reject) => {
313
+ let settled = false;
314
+ let client = null;
315
+ const timeout = setTimeout(() => {
316
+ if (settled) return;
317
+ settled = true;
318
+ if (client) client.close();
319
+ reject(new Error(`Timeout (${timeoutMs}ms) waiting for: first SSE payload`));
320
+ }, timeoutMs);
321
+
322
+ client = startSseClient(sseUrl, (payload) => {
323
+ if (settled) return;
324
+ settled = true;
325
+ clearTimeout(timeout);
326
+ client.close();
327
+ resolve(payload);
328
+ });
329
+ });
330
+ }
331
+
186
332
  function captureChatEvents(payload, cardId) {
187
333
  if (!payload || payload.kind !== 'notification-batch' || !Array.isArray(payload.notifications)) return;
188
334
  for (const n of payload.notifications) {
335
+ if (n && n.kind === 'card_chats' && n.cardId) {
336
+ const messages = Array.isArray(n.messages) ? n.messages : [];
337
+ NS.allChatNotifications.push({
338
+ at: Date.now(),
339
+ cardId: n.cardId,
340
+ processing: !!n.processing,
341
+ receiving: !!n.receiving,
342
+ messageCount: messages.length,
343
+ messages,
344
+ });
345
+ }
189
346
  if (n && n.kind === 'card_chats' && n.cardId === cardId) {
190
347
  const messages = Array.isArray(n.messages) ? n.messages : [];
191
348
  NS.chatEvents.push({
@@ -230,10 +387,159 @@ function waitUntil(predicate, timeoutMs, label) {
230
387
  });
231
388
  }
232
389
 
390
+ function waitUntilAsync(predicate, timeoutMs, label) {
391
+ return new Promise((resolve, reject) => {
392
+ const deadline = Date.now() + timeoutMs;
393
+ const interval = setInterval(async () => {
394
+ let result;
395
+ try { result = await predicate(); } catch { /* retry */ }
396
+ if (result !== undefined && result !== null && result !== false) {
397
+ clearInterval(interval);
398
+ resolve(result);
399
+ return;
400
+ }
401
+ if (Date.now() > deadline) {
402
+ clearInterval(interval);
403
+ reject(new Error(`Timeout (${timeoutMs}ms) waiting for: ${label}`));
404
+ }
405
+ }, 150);
406
+ });
407
+ }
408
+
233
409
  function wait(ms) {
234
410
  return new Promise((resolve) => setTimeout(resolve, ms));
235
411
  }
236
412
 
413
+ function extractStatusSummaryFromPayload(payload) {
414
+ if (payload?.statusSnapshot?.summary && typeof payload.statusSnapshot.summary === 'object') {
415
+ return payload.statusSnapshot.summary;
416
+ }
417
+ if (payload?.kind === 'notification-batch' && Array.isArray(payload.notifications)) {
418
+ for (const notification of payload.notifications) {
419
+ if (notification?.kind === 'status' && notification.status?.summary && typeof notification.status.summary === 'object') {
420
+ return notification.status.summary;
421
+ }
422
+ }
423
+ }
424
+ return null;
425
+ }
426
+
427
+ function normalizeHydratedChatMessages(messages) {
428
+ return (Array.isArray(messages) ? messages : []).map((message) => ({
429
+ role: String(message?.role || ''),
430
+ text: String(message?.text || ''),
431
+ files: Array.isArray(message?.files) ? message.files : [],
432
+ }));
433
+ }
434
+
435
+ function readHeaderValue(headers, name) {
436
+ const raw = headers?.[name.toLowerCase()];
437
+ return Array.isArray(raw) ? String(raw[0] || '') : String(raw || '');
438
+ }
439
+
440
+ function buildTsStaticCard(cardId, label) {
441
+ return {
442
+ id: cardId,
443
+ meta: {
444
+ title: label,
445
+ tags: ['ts', 'sse'],
446
+ desc: `${label} disposable SSE delta card`,
447
+ },
448
+ compute: [],
449
+ view: {
450
+ elements: [
451
+ {
452
+ kind: 'markdown',
453
+ data: {
454
+ bind: 'card_data.text',
455
+ },
456
+ },
457
+ ],
458
+ layout: {
459
+ board: {
460
+ col: 2,
461
+ order: 99,
462
+ },
463
+ canvas: {
464
+ x: 1600,
465
+ y: 600,
466
+ w: 260,
467
+ h: 120,
468
+ },
469
+ },
470
+ features: {},
471
+ },
472
+ card_data: {
473
+ text: label,
474
+ },
475
+ };
476
+ }
477
+
478
+ function buildChatProbeCard(cardId, label) {
479
+ return {
480
+ id: cardId,
481
+ meta: {
482
+ title: label,
483
+ tags: ['chat', 'probe'],
484
+ desc: `${label} disposable chat probe card`,
485
+ },
486
+ provides: [
487
+ {
488
+ bindTo: 'holdings',
489
+ ref: 'card_data.holdings',
490
+ },
491
+ ],
492
+ compute: [],
493
+ view: {
494
+ elements: [
495
+ {
496
+ kind: 'editable-table',
497
+ label: 'Holdings',
498
+ data: {
499
+ bind: 'card_data.holdings',
500
+ writeTo: 'card_data.holdings',
501
+ columns: ['ticker', 'quantity', 'cost_basis'],
502
+ schema: {
503
+ properties: {
504
+ quantity: { type: 'number' },
505
+ cost_basis: { type: 'number' },
506
+ },
507
+ },
508
+ },
509
+ },
510
+ ],
511
+ layout: {
512
+ board: {
513
+ col: 3,
514
+ order: 98,
515
+ },
516
+ canvas: {
517
+ x: 1400,
518
+ y: 420,
519
+ w: 320,
520
+ h: 260,
521
+ },
522
+ },
523
+ features: {
524
+ chat: true,
525
+ },
526
+ },
527
+ card_data: {
528
+ holdings: [
529
+ { ticker: 'AAPL', quantity: 1, cost_basis: 150 },
530
+ ],
531
+ },
532
+ };
533
+ }
534
+
535
+ function assertObjectContains(actual, expected, label) {
536
+ assert(actual && typeof actual === 'object', `${label} actual value is not an object`);
537
+ assert(expected && typeof expected === 'object', `${label} expected value is not an object`);
538
+ for (const [key, value] of Object.entries(expected)) {
539
+ assert(Object.is(actual[key], value), `${label}.${key} mismatch: expected ${JSON.stringify(value)}, got ${JSON.stringify(actual[key])}`);
540
+ }
541
+ }
542
+
237
543
  async function stopChildProcess(proc, label) {
238
544
  if (!proc) return;
239
545
  if (proc.exitCode !== null) return;
@@ -267,9 +573,6 @@ async function stopChildProcess(proc, label) {
267
573
  }
268
574
  }
269
575
 
270
- const waitForInitialPayload = (ms = 15_000) =>
271
- waitUntil(() => NS.initialPayload || false, ms, 'initial SSE payload');
272
-
273
576
  const waitForAllCompleted = (ms = 60_000, label = 'all completed') =>
274
577
  waitUntil(() => {
275
578
  const s = NS.statusSummary;
@@ -315,13 +618,21 @@ function deriveProbeLifecycleMilestones(events, opts) {
315
618
 
316
619
  function matchOrderedProbeLifecycle(events, opts) {
317
620
  const milestones = deriveProbeLifecycleMilestones(events, opts);
318
- if (milestones.length !== 5) return false;
319
- const firstPair = milestones.slice(0, 2);
320
- const lastPair = milestones.slice(3, 5);
321
- const firstOk = firstPair.includes('user') && firstPair.includes('processing-true');
322
- const middleOk = milestones[2] === 'in-progress';
323
- const lastOk = lastPair.includes('assistant') && lastPair.includes('processing-false');
324
- return (firstOk && middleOk && lastOk) ? { milestones } : false;
621
+ const userIdx = milestones.indexOf('user');
622
+ const processingTrueIdx = milestones.indexOf('processing-true');
623
+ const assistantIdx = milestones.indexOf('assistant');
624
+ const processingFalseIdx = milestones.lastIndexOf('processing-false');
625
+ const inProgressIdx = milestones.indexOf('in-progress');
626
+
627
+ if (userIdx === -1 || processingTrueIdx === -1 || assistantIdx === -1 || processingFalseIdx === -1) {
628
+ return false;
629
+ }
630
+
631
+ if (Math.max(userIdx, processingTrueIdx) >= assistantIdx) return false;
632
+ if (assistantIdx >= processingFalseIdx) return false;
633
+ if (inProgressIdx !== -1 && (inProgressIdx <= processingTrueIdx || inProgressIdx >= assistantIdx)) return false;
634
+
635
+ return { milestones };
325
636
  }
326
637
 
327
638
  function httpGet(url) {
@@ -502,12 +813,12 @@ console.log(`target: ${BASE}`);
502
813
  console.log(`card pattern: ${CARD_PATTERN}`);
503
814
 
504
815
  const serverProc = await startServer(PORT);
505
- let sseWorker = null;
816
+ let boardSseClient = null;
506
817
  let chatSseClient = null;
507
818
  let chatSseClientId = '';
508
819
 
509
820
  try {
510
- // ── T0: init-board, SSE connect, wait for initial completion ──
821
+ // ── T0: streaming SSE connect, upsert fixtures, wait for initial completion ──
511
822
 
512
823
  // Register the 'live' board via POST (v8 runtime requires explicit registration)
513
824
  const regRes = await httpJson('POST', `http://127.0.0.1:${PORT}/api/boards`, { id: BOARD_ID, label: 'Live' });
@@ -515,31 +826,34 @@ try {
515
826
  `POST /api/boards returned ${regRes.status}: ${JSON.stringify(regRes.data)}`);
516
827
  console.log(`[setup] board '${BOARD_ID}' registered (${regRes.status})`);
517
828
 
518
- console.log('\n=== T0 Step 1: init-board ===');
519
- const initRes = await httpGet(`${BASE}/init-board`);
520
- assert(initRes.status === 200, `init-board returned ${initRes.status}`);
521
- console.log('[T0.1] init-board ok');
522
-
523
- console.log('\n=== T0 Step 2: start SSE worker ===');
829
+ console.log('\n=== T0 Step 1: start SSE client (board expected empty) ===');
524
830
  const sseClientId = `server-http-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
525
831
  const sseUrl = `${BASE}/sse?clientId=${encodeURIComponent(sseClientId)}`;
526
- sseWorker = new Worker(SSE_WORKER_SCRIPT, {
527
- workerData: { sseUrl },
528
- });
529
- sseWorker.on('message', (msg) => {
530
- if (msg.type === 'frame') applyFrame(msg.payload);
531
- else if (msg.type === 'error') console.error(`[sse-worker] ${msg.message}`);
532
- });
533
- sseWorker.on('error', (err) => console.error(`[sse-worker] uncaught: ${err.message}`));
534
-
535
- const initialPayload = await waitForInitialPayload();
536
- const cardCount = Array.isArray(initialPayload.cardDefinitions) ? initialPayload.cardDefinitions.length : 0;
537
- assert(cardCount === 3, `expected 3 cards (cardT*), got ${cardCount}`);
538
- const cardIds = initialPayload.cardDefinitions.map(c => c.id).sort();
539
- console.log(`[T0.2] SSE initial payload received (${cardCount} cards: ${cardIds.join(', ')})`);
832
+ boardSseClient = startSseClient(sseUrl, applyFrame);
833
+ // Give the streaming endpoint a moment to deliver the initial (empty) snapshot.
834
+ await wait(500);
835
+ console.log('[T0.1] SSE client connected');
836
+
837
+ console.log('\n=== T0 Step 2: upsert 3 cardT-* fixtures ===');
838
+ for (const fileName of T0_CARD_FILES) {
839
+ const cardJson = JSON.parse(fs.readFileSync(path.join(CARDS_DIR, fileName), 'utf-8'));
840
+ const cardId = cardJson?.id;
841
+ assert(typeof cardId === 'string' && cardId.length > 0, `fixture ${fileName} missing id`);
842
+ const upsertRes = await httpMcp('manage.upsert-card', {
843
+ card_id: cardId,
844
+ candidate_card_content: cardJson,
845
+ });
846
+ assert(upsertRes.status === 200, `manage.upsert-card(${cardId}) returned ${upsertRes.status}: ${JSON.stringify(upsertRes.data)}`);
847
+ assert(upsertRes.data?.status === 'success', `manage.upsert-card(${cardId}) failed: ${JSON.stringify(upsertRes.data)}`);
848
+ console.log(`[T0.2] upserted ${cardId}`);
849
+ }
540
850
 
541
- console.log('\n=== T0 Step 3: wait for all cards to complete ===');
542
- const t0Summary = await waitForAllCompleted(30_000, 'T0 initial completion');
851
+ console.log('\n=== T0 Step 3: wait for all 3 cards to complete via SSE ===');
852
+ const t0Summary = await waitUntil(() => {
853
+ const s = NS.statusSummary;
854
+ if (s && s.card_count === 3 && s.completed === 3) return s;
855
+ return false;
856
+ }, 60_000, 'T0 initial completion (3 cards)');
543
857
  assert(t0Summary.failed === 0, `T0 expected failed=0, got ${t0Summary.failed}`);
544
858
  console.log(`[T0.3] completed: ${JSON.stringify(t0Summary)}`);
545
859
 
@@ -549,6 +863,8 @@ try {
549
863
  assert(statusMcpRes.data?.status === 'success', `inspect.board-runtime-status failed: ${JSON.stringify(statusMcpRes.data)}`);
550
864
  const mcpSummary = statusMcpRes.data?.data?.summary;
551
865
  assert(mcpSummary, 'summary missing from inspect.board-runtime-status');
866
+ assert(mcpSummary.card_count === T0_EXPECTED_CARD_IDS.length,
867
+ `expected card_count=${T0_EXPECTED_CARD_IDS.length}, got ${mcpSummary.card_count}`);
552
868
  assert(mcpSummary.completed === mcpSummary.card_count, `not all complete: ${JSON.stringify(mcpSummary)}`);
553
869
  console.log(`[T0.4] board-status: ${JSON.stringify(mcpSummary)}`);
554
870
 
@@ -740,25 +1056,34 @@ try {
740
1056
 
741
1057
  const t3TurnId = randomTurnId();
742
1058
  t3Dbg(`step 4: posting probe chat-send (turn-id=${t3TurnId})`);
743
- const t2SendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
744
- actionType: 'chat-send',
745
- payload: {
746
- text: `${ECHO_PROBE_MARKER}${t2ProbePrompt}${ECHO_PROBE_MARKER}`,
747
- 'turn-id': t3TurnId,
1059
+ const t2SendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1060
+ tool: 'chat-send',
1061
+ args: {
1062
+ card_id: CHAT_CARD_ID,
1063
+ payload: {
1064
+ text: `${ECHO_PROBE_MARKER}${t2ProbePrompt}${ECHO_PROBE_MARKER}`,
1065
+ 'turn-id': t3TurnId,
1066
+ },
748
1067
  },
749
1068
  });
750
1069
  t3Dbg(`step 4: chat-send returned status=${t2SendRes.status}`);
751
1070
  assert(t2SendRes.status === 200, `T3 chat-send returned ${t2SendRes.status}`);
752
1071
 
753
1072
  t3Dbg('step 5: waiting for ordered probe lifecycle on chat SSE');
754
- const t2Lifecycle = await waitForChatPredicate((events) => {
755
- return matchOrderedProbeLifecycle(events.slice(t2EventStart), {
756
- beforeCount: t2BeforeCount,
757
- beforeProcessing: false,
758
- prompt: t2ProbePrompt,
759
- inProgressText: PROBE_IN_PROGRESS_TEXT,
760
- });
761
- }, 45_000, 'T3 ordered lifecycle');
1073
+ let t2Lifecycle;
1074
+ try {
1075
+ t2Lifecycle = await waitForChatPredicate((events) => {
1076
+ return matchOrderedProbeLifecycle(events.slice(t2EventStart), {
1077
+ beforeCount: t2BeforeCount,
1078
+ beforeProcessing: false,
1079
+ prompt: t2ProbePrompt,
1080
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1081
+ });
1082
+ }, 45_000, 'T3 ordered lifecycle');
1083
+ } catch (error) {
1084
+ t3Dbg(`step 5: lifecycle timeout; events=${JSON.stringify(NS.chatEvents.slice(t2EventStart), null, 2)}`);
1085
+ throw error;
1086
+ }
762
1087
  t3Dbg('step 5: ordered lifecycle observed');
763
1088
  assert(!!t2Lifecycle, 'T3 ordered lifecycle not observed');
764
1089
 
@@ -769,12 +1094,12 @@ try {
769
1094
  const t2AfterMessages = Array.isArray(t2After.data?.data?.messages) ? t2After.data.data.messages : [];
770
1095
  const t2NewMessages = t2AfterMessages.slice(t2BeforeCount);
771
1096
  t3Dbg(`step 6: validating ${t2NewMessages.length} new messages`);
772
- assert(t2NewMessages.length >= 3, `T3 expected at least 3 new chat messages, got ${t2NewMessages.length}`);
1097
+ assert(t2NewMessages.length >= 2, `T3 expected at least 2 new chat messages, got ${t2NewMessages.length}`);
773
1098
  const t3McpAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3TurnId });
774
1099
  const t3McpAfterData = expectMcpSuccess(t3McpAfter, 'T3 MCP post chats');
775
1100
  const t3TurnMessages = Array.isArray(t3McpAfterData?.messages) ? t3McpAfterData.messages : [];
776
1101
  t3Dbg(`step 6: MCP turn messages count=${t3TurnMessages.length}`);
777
- assert(t3TurnMessages.length >= 3, `T3 expected at least 3 MCP messages for turn ${t3TurnId}, got ${t3TurnMessages.length}`);
1102
+ assert(t3TurnMessages.length >= 2, `T3 expected at least 2 MCP messages for turn ${t3TurnId}, got ${t3TurnMessages.length}`);
778
1103
  for (const msg of t3TurnMessages) {
779
1104
  assert(String(msg?.turn || '') === t3TurnId, 'T3 MCP turn id mismatch');
780
1105
  }
@@ -792,7 +1117,6 @@ try {
792
1117
  const t2AssistantMsg = t2NewMessages.find((m) => m?.role === 'assistant');
793
1118
  assert(!!t2User && typeof t2User.id === 'string', 'T3 user chat message missing id');
794
1119
  assert(String(t2User?.text || '').includes(t2ProbePrompt), 'T3 user file text mismatch');
795
- assert(!!t2InProgress && typeof t2InProgress.id === 'string', 'T3 in-progress system message missing id');
796
1120
  assert(!!t2AssistantMsg && typeof t2AssistantMsg.id === 'string', 'T3 assistant chat message missing id');
797
1121
  assert(String(t2AssistantMsg?.text || '').includes(`Echo: ${t2ProbePrompt}`), 'T3 assistant echo file content mismatch');
798
1122
  t3Dbg('step 6: all assertions passed');
@@ -818,14 +1142,17 @@ try {
818
1142
 
819
1143
  const t3aTurnId = randomTurnId();
820
1144
  t3aDbg(`step 2: posting non-probe chat-send (turn-id=${t3aTurnId})`);
821
- const t2aSendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
822
- actionType: 'chat-send',
823
- payload: {
824
- text: JSON.stringify({
825
- prompt: t2aPrompt,
826
- chatTimeoutMs: 180000,
827
- }),
828
- 'turn-id': t3aTurnId,
1145
+ const t2aSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1146
+ tool: 'chat-send',
1147
+ args: {
1148
+ card_id: CHAT_CARD_ID,
1149
+ payload: {
1150
+ text: JSON.stringify({
1151
+ prompt: t2aPrompt,
1152
+ chatTimeoutMs: 180000,
1153
+ }),
1154
+ 'turn-id': t3aTurnId,
1155
+ },
829
1156
  },
830
1157
  });
831
1158
  t3aDbg(`step 2: chat-send returned status=${t2aSendRes.status}`);
@@ -910,12 +1237,14 @@ try {
910
1237
  const t2bEventStart = NS.chatEvents.length;
911
1238
 
912
1239
  const t2bPrompt = `probe echo file-upload validation ${Date.now()}`;
913
- const t2bSendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
914
- actionType: 'chat-send',
915
- payload: {
916
- text: `${ECHO_PROBE_MARKER}${t2bPrompt}${ECHO_PROBE_MARKER}`,
917
- files: [uploadedFile],
918
- 'turn-id': t3bTurnId,
1240
+ const t2bSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1241
+ tool: 'chat-send',
1242
+ args: {
1243
+ card_id: CHAT_CARD_ID,
1244
+ payload: {
1245
+ text: `${ECHO_PROBE_MARKER}${t2bPrompt}${ECHO_PROBE_MARKER}`,
1246
+ 'turn-id': t3bTurnId,
1247
+ },
919
1248
  },
920
1249
  });
921
1250
  assert(t2bSendRes.status === 200, `T3b chat-send returned ${t2bSendRes.status}`);
@@ -925,28 +1254,336 @@ try {
925
1254
  beforeCount: t2bSendBaseline,
926
1255
  beforeProcessing: false,
927
1256
  prompt: t2bPrompt,
928
- assistantText: 'tokyo',
929
1257
  inProgressText: PROBE_IN_PROGRESS_TEXT,
930
1258
  });
931
- }, 60_000, 'T3b ordered lifecycle');
1259
+ }, 60_000, 'T3b ordered lifecycle').catch(async (err) => {
1260
+ const t2bEvents = NS.chatEvents.slice(t2bEventStart);
1261
+ const t2bMilestones = deriveProbeLifecycleMilestones(t2bEvents, {
1262
+ beforeCount: t2bSendBaseline,
1263
+ beforeProcessing: false,
1264
+ prompt: t2bPrompt,
1265
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1266
+ });
1267
+ const t2bCurrent = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1268
+ console.error('[T3b.DBG timeout] milestones=', JSON.stringify(t2bMilestones));
1269
+ console.error('[T3b.DBG timeout] events=', JSON.stringify(t2bEvents));
1270
+ console.error('[T3b.DBG timeout] inspect=', JSON.stringify(t2bCurrent?.data ?? null));
1271
+ throw err;
1272
+ });
932
1273
  assert(!!t2bLifecycle, 'T3b ordered lifecycle not observed');
933
1274
 
934
1275
  const t2bAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
935
1276
  assert(t2bAfter.status === 200, `T3b post chats returned ${t2bAfter.status}`);
936
1277
  const t2bAfterMessages = Array.isArray(t2bAfter.data?.data?.messages) ? t2bAfter.data.data.messages : [];
937
1278
  const t2bNewMessages = t2bAfterMessages.slice(t2bSendBaseline);
938
- assert(t2bNewMessages.length >= 3, `T3b expected at least 3 chat messages after send, got ${t2bNewMessages.length}`);
1279
+ assert(t2bNewMessages.length >= 2, `T3b expected at least 2 chat messages after send, got ${t2bNewMessages.length}`);
939
1280
 
940
1281
  const t2bUser = t2bNewMessages.find((m) => m?.role === 'user');
941
1282
  const t2bInProgress = t2bNewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
942
1283
  const t2bAssistantMsg = t2bNewMessages.find((m) => m?.role === 'assistant');
943
1284
 
944
1285
  assert(!!t2bUser && typeof t2bUser.id === 'string', 'T3b missing user chat message notification');
945
- assert(!!t2bInProgress && typeof t2bInProgress.id === 'string', 'T3b missing in-progress system chat message');
946
1286
  assert(!!t2bAssistantMsg && typeof t2bAssistantMsg.id === 'string', 'T3b missing assistant chat message notification');
947
- assert(Array.isArray(t2bUser?.files) && t2bUser.files.length === 1, 'T3b user chat message missing uploaded file metadata');
948
- assert(String(t2bAssistantMsg?.text || '').trim() === 'tokyo', 'T3b assistant attachment content mismatch');
949
- console.log('[T3b] ok: upload protocol and ordered probe lifecycle observed with attachment-derived assistant reply');
1287
+ assert(!Array.isArray(t2bUser?.files) || t2bUser.files.length === 0, 'T3b user chat message should remain text-only after add-chat-attachment upload');
1288
+ assert(String(t2bAssistantMsg?.text || '').includes(`Echo: ${t2bPrompt}`), 'T3b assistant probe echo mismatch');
1289
+ console.log('[T3b] ok: add-chat-attachment upload plus text-only chat-send preserved the normal probe lifecycle');
1290
+
1291
+ if (skipT3e) {
1292
+ console.log('\n=== T3e: skipped (--skip-t3e) ===');
1293
+ } else {
1294
+ console.log('\n=== T3e: subscribed chat turn with attachment plus unsubscribed negative case ===');
1295
+ const t3eOtherCardId = `card-t3e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1296
+ const t3eOtherCard = buildChatProbeCard(t3eOtherCardId, 'T3e Chat Probe');
1297
+
1298
+ const t3eUpsertOtherRes = await httpMcp('manage.upsert-card', {
1299
+ card_id: t3eOtherCardId,
1300
+ candidate_card_content: t3eOtherCard,
1301
+ });
1302
+ assert(t3eUpsertOtherRes.status === 200, `T3e manage.upsert-card(${t3eOtherCardId}) returned ${t3eUpsertOtherRes.status}`);
1303
+ assert(t3eUpsertOtherRes.data?.status === 'success', `T3e manage.upsert-card(${t3eOtherCardId}) failed: ${JSON.stringify(t3eUpsertOtherRes.data)}`);
1304
+ await waitUntil(() => {
1305
+ const s = NS.statusSummary;
1306
+ if (s && s.card_count === T0_EXPECTED_CARD_IDS.length + 1) return s;
1307
+ return false;
1308
+ }, 30_000, 'T3e extra chat card visible in board summary');
1309
+
1310
+ try {
1311
+ const t3eBefore = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1312
+ assert(t3eBefore.status === 200, `T3e pre chats returned ${t3eBefore.status}`);
1313
+ const t3eBeforeMessages = Array.isArray(t3eBefore.data?.data?.messages) ? t3eBefore.data.data.messages : [];
1314
+ const t3eBeforeCount = t3eBeforeMessages.length;
1315
+
1316
+ const t3eTurnId = randomTurnId();
1317
+ const t3eUploadRes = await httpMcpControlplane('manage.add-chat-attachment', {
1318
+ board_id: BOARD_ID,
1319
+ card_id: CHAT_CARD_ID,
1320
+ turn_id: t3eTurnId,
1321
+ file_name: 't3e-probe.txt',
1322
+ content_type: 'text/plain; charset=utf-8',
1323
+ text: 'what is the capital of japan',
1324
+ });
1325
+ assert(t3eUploadRes.status === 200, `T3e file upload returned ${t3eUploadRes.status}`);
1326
+ assert(t3eUploadRes.data?.status === 'success', `T3e file upload failed: ${JSON.stringify(t3eUploadRes.data)}`);
1327
+ const t3eUploadedFile = t3eUploadRes.data?.data?.files?.[0];
1328
+ assert(t3eUploadedFile && typeof t3eUploadedFile === 'object', 'T3e upload response missing file metadata');
1329
+ assert(!Object.prototype.hasOwnProperty.call(t3eUploadedFile, 'path'), 'T3e uploaded file metadata should not expose path');
1330
+
1331
+ const t3eCardAfterUpload = await httpMcp('manage.read-card', { card_id: CHAT_CARD_ID });
1332
+ assert(t3eCardAfterUpload.status === 200, `T3e card read after upload returned ${t3eCardAfterUpload.status}`);
1333
+ const t3eStoredFiles = Array.isArray(t3eCardAfterUpload.data?.data?.[0]?.card_data?.files)
1334
+ ? t3eCardAfterUpload.data.data[0].card_data.files
1335
+ : [];
1336
+ const t3eStoredFile = t3eStoredFiles.find((file) => String(file?.stored_name || '') === String(t3eUploadedFile?.stored_name || ''));
1337
+ assert(!!t3eStoredFile, 'T3e stored file metadata missing after upload');
1338
+ assert(t3eStoredFile?.chat === true, 'T3e stored file should be marked as chat-origin');
1339
+ assert(!Object.prototype.hasOwnProperty.call(t3eStoredFile || {}, 'path'), 'T3e stored file metadata should not expose path');
1340
+
1341
+ const t3eUploadMessages = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3eTurnId });
1342
+ assert(t3eUploadMessages.status === 200, `T3e chats after upload returned ${t3eUploadMessages.status}`);
1343
+ const t3eUploadTurnMessages = Array.isArray(t3eUploadMessages.data?.data?.messages) ? t3eUploadMessages.data.data.messages : [];
1344
+ const t3eUploadSystem = t3eUploadTurnMessages.find((message) => message?.role === 'system');
1345
+ assert(!!t3eUploadSystem, 'T3e upload protocol missing system chat message');
1346
+ assert(String(t3eUploadSystem?.text || '').toLowerCase().includes('file uploaded:'), 'T3e upload system message does not describe uploaded file');
1347
+
1348
+ const t3eEventStart = NS.chatEvents.length;
1349
+ const t3eAllNotificationsStart = NS.allChatNotifications.length;
1350
+ const t3ePrompt = `attachment probe ${Date.now()}`;
1351
+ const t3eProbeText = `${ECHO_PROBE_MARKER}${t3ePrompt}${ECHO_PROBE_MARKER}`;
1352
+ const t3eSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1353
+ tool: 'chat-send',
1354
+ args: {
1355
+ card_id: CHAT_CARD_ID,
1356
+ payload: {
1357
+ text: t3eProbeText,
1358
+ 'turn-id': t3eTurnId,
1359
+ },
1360
+ },
1361
+ });
1362
+ assert(t3eSendRes.status === 200, `T3e chat-send returned ${t3eSendRes.status}`);
1363
+
1364
+ const t3eLifecycle = await waitForChatPredicate((events) => {
1365
+ return matchOrderedProbeLifecycle(events.slice(t3eEventStart), {
1366
+ beforeCount: t3eBeforeCount + t3eUploadTurnMessages.length,
1367
+ beforeProcessing: false,
1368
+ prompt: t3ePrompt,
1369
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1370
+ });
1371
+ }, 60_000, 'T3e ordered lifecycle');
1372
+ assert(!!t3eLifecycle, 'T3e ordered lifecycle not observed');
1373
+
1374
+ const t3eAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3eTurnId });
1375
+ assert(t3eAfter.status === 200, `T3e post chats returned ${t3eAfter.status}`);
1376
+ const t3eFinalMessages = Array.isArray(t3eAfter.data?.data?.messages) ? t3eAfter.data.data.messages : [];
1377
+ const t3eFinalUser = t3eFinalMessages.find((message) => message?.role === 'user');
1378
+ const t3eFinalAssistant = t3eFinalMessages.find((message) => message?.role === 'assistant');
1379
+ assert(!!t3eFinalUser, `T3e final user message missing: ${JSON.stringify(t3eFinalMessages)}`);
1380
+ assert(!!t3eFinalAssistant, `T3e final assistant message missing: ${JSON.stringify(t3eFinalMessages)}`);
1381
+ assert(String(t3eFinalUser?.text || '') === t3ePrompt, `T3e final user text mismatch: ${JSON.stringify(t3eFinalUser)}`);
1382
+ assert(!Array.isArray(t3eFinalUser?.files) || t3eFinalUser.files.length === 0,
1383
+ `T3e final user message should remain text-only after controlplane attachment upload: ${JSON.stringify(t3eFinalUser)}`);
1384
+ assert(String(t3eFinalAssistant?.text || '').includes(`Echo: ${t3ePrompt}`), `T3e final probe reply mismatch: ${JSON.stringify(t3eFinalAssistant)}`);
1385
+
1386
+ const t3eNegativeTurnId = randomTurnId();
1387
+ const t3eNegativeSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1388
+ tool: 'chat-send',
1389
+ args: {
1390
+ card_id: t3eOtherCardId,
1391
+ payload: {
1392
+ text: `${ECHO_PROBE_MARKER}negative unsubscribed ${Date.now()}${ECHO_PROBE_MARKER}`,
1393
+ 'turn-id': t3eNegativeTurnId,
1394
+ },
1395
+ },
1396
+ });
1397
+ assert(t3eNegativeSendRes.status === 200, `T3e negative chat-send returned ${t3eNegativeSendRes.status}`);
1398
+
1399
+ const t3eNegativePersisted = await waitUntilAsync(async () => {
1400
+ const result = await httpMcp('inspect.chat-messages-on-cards', { card_id: t3eOtherCardId, turn_id: t3eNegativeTurnId });
1401
+ if (result.status !== 200) return false;
1402
+ const messages = Array.isArray(result.data?.data?.messages) ? result.data.data.messages : [];
1403
+ return messages.find((message) => message?.role === 'assistant') ? messages : false;
1404
+ }, 60_000, 'T3e negative turn persisted on unsubscribed card');
1405
+ assert(Array.isArray(t3eNegativePersisted), 'T3e negative turn did not persist as expected');
1406
+
1407
+ await wait(1_500);
1408
+ const t3eUnexpectedNotification = NS.allChatNotifications.slice(t3eAllNotificationsStart)
1409
+ .find((event) => event?.cardId === t3eOtherCardId);
1410
+ assert(!t3eUnexpectedNotification,
1411
+ `T3e unsubscribed client unexpectedly received chat notification for ${t3eOtherCardId}: ${JSON.stringify(t3eUnexpectedNotification)}`);
1412
+
1413
+ console.log('[T3e] ok: subscribed client received attachment-bearing turn and unsubscribed card produced no chat SSE notification');
1414
+ } finally {
1415
+ const t3eRemoveOtherRes = await httpMcp('manage.remove-card', { card_id: t3eOtherCardId });
1416
+ assert(t3eRemoveOtherRes.status === 200, `T3e manage.remove-card(${t3eOtherCardId}) returned ${t3eRemoveOtherRes.status}`);
1417
+ assert(t3eRemoveOtherRes.data?.status === 'success', `T3e manage.remove-card(${t3eOtherCardId}) failed: ${JSON.stringify(t3eRemoveOtherRes.data)}`);
1418
+ await waitUntil(() => {
1419
+ const s = NS.statusSummary;
1420
+ if (s && s.card_count === T0_EXPECTED_CARD_IDS.length) return s;
1421
+ return false;
1422
+ }, 30_000, 'T3e cleanup card_count back to 3');
1423
+ }
1424
+ }
1425
+
1426
+ console.log('\n=== T3c: fresh /sse connect hydrates current board state ===');
1427
+ const t3cInspectStatusRes = await httpMcp('inspect.board-runtime-status', {});
1428
+ assert(t3cInspectStatusRes.status === 200, `T3c inspect.board-runtime-status returned ${t3cInspectStatusRes.status}`);
1429
+ assert(t3cInspectStatusRes.data?.status === 'success', `T3c inspect.board-runtime-status failed: ${JSON.stringify(t3cInspectStatusRes.data)}`);
1430
+ const t3cExpectedSummary = t3cInspectStatusRes.data?.data?.summary;
1431
+ assert(t3cExpectedSummary, 'T3c summary missing from inspect.board-runtime-status');
1432
+
1433
+ const t3cExpectedCards = {};
1434
+ for (const cardId of T0_EXPECTED_CARD_IDS) {
1435
+ const t3cInspectCardRes = await httpMcp('inspect.card-definition-and-runtime', { card_id: cardId });
1436
+ assert(t3cInspectCardRes.status === 200, `T3c inspect.card-definition-and-runtime(${cardId}) returned ${t3cInspectCardRes.status}`);
1437
+ assert(t3cInspectCardRes.data?.status === 'success', `T3c inspect.card-definition-and-runtime(${cardId}) failed: ${JSON.stringify(t3cInspectCardRes.data)}`);
1438
+ const t3cInspectCardData = t3cInspectCardRes.data?.data;
1439
+ assert(t3cInspectCardData && typeof t3cInspectCardData === 'object', `T3c inspect.card-definition-and-runtime(${cardId}) missing data`);
1440
+ t3cExpectedCards[cardId] = t3cInspectCardData;
1441
+ }
1442
+
1443
+ const t3cRefreshClientId = `server-http-refresh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1444
+ const t3cRefreshPayload = await waitForFirstSsePayload(`${BASE}/sse?clientId=${encodeURIComponent(t3cRefreshClientId)}`);
1445
+ assert(t3cRefreshPayload && typeof t3cRefreshPayload === 'object', 'T3c missing refresh SSE payload');
1446
+
1447
+ const t3cCardDefinitions = Array.isArray(t3cRefreshPayload.cardDefinitions) ? t3cRefreshPayload.cardDefinitions : [];
1448
+ const t3cCardIds = t3cCardDefinitions.map((card) => card?.id).filter((id) => typeof id === 'string').sort();
1449
+ assert(JSON.stringify(t3cCardIds) === JSON.stringify(T0_EXPECTED_CARD_IDS),
1450
+ `T3c refreshed SSE cardDefinitions mismatch: ${JSON.stringify(t3cCardIds)}`);
1451
+
1452
+ const t3cStatusSummary = t3cRefreshPayload.statusSnapshot?.summary;
1453
+ assert(t3cStatusSummary, 'T3c refresh SSE payload missing statusSnapshot.summary');
1454
+ assertObjectContains(t3cStatusSummary, t3cExpectedSummary, 'T3c refresh SSE summary');
1455
+
1456
+ const t3cCardRuntimeById = t3cRefreshPayload.cardRuntimeById && typeof t3cRefreshPayload.cardRuntimeById === 'object'
1457
+ ? t3cRefreshPayload.cardRuntimeById
1458
+ : {};
1459
+ for (const card of t3cCardDefinitions) {
1460
+ const cardId = card?.id;
1461
+ if (typeof cardId !== 'string' || !t3cExpectedCards[cardId]) continue;
1462
+ const t3cExpectedCard = t3cExpectedCards[cardId];
1463
+ assert(JSON.stringify(card) === JSON.stringify(t3cExpectedCard.card_definition_and_static_data),
1464
+ `T3c refresh SSE cardDefinitions[${cardId}] mismatch`);
1465
+
1466
+ const t3cHydratedCardRuntime = t3cCardRuntimeById[cardId];
1467
+ assert(t3cHydratedCardRuntime && typeof t3cHydratedCardRuntime === 'object', `T3c refresh SSE payload missing cardRuntimeById.${cardId}`);
1468
+ assert(JSON.stringify(t3cHydratedCardRuntime.card_data || {}) === JSON.stringify(t3cExpectedCard.card_definition_and_static_data?.card_data || {}),
1469
+ `T3c refresh SSE cardRuntimeById.${cardId}.card_data mismatch`);
1470
+ assert(JSON.stringify(t3cHydratedCardRuntime.computed_values || {}) === JSON.stringify(t3cExpectedCard.runtime_data?.computed_values || {}),
1471
+ `T3c refresh SSE cardRuntimeById.${cardId}.computed_values mismatch`);
1472
+ }
1473
+ console.log('[T3c] ok: fresh /sse first payload hydrated the current board state');
1474
+
1475
+ console.log('\n=== TS: one-shot, raw framing, replay, delta ordering, and chat hydration ===');
1476
+ const tsExpectedChatRes = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1477
+ assert(tsExpectedChatRes.status === 200, `TS inspect.chat-messages-on-cards returned ${tsExpectedChatRes.status}`);
1478
+ const tsExpectedChatMessages = normalizeHydratedChatMessages(tsExpectedChatRes.data?.data?.messages || []);
1479
+
1480
+ const tsOneShot = await waitForRawSseFrames({
1481
+ sseUrl: `${BASE}/sse?one-shot`,
1482
+ timeoutMs: 15_000,
1483
+ until: (state) => state.frames.length >= 1,
1484
+ waitForClose: true,
1485
+ });
1486
+ assert(tsOneShot.statusCode === 200, `TS one-shot returned ${tsOneShot.statusCode}`);
1487
+ assert(/text\/event-stream/i.test(readHeaderValue(tsOneShot.headers, 'content-type')),
1488
+ `TS one-shot content-type mismatch: ${readHeaderValue(tsOneShot.headers, 'content-type')}`);
1489
+ assert(tsOneShot.closed === true, 'TS one-shot connection should close after first frame');
1490
+ assert(tsOneShot.frames.length === 1, `TS one-shot expected exactly 1 frame, got ${tsOneShot.frames.length}`);
1491
+ const tsOneShotFrame = tsOneShot.frames[0];
1492
+ assert(/^\d+$/.test(String(tsOneShotFrame.id || '')), `TS one-shot frame missing numeric id: ${JSON.stringify(tsOneShotFrame)}`);
1493
+ assert(tsOneShotFrame.payload && typeof tsOneShotFrame.payload === 'object', 'TS one-shot frame missing JSON payload');
1494
+ const tsOneShotChatState = tsOneShotFrame.payload.cardChatsByCardId?.[CHAT_CARD_ID];
1495
+ assert(tsOneShotChatState && typeof tsOneShotChatState === 'object', `TS one-shot payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1496
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsOneShotChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1497
+ 'TS one-shot cardChatsByCardId hydration mismatch');
1498
+
1499
+ const tsDeltaClientId = `ts-delta-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1500
+ const tsRawFrames = [];
1501
+ const tsDeltaClient = startRawSseClient({
1502
+ sseUrl: `${BASE}/sse?clientId=${encodeURIComponent(tsDeltaClientId)}`,
1503
+ onFrame(frame) {
1504
+ tsRawFrames.push(frame);
1505
+ },
1506
+ });
1507
+
1508
+ try {
1509
+ const tsInitialFrame = await waitUntil(() => tsRawFrames[0] || false, 15_000, 'TS initial raw SSE frame');
1510
+ assert(/^\d+$/.test(String(tsInitialFrame.id || '')), `TS initial streaming frame missing numeric id: ${JSON.stringify(tsInitialFrame)}`);
1511
+ const tsInitialChatState = tsInitialFrame.payload?.cardChatsByCardId?.[CHAT_CARD_ID];
1512
+ assert(tsInitialChatState && typeof tsInitialChatState === 'object', `TS initial streaming payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1513
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsInitialChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1514
+ 'TS initial streaming cardChatsByCardId hydration mismatch');
1515
+
1516
+ const tsTempCards = [
1517
+ buildTsStaticCard(`card-ts-${Date.now()}-a`, 'TS Delta Card A'),
1518
+ buildTsStaticCard(`card-ts-${Date.now()}-b`, 'TS Delta Card B'),
1519
+ ];
1520
+ let tsLastEventId = Number(tsInitialFrame.id);
1521
+
1522
+ for (let idx = 0; idx < tsTempCards.length; idx += 1) {
1523
+ const tsCard = tsTempCards[idx];
1524
+ const tsExpectedCardCount = T0_EXPECTED_CARD_IDS.length + idx + 1;
1525
+ const tsFrameStart = tsRawFrames.length;
1526
+ const tsUpsertRes = await httpMcp('manage.upsert-card', {
1527
+ card_id: tsCard.id,
1528
+ candidate_card_content: tsCard,
1529
+ });
1530
+ assert(tsUpsertRes.status === 200, `TS manage.upsert-card(${tsCard.id}) returned ${tsUpsertRes.status}`);
1531
+ assert(tsUpsertRes.data?.status === 'success', `TS manage.upsert-card(${tsCard.id}) failed: ${JSON.stringify(tsUpsertRes.data)}`);
1532
+ const tsDeltaFrame = await waitUntil(() => {
1533
+ for (const frame of tsRawFrames.slice(tsFrameStart)) {
1534
+ const summary = extractStatusSummaryFromPayload(frame.payload);
1535
+ if (summary?.card_count === tsExpectedCardCount) return frame;
1536
+ }
1537
+ return false;
1538
+ }, 30_000, `TS board delta card_count=${tsExpectedCardCount}`);
1539
+ assert(Number(tsDeltaFrame.id) > tsLastEventId,
1540
+ `TS delta frame id did not increase: prev=${tsLastEventId}, next=${JSON.stringify(tsDeltaFrame.id)}`);
1541
+ tsLastEventId = Number(tsDeltaFrame.id);
1542
+ }
1543
+
1544
+ tsDeltaClient.close();
1545
+ await wait(250);
1546
+
1547
+ const tsReconnect = await waitForRawSseFrames({
1548
+ sseUrl: `${BASE}/sse?clientId=${encodeURIComponent(tsDeltaClientId)}`,
1549
+ headers: { 'Last-Event-ID': String(tsLastEventId) },
1550
+ timeoutMs: 15_000,
1551
+ until: (state) => state.frames.length >= 1,
1552
+ });
1553
+ assert(tsReconnect.statusCode === 200, `TS reconnect returned ${tsReconnect.statusCode}`);
1554
+ assert(/text\/event-stream/i.test(readHeaderValue(tsReconnect.headers, 'content-type')),
1555
+ `TS reconnect content-type mismatch: ${readHeaderValue(tsReconnect.headers, 'content-type')}`);
1556
+ const tsReconnectFrame = tsReconnect.frames[0];
1557
+ assert(Number(tsReconnectFrame.id) > tsLastEventId,
1558
+ `TS reconnect frame id did not advance beyond Last-Event-ID: prev=${tsLastEventId}, next=${JSON.stringify(tsReconnectFrame.id)}`);
1559
+ const tsReconnectPayload = tsReconnectFrame.payload;
1560
+ assert(tsReconnectPayload && typeof tsReconnectPayload === 'object', 'TS reconnect first frame missing JSON payload');
1561
+ const tsReconnectIds = (Array.isArray(tsReconnectPayload.cardDefinitions) ? tsReconnectPayload.cardDefinitions : [])
1562
+ .map((card) => card?.id)
1563
+ .filter((cardId) => typeof cardId === 'string')
1564
+ .sort();
1565
+ const tsExpectedReconnectIds = [...T0_EXPECTED_CARD_IDS, ...tsTempCards.map((card) => card.id)].sort();
1566
+ assert(JSON.stringify(tsReconnectIds) === JSON.stringify(tsExpectedReconnectIds),
1567
+ `TS reconnect snapshot mismatch: expected ${JSON.stringify(tsExpectedReconnectIds)}, got ${JSON.stringify(tsReconnectIds)}`);
1568
+ const tsReconnectChatState = tsReconnectPayload.cardChatsByCardId?.[CHAT_CARD_ID];
1569
+ assert(tsReconnectChatState && typeof tsReconnectChatState === 'object', `TS reconnect payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1570
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsReconnectChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1571
+ 'TS reconnect cardChatsByCardId hydration mismatch');
1572
+
1573
+ for (const tsCard of tsTempCards) {
1574
+ const tsRemoveRes = await httpMcp('manage.remove-card', { card_id: tsCard.id });
1575
+ assert(tsRemoveRes.status === 200, `TS manage.remove-card(${tsCard.id}) returned ${tsRemoveRes.status}`);
1576
+ assert(tsRemoveRes.data?.status === 'success', `TS manage.remove-card(${tsCard.id}) failed: ${JSON.stringify(tsRemoveRes.data)}`);
1577
+ }
1578
+ await waitUntil(() => {
1579
+ const summary = NS.statusSummary;
1580
+ if (summary && summary.card_count === T0_EXPECTED_CARD_IDS.length) return summary;
1581
+ return false;
1582
+ }, 30_000, 'TS cleanup card_count back to 3');
1583
+ } finally {
1584
+ tsDeltaClient.close();
1585
+ }
1586
+ console.log('[TS] ok: one-shot framing, event ids, Last-Event-ID reconnect, ordered board deltas, and initial chat hydration verified');
950
1587
  }
951
1588
 
952
1589
  // ── T3d: probe-echo chat with one AI-generated attachment ──
@@ -982,11 +1619,14 @@ try {
982
1619
  const t3dTurnId = randomTurnId();
983
1620
  const t2dPrompt = `probe generated attachment validation ${Date.now()}`;
984
1621
  const t2dEventStart = NS.chatEvents.length;
985
- const t2dSendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
986
- actionType: 'chat-send',
987
- payload: {
988
- text: `${ECHO_PROBE_MARKER}[attach] ${t2dPrompt}${ECHO_PROBE_MARKER}`,
989
- 'turn-id': t3dTurnId,
1622
+ const t2dSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1623
+ tool: 'chat-send',
1624
+ args: {
1625
+ card_id: CHAT_CARD_ID,
1626
+ payload: {
1627
+ text: `${ECHO_PROBE_MARKER}echoattach__ ${t2dPrompt}${ECHO_PROBE_MARKER}`,
1628
+ 'turn-id': t3dTurnId,
1629
+ },
990
1630
  },
991
1631
  });
992
1632
  assert(t2dSendRes.status === 200, `T3d chat-send returned ${t2dSendRes.status}`);
@@ -1006,7 +1646,7 @@ try {
1006
1646
  assert(t2dAfter.status === 200, `T3d post chats returned ${t2dAfter.status}`);
1007
1647
  const t2dAfterMessages = Array.isArray(t2dAfter.data?.data?.messages) ? t2dAfter.data.data.messages : [];
1008
1648
  const t2dNewMessages = t2dAfterMessages.slice(t2dBeforeCount);
1009
- assert(t2dNewMessages.length >= 4, `T3d expected at least 4 chat messages after send, got ${t2dNewMessages.length}`);
1649
+ assert(t2dNewMessages.length >= 3, `T3d expected at least 3 chat messages after send, got ${t2dNewMessages.length}`);
1010
1650
 
1011
1651
  const t2dUser = t2dNewMessages.find((m) => m?.role === 'user');
1012
1652
  const t2dInProgress = t2dNewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
@@ -1014,7 +1654,6 @@ try {
1014
1654
  const t2dAssistantMsg = t2dNewMessages.find((m) => m?.role === 'assistant');
1015
1655
 
1016
1656
  assert(!!t2dUser && typeof t2dUser.id === 'string', 'T3d missing user chat message');
1017
- assert(!!t2dInProgress && typeof t2dInProgress.id === 'string', 'T3d missing in-progress system chat message');
1018
1657
  assert(!!t2dAiGenerated && typeof t2dAiGenerated.id === 'string', 'T3d missing AI-generated attachment system chat message');
1019
1658
  assert(/#\d+\s*$/.test(String(t2dAiGenerated?.text || '')), 'T3d AI-generated system message should include merged file index');
1020
1659
  assert(String(t2dAiGenerated?.turn || '') === t3dTurnId, 'T3d AI-generated system turn id mismatch');
@@ -1643,8 +2282,8 @@ try {
1643
2282
  } catch { /* ignore */ }
1644
2283
  }
1645
2284
  if (chatSseClient) chatSseClient.close();
2285
+ if (boardSseClient) boardSseClient.close();
1646
2286
  await stopChildProcess(serverProc, 'demo board server');
1647
- if (sseWorker) await sseWorker.terminate();
1648
2287
 
1649
2288
  // Clean up the test setup directory
1650
2289
  if (fs.existsSync(SETUP_DIR)) {