yaml-flow 8.8.0 → 8.8.6

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 (92) hide show
  1. package/browser/adapters/firestore-storage.js +2 -2
  2. package/browser/adapters/localstorage-storage.js +1 -1
  3. package/browser/asset-integrity.json +6 -6
  4. package/browser/live-cards.js +6 -6
  5. package/browser/server-runtime-controlface.js +4 -4
  6. package/examples/board/server/chat-flow/flow-steps.json +5 -5
  7. package/examples/board/test/server-http-test.js +623 -18
  8. package/lib/board-live-cards-node.cjs +7 -7
  9. package/lib/board-live-cards-node.d.cts +61 -7
  10. package/lib/board-live-cards-node.d.ts +61 -7
  11. package/lib/board-live-cards-node.js +7 -7
  12. package/lib/board-live-cards-public.cjs +1 -1
  13. package/lib/board-live-cards-public.js +1 -1
  14. package/lib/board-live-cards-server-runtime.cjs +1 -1
  15. package/lib/board-live-cards-server-runtime.d.cts +1 -1
  16. package/lib/board-live-cards-server-runtime.d.ts +1 -1
  17. package/lib/board-live-cards-server-runtime.js +1 -1
  18. package/lib/board-livegraph-runtime/index.cjs +1 -1
  19. package/lib/board-livegraph-runtime/index.d.cts +1 -0
  20. package/lib/board-livegraph-runtime/index.d.ts +1 -0
  21. package/lib/board-livegraph-runtime/index.js +1 -1
  22. package/lib/chunk-36QUKFL7.cjs +3 -0
  23. package/lib/chunk-3MMTBEAO.js +2 -0
  24. package/lib/{chunk-YGKDQLYP.js → chunk-4HIEOBJC.js} +2 -2
  25. package/lib/chunk-7QQFDYBM.js +3 -0
  26. package/lib/chunk-ABAVFLDP.js +7 -0
  27. package/lib/chunk-EPCJYP4N.js +3 -0
  28. package/lib/chunk-H22NK6KH.cjs +7 -0
  29. package/lib/chunk-OJ2CAQ4C.cjs +2 -0
  30. package/lib/chunk-QG6ERLZQ.cjs +3 -0
  31. package/lib/{chunk-S6DRP2HX.cjs → chunk-XQAHHUZO.cjs} +2 -2
  32. package/lib/chunk-ZENTBLLA.cjs +3 -0
  33. package/lib/chunk-ZWVT24YW.js +3 -0
  34. package/lib/cloud-storage.cjs +1 -1
  35. package/lib/cloud-storage.js +1 -1
  36. package/lib/compute-jsonata/browser.cjs +2 -0
  37. package/lib/compute-jsonata/browser.d.cts +20 -0
  38. package/lib/compute-jsonata/browser.d.ts +20 -0
  39. package/lib/compute-jsonata/browser.js +2 -0
  40. package/lib/compute-jsonata/index.cjs +2 -0
  41. package/lib/compute-jsonata/index.d.cts +20 -0
  42. package/lib/compute-jsonata/index.d.ts +20 -0
  43. package/lib/compute-jsonata/index.js +2 -0
  44. package/lib/compute-jsonata/jsonata-sync.cjs +7623 -0
  45. package/lib/firestore-storage/index.cjs +2 -2
  46. package/lib/firestore-storage/index.d.cts +2 -13
  47. package/lib/firestore-storage/index.d.ts +2 -13
  48. package/lib/firestore-storage/index.js +2 -2
  49. package/lib/index.cjs +2 -2
  50. package/lib/index.js +1 -1
  51. package/lib/localstorage-storage/index.cjs +1 -1
  52. package/lib/localstorage-storage/index.js +1 -1
  53. package/lib/server-jobs-queue-runner/index.d.cts +1 -1
  54. package/lib/server-jobs-queue-runner/index.d.ts +1 -1
  55. package/lib/server-runtime/index.cjs +1 -1
  56. package/lib/server-runtime/index.d.cts +2 -2
  57. package/lib/server-runtime/index.d.ts +2 -2
  58. package/lib/server-runtime/index.js +1 -1
  59. package/lib/server-runtime-agentface/index.d.cts +1 -1
  60. package/lib/server-runtime-agentface/index.d.ts +1 -1
  61. package/lib/server-runtime-controlface/index.cjs +1 -1
  62. package/lib/server-runtime-controlface/index.d.cts +1 -1
  63. package/lib/server-runtime-controlface/index.d.ts +1 -1
  64. package/lib/server-runtime-controlface/index.js +1 -1
  65. package/lib/server-runtime-core/index.cjs +1 -1
  66. package/lib/server-runtime-core/index.d.cts +3 -3
  67. package/lib/server-runtime-core/index.d.ts +3 -3
  68. package/lib/server-runtime-core/index.js +1 -1
  69. package/lib/server-runtime-watchers/index.cjs +1 -1
  70. package/lib/server-runtime-watchers/index.d.cts +3 -3
  71. package/lib/server-runtime-watchers/index.d.ts +3 -3
  72. package/lib/server-runtime-watchers/index.js +1 -1
  73. package/lib/server-runtime-webhooks/index.d.cts +1 -1
  74. package/lib/server-runtime-webhooks/index.d.ts +1 -1
  75. package/lib/{sse-hub-CYXisfXJ.d.cts → sse-hub-BDjWI7JR.d.cts} +1 -1
  76. package/lib/{sse-hub-Dodwtc3_.d.ts → sse-hub-DM8bw-dO.d.ts} +1 -1
  77. package/lib/{types-BtH3scgE.d.ts → types-BsfXZyI3.d.ts} +1 -1
  78. package/lib/{types-Ch0u3FKP.d.cts → types-CPnYv7RC.d.cts} +1 -1
  79. package/package.json +7 -1
  80. package/examples/board/test/sse-worker.js +0 -49
  81. package/lib/chunk-2GSI6C45.js +0 -7
  82. package/lib/chunk-CMFD27ZC.cjs +0 -3
  83. package/lib/chunk-DOFNXJ4C.js +0 -3
  84. package/lib/chunk-GU3T75C4.js +0 -3
  85. package/lib/chunk-H3EHFCDZ.js +0 -3
  86. package/lib/chunk-HEEDJEKM.js +0 -2
  87. package/lib/chunk-IQIZA7TN.cjs +0 -7
  88. package/lib/chunk-NDAKMJQK.cjs +0 -3
  89. package/lib/chunk-NU5NO5NM.js +0 -2
  90. package/lib/chunk-O4RKTQBP.cjs +0 -3
  91. package/lib/chunk-PBCDDO4V.cjs +0 -2
  92. package/lib/chunk-ZK3E7L4Y.cjs +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: /sse?one-shot bootstrapSSE 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:
@@ -44,6 +44,7 @@ function isCopilotAvailable() {
44
44
 
45
45
  const skipT3a = cliArgs.includes('--skip-t3a') || !isCopilotAvailable();
46
46
  const skipT3b = cliArgs.includes('--skip-t3b');
47
+ const skipT3e = cliArgs.includes('--skip-t3e');
47
48
  const skipT3d = cliArgs.includes('--skip-t3d');
48
49
  const RUN_ID = `run-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
49
50
 
@@ -104,6 +105,7 @@ const NS = {
104
105
  statusGeneration: 0,
105
106
  computedValues: {},
106
107
  chatEvents: [],
108
+ allChatNotifications: [],
107
109
  boardEvents: [],
108
110
  };
109
111
 
@@ -168,6 +170,39 @@ function parseSseBlocks(buffer) {
168
170
  return { payloads, remainder: buf };
169
171
  }
170
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
+
171
206
  function startSseClient(sseUrl, onPayload) {
172
207
  const req = http.get(sseUrl, (res) => {
173
208
  let buf = '';
@@ -187,9 +222,127 @@ function startSseClient(sseUrl, onPayload) {
187
222
  };
188
223
  }
189
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
+
190
332
  function captureChatEvents(payload, cardId) {
191
333
  if (!payload || payload.kind !== 'notification-batch' || !Array.isArray(payload.notifications)) return;
192
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
+ }
193
346
  if (n && n.kind === 'card_chats' && n.cardId === cardId) {
194
347
  const messages = Array.isArray(n.messages) ? n.messages : [];
195
348
  NS.chatEvents.push({
@@ -234,10 +387,159 @@ function waitUntil(predicate, timeoutMs, label) {
234
387
  });
235
388
  }
236
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
+
237
409
  function wait(ms) {
238
410
  return new Promise((resolve) => setTimeout(resolve, ms));
239
411
  }
240
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
+
241
543
  async function stopChildProcess(proc, label) {
242
544
  if (!proc) return;
243
545
  if (proc.exitCode !== null) return;
@@ -316,13 +618,21 @@ function deriveProbeLifecycleMilestones(events, opts) {
316
618
 
317
619
  function matchOrderedProbeLifecycle(events, opts) {
318
620
  const milestones = deriveProbeLifecycleMilestones(events, opts);
319
- if (milestones.length !== 5) return false;
320
- const firstPair = milestones.slice(0, 2);
321
- const lastPair = milestones.slice(3, 5);
322
- const firstOk = firstPair.includes('user') && firstPair.includes('processing-true');
323
- const middleOk = milestones[2] === 'in-progress';
324
- const lastOk = lastPair.includes('assistant') && lastPair.includes('processing-false');
325
- 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 };
326
636
  }
327
637
 
328
638
  function httpGet(url) {
@@ -508,7 +818,7 @@ let chatSseClient = null;
508
818
  let chatSseClientId = '';
509
819
 
510
820
  try {
511
- // ── T0: one-shot bootstrap, SSE connect, wait for initial completion ──
821
+ // ── T0: streaming SSE connect, upsert fixtures, wait for initial completion ──
512
822
 
513
823
  // Register the 'live' board via POST (v8 runtime requires explicit registration)
514
824
  const regRes = await httpJson('POST', `http://127.0.0.1:${PORT}/api/boards`, { id: BOARD_ID, label: 'Live' });
@@ -784,12 +1094,12 @@ try {
784
1094
  const t2AfterMessages = Array.isArray(t2After.data?.data?.messages) ? t2After.data.data.messages : [];
785
1095
  const t2NewMessages = t2AfterMessages.slice(t2BeforeCount);
786
1096
  t3Dbg(`step 6: validating ${t2NewMessages.length} new messages`);
787
- 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}`);
788
1098
  const t3McpAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3TurnId });
789
1099
  const t3McpAfterData = expectMcpSuccess(t3McpAfter, 'T3 MCP post chats');
790
1100
  const t3TurnMessages = Array.isArray(t3McpAfterData?.messages) ? t3McpAfterData.messages : [];
791
1101
  t3Dbg(`step 6: MCP turn messages count=${t3TurnMessages.length}`);
792
- 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}`);
793
1103
  for (const msg of t3TurnMessages) {
794
1104
  assert(String(msg?.turn || '') === t3TurnId, 'T3 MCP turn id mismatch');
795
1105
  }
@@ -806,8 +1116,8 @@ try {
806
1116
  const t2InProgress = t2NewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
807
1117
  const t2AssistantMsg = t2NewMessages.find((m) => m?.role === 'assistant');
808
1118
  assert(!!t2User && typeof t2User.id === 'string', 'T3 user chat message missing id');
809
- assert(String(t2User?.text || '').includes(t2ProbePrompt), 'T3 user file text mismatch');
810
- assert(!!t2InProgress && typeof t2InProgress.id === 'string', 'T3 in-progress system message missing id');
1119
+ assert(String(t2User?.text || '') === t2ProbePrompt, `T3 expected stored user text to equal prompt without probe envelope, got ${JSON.stringify(String(t2User?.text || ''))}`);
1120
+ assert(!String(t2User?.text || '').includes(ECHO_PROBE_MARKER), 'T3 stored user text should not include probe envelope markers');
811
1121
  assert(!!t2AssistantMsg && typeof t2AssistantMsg.id === 'string', 'T3 assistant chat message missing id');
812
1122
  assert(String(t2AssistantMsg?.text || '').includes(`Echo: ${t2ProbePrompt}`), 'T3 assistant echo file content mismatch');
813
1123
  t3Dbg('step 6: all assertions passed');
@@ -967,18 +1277,314 @@ try {
967
1277
  assert(t2bAfter.status === 200, `T3b post chats returned ${t2bAfter.status}`);
968
1278
  const t2bAfterMessages = Array.isArray(t2bAfter.data?.data?.messages) ? t2bAfter.data.data.messages : [];
969
1279
  const t2bNewMessages = t2bAfterMessages.slice(t2bSendBaseline);
970
- assert(t2bNewMessages.length >= 3, `T3b expected at least 3 chat messages after send, got ${t2bNewMessages.length}`);
1280
+ assert(t2bNewMessages.length >= 2, `T3b expected at least 2 chat messages after send, got ${t2bNewMessages.length}`);
971
1281
 
972
1282
  const t2bUser = t2bNewMessages.find((m) => m?.role === 'user');
973
1283
  const t2bInProgress = t2bNewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
974
1284
  const t2bAssistantMsg = t2bNewMessages.find((m) => m?.role === 'assistant');
975
1285
 
976
1286
  assert(!!t2bUser && typeof t2bUser.id === 'string', 'T3b missing user chat message notification');
977
- assert(!!t2bInProgress && typeof t2bInProgress.id === 'string', 'T3b missing in-progress system chat message');
978
1287
  assert(!!t2bAssistantMsg && typeof t2bAssistantMsg.id === 'string', 'T3b missing assistant chat message notification');
979
1288
  assert(!Array.isArray(t2bUser?.files) || t2bUser.files.length === 0, 'T3b user chat message should remain text-only after add-chat-attachment upload');
980
1289
  assert(String(t2bAssistantMsg?.text || '').includes(`Echo: ${t2bPrompt}`), 'T3b assistant probe echo mismatch');
981
1290
  console.log('[T3b] ok: add-chat-attachment upload plus text-only chat-send preserved the normal probe lifecycle');
1291
+
1292
+ if (skipT3e) {
1293
+ console.log('\n=== T3e: skipped (--skip-t3e) ===');
1294
+ } else {
1295
+ console.log('\n=== T3e: subscribed chat turn with attachment plus unsubscribed negative case ===');
1296
+ const t3eOtherCardId = `card-t3e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1297
+ const t3eOtherCard = buildChatProbeCard(t3eOtherCardId, 'T3e Chat Probe');
1298
+
1299
+ const t3eUpsertOtherRes = await httpMcp('manage.upsert-card', {
1300
+ card_id: t3eOtherCardId,
1301
+ candidate_card_content: t3eOtherCard,
1302
+ });
1303
+ assert(t3eUpsertOtherRes.status === 200, `T3e manage.upsert-card(${t3eOtherCardId}) returned ${t3eUpsertOtherRes.status}`);
1304
+ assert(t3eUpsertOtherRes.data?.status === 'success', `T3e manage.upsert-card(${t3eOtherCardId}) failed: ${JSON.stringify(t3eUpsertOtherRes.data)}`);
1305
+ await waitUntil(() => {
1306
+ const s = NS.statusSummary;
1307
+ if (s && s.card_count === T0_EXPECTED_CARD_IDS.length + 1) return s;
1308
+ return false;
1309
+ }, 30_000, 'T3e extra chat card visible in board summary');
1310
+
1311
+ try {
1312
+ const t3eBefore = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1313
+ assert(t3eBefore.status === 200, `T3e pre chats returned ${t3eBefore.status}`);
1314
+ const t3eBeforeMessages = Array.isArray(t3eBefore.data?.data?.messages) ? t3eBefore.data.data.messages : [];
1315
+ const t3eBeforeCount = t3eBeforeMessages.length;
1316
+
1317
+ const t3eTurnId = randomTurnId();
1318
+ const t3eUploadRes = await httpMcpControlplane('manage.add-chat-attachment', {
1319
+ board_id: BOARD_ID,
1320
+ card_id: CHAT_CARD_ID,
1321
+ turn_id: t3eTurnId,
1322
+ file_name: 't3e-probe.txt',
1323
+ content_type: 'text/plain; charset=utf-8',
1324
+ text: 'what is the capital of japan',
1325
+ });
1326
+ assert(t3eUploadRes.status === 200, `T3e file upload returned ${t3eUploadRes.status}`);
1327
+ assert(t3eUploadRes.data?.status === 'success', `T3e file upload failed: ${JSON.stringify(t3eUploadRes.data)}`);
1328
+ const t3eUploadedFile = t3eUploadRes.data?.data?.files?.[0];
1329
+ assert(t3eUploadedFile && typeof t3eUploadedFile === 'object', 'T3e upload response missing file metadata');
1330
+ assert(!Object.prototype.hasOwnProperty.call(t3eUploadedFile, 'path'), 'T3e uploaded file metadata should not expose path');
1331
+
1332
+ const t3eCardAfterUpload = await httpMcp('manage.read-card', { card_id: CHAT_CARD_ID });
1333
+ assert(t3eCardAfterUpload.status === 200, `T3e card read after upload returned ${t3eCardAfterUpload.status}`);
1334
+ const t3eStoredFiles = Array.isArray(t3eCardAfterUpload.data?.data?.[0]?.card_data?.files)
1335
+ ? t3eCardAfterUpload.data.data[0].card_data.files
1336
+ : [];
1337
+ const t3eStoredFile = t3eStoredFiles.find((file) => String(file?.stored_name || '') === String(t3eUploadedFile?.stored_name || ''));
1338
+ assert(!!t3eStoredFile, 'T3e stored file metadata missing after upload');
1339
+ assert(t3eStoredFile?.chat === true, 'T3e stored file should be marked as chat-origin');
1340
+ assert(!Object.prototype.hasOwnProperty.call(t3eStoredFile || {}, 'path'), 'T3e stored file metadata should not expose path');
1341
+
1342
+ const t3eUploadMessages = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3eTurnId });
1343
+ assert(t3eUploadMessages.status === 200, `T3e chats after upload returned ${t3eUploadMessages.status}`);
1344
+ const t3eUploadTurnMessages = Array.isArray(t3eUploadMessages.data?.data?.messages) ? t3eUploadMessages.data.data.messages : [];
1345
+ const t3eUploadSystem = t3eUploadTurnMessages.find((message) => message?.role === 'system');
1346
+ assert(!!t3eUploadSystem, 'T3e upload protocol missing system chat message');
1347
+ assert(String(t3eUploadSystem?.text || '').toLowerCase().includes('file uploaded:'), 'T3e upload system message does not describe uploaded file');
1348
+
1349
+ const t3eEventStart = NS.chatEvents.length;
1350
+ const t3eAllNotificationsStart = NS.allChatNotifications.length;
1351
+ const t3ePrompt = `attachment probe ${Date.now()}`;
1352
+ const t3eProbeText = `${ECHO_PROBE_MARKER}${t3ePrompt}${ECHO_PROBE_MARKER}`;
1353
+ const t3eSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1354
+ tool: 'chat-send',
1355
+ args: {
1356
+ card_id: CHAT_CARD_ID,
1357
+ payload: {
1358
+ text: t3eProbeText,
1359
+ 'turn-id': t3eTurnId,
1360
+ },
1361
+ },
1362
+ });
1363
+ assert(t3eSendRes.status === 200, `T3e chat-send returned ${t3eSendRes.status}`);
1364
+
1365
+ const t3eLifecycle = await waitForChatPredicate((events) => {
1366
+ return matchOrderedProbeLifecycle(events.slice(t3eEventStart), {
1367
+ beforeCount: t3eBeforeCount + t3eUploadTurnMessages.length,
1368
+ beforeProcessing: false,
1369
+ prompt: t3ePrompt,
1370
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1371
+ });
1372
+ }, 60_000, 'T3e ordered lifecycle');
1373
+ assert(!!t3eLifecycle, 'T3e ordered lifecycle not observed');
1374
+
1375
+ const t3eAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3eTurnId });
1376
+ assert(t3eAfter.status === 200, `T3e post chats returned ${t3eAfter.status}`);
1377
+ const t3eFinalMessages = Array.isArray(t3eAfter.data?.data?.messages) ? t3eAfter.data.data.messages : [];
1378
+ const t3eFinalUser = t3eFinalMessages.find((message) => message?.role === 'user');
1379
+ const t3eFinalAssistant = t3eFinalMessages.find((message) => message?.role === 'assistant');
1380
+ assert(!!t3eFinalUser, `T3e final user message missing: ${JSON.stringify(t3eFinalMessages)}`);
1381
+ assert(!!t3eFinalAssistant, `T3e final assistant message missing: ${JSON.stringify(t3eFinalMessages)}`);
1382
+ assert(String(t3eFinalUser?.text || '') === t3ePrompt, `T3e final user text mismatch: ${JSON.stringify(t3eFinalUser)}`);
1383
+ assert(!Array.isArray(t3eFinalUser?.files) || t3eFinalUser.files.length === 0,
1384
+ `T3e final user message should remain text-only after controlplane attachment upload: ${JSON.stringify(t3eFinalUser)}`);
1385
+ assert(String(t3eFinalAssistant?.text || '').includes(`Echo: ${t3ePrompt}`), `T3e final probe reply mismatch: ${JSON.stringify(t3eFinalAssistant)}`);
1386
+
1387
+ const t3eNegativeTurnId = randomTurnId();
1388
+ const t3eNegativeSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1389
+ tool: 'chat-send',
1390
+ args: {
1391
+ card_id: t3eOtherCardId,
1392
+ payload: {
1393
+ text: `${ECHO_PROBE_MARKER}negative unsubscribed ${Date.now()}${ECHO_PROBE_MARKER}`,
1394
+ 'turn-id': t3eNegativeTurnId,
1395
+ },
1396
+ },
1397
+ });
1398
+ assert(t3eNegativeSendRes.status === 200, `T3e negative chat-send returned ${t3eNegativeSendRes.status}`);
1399
+
1400
+ const t3eNegativePersisted = await waitUntilAsync(async () => {
1401
+ const result = await httpMcp('inspect.chat-messages-on-cards', { card_id: t3eOtherCardId, turn_id: t3eNegativeTurnId });
1402
+ if (result.status !== 200) return false;
1403
+ const messages = Array.isArray(result.data?.data?.messages) ? result.data.data.messages : [];
1404
+ return messages.find((message) => message?.role === 'assistant') ? messages : false;
1405
+ }, 60_000, 'T3e negative turn persisted on unsubscribed card');
1406
+ assert(Array.isArray(t3eNegativePersisted), 'T3e negative turn did not persist as expected');
1407
+
1408
+ await wait(1_500);
1409
+ const t3eUnexpectedNotification = NS.allChatNotifications.slice(t3eAllNotificationsStart)
1410
+ .find((event) => event?.cardId === t3eOtherCardId);
1411
+ assert(!t3eUnexpectedNotification,
1412
+ `T3e unsubscribed client unexpectedly received chat notification for ${t3eOtherCardId}: ${JSON.stringify(t3eUnexpectedNotification)}`);
1413
+
1414
+ console.log('[T3e] ok: subscribed client received attachment-bearing turn and unsubscribed card produced no chat SSE notification');
1415
+ } finally {
1416
+ const t3eRemoveOtherRes = await httpMcp('manage.remove-card', { card_id: t3eOtherCardId });
1417
+ assert(t3eRemoveOtherRes.status === 200, `T3e manage.remove-card(${t3eOtherCardId}) returned ${t3eRemoveOtherRes.status}`);
1418
+ assert(t3eRemoveOtherRes.data?.status === 'success', `T3e manage.remove-card(${t3eOtherCardId}) failed: ${JSON.stringify(t3eRemoveOtherRes.data)}`);
1419
+ await waitUntil(() => {
1420
+ const s = NS.statusSummary;
1421
+ if (s && s.card_count === T0_EXPECTED_CARD_IDS.length) return s;
1422
+ return false;
1423
+ }, 30_000, 'T3e cleanup card_count back to 3');
1424
+ }
1425
+ }
1426
+
1427
+ console.log('\n=== T3c: fresh /sse connect hydrates current board state ===');
1428
+ const t3cInspectStatusRes = await httpMcp('inspect.board-runtime-status', {});
1429
+ assert(t3cInspectStatusRes.status === 200, `T3c inspect.board-runtime-status returned ${t3cInspectStatusRes.status}`);
1430
+ assert(t3cInspectStatusRes.data?.status === 'success', `T3c inspect.board-runtime-status failed: ${JSON.stringify(t3cInspectStatusRes.data)}`);
1431
+ const t3cExpectedSummary = t3cInspectStatusRes.data?.data?.summary;
1432
+ assert(t3cExpectedSummary, 'T3c summary missing from inspect.board-runtime-status');
1433
+
1434
+ const t3cExpectedCards = {};
1435
+ for (const cardId of T0_EXPECTED_CARD_IDS) {
1436
+ const t3cInspectCardRes = await httpMcp('inspect.card-definition-and-runtime', { card_id: cardId });
1437
+ assert(t3cInspectCardRes.status === 200, `T3c inspect.card-definition-and-runtime(${cardId}) returned ${t3cInspectCardRes.status}`);
1438
+ assert(t3cInspectCardRes.data?.status === 'success', `T3c inspect.card-definition-and-runtime(${cardId}) failed: ${JSON.stringify(t3cInspectCardRes.data)}`);
1439
+ const t3cInspectCardData = t3cInspectCardRes.data?.data;
1440
+ assert(t3cInspectCardData && typeof t3cInspectCardData === 'object', `T3c inspect.card-definition-and-runtime(${cardId}) missing data`);
1441
+ t3cExpectedCards[cardId] = t3cInspectCardData;
1442
+ }
1443
+
1444
+ const t3cRefreshClientId = `server-http-refresh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1445
+ const t3cRefreshPayload = await waitForFirstSsePayload(`${BASE}/sse?clientId=${encodeURIComponent(t3cRefreshClientId)}`);
1446
+ assert(t3cRefreshPayload && typeof t3cRefreshPayload === 'object', 'T3c missing refresh SSE payload');
1447
+
1448
+ const t3cCardDefinitions = Array.isArray(t3cRefreshPayload.cardDefinitions) ? t3cRefreshPayload.cardDefinitions : [];
1449
+ const t3cCardIds = t3cCardDefinitions.map((card) => card?.id).filter((id) => typeof id === 'string').sort();
1450
+ assert(JSON.stringify(t3cCardIds) === JSON.stringify(T0_EXPECTED_CARD_IDS),
1451
+ `T3c refreshed SSE cardDefinitions mismatch: ${JSON.stringify(t3cCardIds)}`);
1452
+
1453
+ const t3cStatusSummary = t3cRefreshPayload.statusSnapshot?.summary;
1454
+ assert(t3cStatusSummary, 'T3c refresh SSE payload missing statusSnapshot.summary');
1455
+ assertObjectContains(t3cStatusSummary, t3cExpectedSummary, 'T3c refresh SSE summary');
1456
+
1457
+ const t3cCardRuntimeById = t3cRefreshPayload.cardRuntimeById && typeof t3cRefreshPayload.cardRuntimeById === 'object'
1458
+ ? t3cRefreshPayload.cardRuntimeById
1459
+ : {};
1460
+ for (const card of t3cCardDefinitions) {
1461
+ const cardId = card?.id;
1462
+ if (typeof cardId !== 'string' || !t3cExpectedCards[cardId]) continue;
1463
+ const t3cExpectedCard = t3cExpectedCards[cardId];
1464
+ assert(JSON.stringify(card) === JSON.stringify(t3cExpectedCard.card_definition_and_static_data),
1465
+ `T3c refresh SSE cardDefinitions[${cardId}] mismatch`);
1466
+
1467
+ const t3cHydratedCardRuntime = t3cCardRuntimeById[cardId];
1468
+ assert(t3cHydratedCardRuntime && typeof t3cHydratedCardRuntime === 'object', `T3c refresh SSE payload missing cardRuntimeById.${cardId}`);
1469
+ assert(JSON.stringify(t3cHydratedCardRuntime.card_data || {}) === JSON.stringify(t3cExpectedCard.card_definition_and_static_data?.card_data || {}),
1470
+ `T3c refresh SSE cardRuntimeById.${cardId}.card_data mismatch`);
1471
+ assert(JSON.stringify(t3cHydratedCardRuntime.computed_values || {}) === JSON.stringify(t3cExpectedCard.runtime_data?.computed_values || {}),
1472
+ `T3c refresh SSE cardRuntimeById.${cardId}.computed_values mismatch`);
1473
+ }
1474
+ console.log('[T3c] ok: fresh /sse first payload hydrated the current board state');
1475
+
1476
+ console.log('\n=== TS: one-shot, raw framing, replay, delta ordering, and chat hydration ===');
1477
+ const tsExpectedChatRes = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1478
+ assert(tsExpectedChatRes.status === 200, `TS inspect.chat-messages-on-cards returned ${tsExpectedChatRes.status}`);
1479
+ const tsExpectedChatMessages = normalizeHydratedChatMessages(tsExpectedChatRes.data?.data?.messages || []);
1480
+
1481
+ const tsOneShot = await waitForRawSseFrames({
1482
+ sseUrl: `${BASE}/sse?one-shot`,
1483
+ timeoutMs: 15_000,
1484
+ until: (state) => state.frames.length >= 1,
1485
+ waitForClose: true,
1486
+ });
1487
+ assert(tsOneShot.statusCode === 200, `TS one-shot returned ${tsOneShot.statusCode}`);
1488
+ assert(/text\/event-stream/i.test(readHeaderValue(tsOneShot.headers, 'content-type')),
1489
+ `TS one-shot content-type mismatch: ${readHeaderValue(tsOneShot.headers, 'content-type')}`);
1490
+ assert(tsOneShot.closed === true, 'TS one-shot connection should close after first frame');
1491
+ assert(tsOneShot.frames.length === 1, `TS one-shot expected exactly 1 frame, got ${tsOneShot.frames.length}`);
1492
+ const tsOneShotFrame = tsOneShot.frames[0];
1493
+ assert(/^\d+$/.test(String(tsOneShotFrame.id || '')), `TS one-shot frame missing numeric id: ${JSON.stringify(tsOneShotFrame)}`);
1494
+ assert(tsOneShotFrame.payload && typeof tsOneShotFrame.payload === 'object', 'TS one-shot frame missing JSON payload');
1495
+ const tsOneShotChatState = tsOneShotFrame.payload.cardChatsByCardId?.[CHAT_CARD_ID];
1496
+ assert(tsOneShotChatState && typeof tsOneShotChatState === 'object', `TS one-shot payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1497
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsOneShotChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1498
+ 'TS one-shot cardChatsByCardId hydration mismatch');
1499
+
1500
+ const tsDeltaClientId = `ts-delta-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1501
+ const tsRawFrames = [];
1502
+ const tsDeltaClient = startRawSseClient({
1503
+ sseUrl: `${BASE}/sse?clientId=${encodeURIComponent(tsDeltaClientId)}`,
1504
+ onFrame(frame) {
1505
+ tsRawFrames.push(frame);
1506
+ },
1507
+ });
1508
+
1509
+ try {
1510
+ const tsInitialFrame = await waitUntil(() => tsRawFrames[0] || false, 15_000, 'TS initial raw SSE frame');
1511
+ assert(/^\d+$/.test(String(tsInitialFrame.id || '')), `TS initial streaming frame missing numeric id: ${JSON.stringify(tsInitialFrame)}`);
1512
+ const tsInitialChatState = tsInitialFrame.payload?.cardChatsByCardId?.[CHAT_CARD_ID];
1513
+ assert(tsInitialChatState && typeof tsInitialChatState === 'object', `TS initial streaming payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1514
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsInitialChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1515
+ 'TS initial streaming cardChatsByCardId hydration mismatch');
1516
+
1517
+ const tsTempCards = [
1518
+ buildTsStaticCard(`card-ts-${Date.now()}-a`, 'TS Delta Card A'),
1519
+ buildTsStaticCard(`card-ts-${Date.now()}-b`, 'TS Delta Card B'),
1520
+ ];
1521
+ let tsLastEventId = Number(tsInitialFrame.id);
1522
+
1523
+ for (let idx = 0; idx < tsTempCards.length; idx += 1) {
1524
+ const tsCard = tsTempCards[idx];
1525
+ const tsExpectedCardCount = T0_EXPECTED_CARD_IDS.length + idx + 1;
1526
+ const tsFrameStart = tsRawFrames.length;
1527
+ const tsUpsertRes = await httpMcp('manage.upsert-card', {
1528
+ card_id: tsCard.id,
1529
+ candidate_card_content: tsCard,
1530
+ });
1531
+ assert(tsUpsertRes.status === 200, `TS manage.upsert-card(${tsCard.id}) returned ${tsUpsertRes.status}`);
1532
+ assert(tsUpsertRes.data?.status === 'success', `TS manage.upsert-card(${tsCard.id}) failed: ${JSON.stringify(tsUpsertRes.data)}`);
1533
+ const tsDeltaFrame = await waitUntil(() => {
1534
+ for (const frame of tsRawFrames.slice(tsFrameStart)) {
1535
+ const summary = extractStatusSummaryFromPayload(frame.payload);
1536
+ if (summary?.card_count === tsExpectedCardCount) return frame;
1537
+ }
1538
+ return false;
1539
+ }, 30_000, `TS board delta card_count=${tsExpectedCardCount}`);
1540
+ assert(Number(tsDeltaFrame.id) > tsLastEventId,
1541
+ `TS delta frame id did not increase: prev=${tsLastEventId}, next=${JSON.stringify(tsDeltaFrame.id)}`);
1542
+ tsLastEventId = Number(tsDeltaFrame.id);
1543
+ }
1544
+
1545
+ tsDeltaClient.close();
1546
+ await wait(250);
1547
+
1548
+ const tsReconnect = await waitForRawSseFrames({
1549
+ sseUrl: `${BASE}/sse?clientId=${encodeURIComponent(tsDeltaClientId)}`,
1550
+ headers: { 'Last-Event-ID': String(tsLastEventId) },
1551
+ timeoutMs: 15_000,
1552
+ until: (state) => state.frames.length >= 1,
1553
+ });
1554
+ assert(tsReconnect.statusCode === 200, `TS reconnect returned ${tsReconnect.statusCode}`);
1555
+ assert(/text\/event-stream/i.test(readHeaderValue(tsReconnect.headers, 'content-type')),
1556
+ `TS reconnect content-type mismatch: ${readHeaderValue(tsReconnect.headers, 'content-type')}`);
1557
+ const tsReconnectFrame = tsReconnect.frames[0];
1558
+ assert(Number(tsReconnectFrame.id) > tsLastEventId,
1559
+ `TS reconnect frame id did not advance beyond Last-Event-ID: prev=${tsLastEventId}, next=${JSON.stringify(tsReconnectFrame.id)}`);
1560
+ const tsReconnectPayload = tsReconnectFrame.payload;
1561
+ assert(tsReconnectPayload && typeof tsReconnectPayload === 'object', 'TS reconnect first frame missing JSON payload');
1562
+ const tsReconnectIds = (Array.isArray(tsReconnectPayload.cardDefinitions) ? tsReconnectPayload.cardDefinitions : [])
1563
+ .map((card) => card?.id)
1564
+ .filter((cardId) => typeof cardId === 'string')
1565
+ .sort();
1566
+ const tsExpectedReconnectIds = [...T0_EXPECTED_CARD_IDS, ...tsTempCards.map((card) => card.id)].sort();
1567
+ assert(JSON.stringify(tsReconnectIds) === JSON.stringify(tsExpectedReconnectIds),
1568
+ `TS reconnect snapshot mismatch: expected ${JSON.stringify(tsExpectedReconnectIds)}, got ${JSON.stringify(tsReconnectIds)}`);
1569
+ const tsReconnectChatState = tsReconnectPayload.cardChatsByCardId?.[CHAT_CARD_ID];
1570
+ assert(tsReconnectChatState && typeof tsReconnectChatState === 'object', `TS reconnect payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1571
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsReconnectChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1572
+ 'TS reconnect cardChatsByCardId hydration mismatch');
1573
+
1574
+ for (const tsCard of tsTempCards) {
1575
+ const tsRemoveRes = await httpMcp('manage.remove-card', { card_id: tsCard.id });
1576
+ assert(tsRemoveRes.status === 200, `TS manage.remove-card(${tsCard.id}) returned ${tsRemoveRes.status}`);
1577
+ assert(tsRemoveRes.data?.status === 'success', `TS manage.remove-card(${tsCard.id}) failed: ${JSON.stringify(tsRemoveRes.data)}`);
1578
+ }
1579
+ await waitUntil(() => {
1580
+ const summary = NS.statusSummary;
1581
+ if (summary && summary.card_count === T0_EXPECTED_CARD_IDS.length) return summary;
1582
+ return false;
1583
+ }, 30_000, 'TS cleanup card_count back to 3');
1584
+ } finally {
1585
+ tsDeltaClient.close();
1586
+ }
1587
+ console.log('[TS] ok: one-shot framing, event ids, Last-Event-ID reconnect, ordered board deltas, and initial chat hydration verified');
982
1588
  }
983
1589
 
984
1590
  // ── T3d: probe-echo chat with one AI-generated attachment ──
@@ -1019,7 +1625,7 @@ try {
1019
1625
  args: {
1020
1626
  card_id: CHAT_CARD_ID,
1021
1627
  payload: {
1022
- text: `${ECHO_PROBE_MARKER}[attach] ${t2dPrompt}${ECHO_PROBE_MARKER}`,
1628
+ text: `${ECHO_PROBE_MARKER}echoattach__ ${t2dPrompt}${ECHO_PROBE_MARKER}`,
1023
1629
  'turn-id': t3dTurnId,
1024
1630
  },
1025
1631
  },
@@ -1041,7 +1647,7 @@ try {
1041
1647
  assert(t2dAfter.status === 200, `T3d post chats returned ${t2dAfter.status}`);
1042
1648
  const t2dAfterMessages = Array.isArray(t2dAfter.data?.data?.messages) ? t2dAfter.data.data.messages : [];
1043
1649
  const t2dNewMessages = t2dAfterMessages.slice(t2dBeforeCount);
1044
- assert(t2dNewMessages.length >= 4, `T3d expected at least 4 chat messages after send, got ${t2dNewMessages.length}`);
1650
+ assert(t2dNewMessages.length >= 3, `T3d expected at least 3 chat messages after send, got ${t2dNewMessages.length}`);
1045
1651
 
1046
1652
  const t2dUser = t2dNewMessages.find((m) => m?.role === 'user');
1047
1653
  const t2dInProgress = t2dNewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
@@ -1049,7 +1655,6 @@ try {
1049
1655
  const t2dAssistantMsg = t2dNewMessages.find((m) => m?.role === 'assistant');
1050
1656
 
1051
1657
  assert(!!t2dUser && typeof t2dUser.id === 'string', 'T3d missing user chat message');
1052
- assert(!!t2dInProgress && typeof t2dInProgress.id === 'string', 'T3d missing in-progress system chat message');
1053
1658
  assert(!!t2dAiGenerated && typeof t2dAiGenerated.id === 'string', 'T3d missing AI-generated attachment system chat message');
1054
1659
  assert(/#\d+\s*$/.test(String(t2dAiGenerated?.text || '')), 'T3d AI-generated system message should include merged file index');
1055
1660
  assert(String(t2dAiGenerated?.turn || '') === t3dTurnId, 'T3d AI-generated system turn id mismatch');