yaml-flow 8.8.7 → 8.9.0

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 (155) hide show
  1. package/browser/adapters/firestore-storage.js +2 -2
  2. package/browser/adapters/localstorage-storage.js +3 -3
  3. package/browser/asset-integrity.json +7 -7
  4. package/browser/server-runtime-controlface.js +5 -5
  5. package/examples/board/test/server-http-test-browser.ts +2394 -0
  6. package/examples/board/test/server-http-test.js +44 -35
  7. package/lib/{artifacts-store-lib-C6qBpMfU.d.cts → artifacts-store-lib-CqkJEgbx.d.cts} +1 -1
  8. package/lib/{artifacts-store-lib-D4qf7Q-7.d.ts → artifacts-store-lib-D6Bo4yFN.d.ts} +1 -1
  9. package/lib/artifacts-store-public.d.cts +5 -3
  10. package/lib/artifacts-store-public.d.ts +5 -3
  11. package/lib/board-callback-transport-Bekq8Gp-.d.ts +71 -0
  12. package/lib/board-callback-transport-kP0K6UMq.d.cts +71 -0
  13. package/lib/board-live-cards-lib-DVh3t9NT.d.cts +222 -0
  14. package/lib/board-live-cards-lib-iU8xOwNp.d.ts +222 -0
  15. package/lib/board-live-cards-mcp.cjs +1 -1
  16. package/lib/board-live-cards-mcp.d.cts +4 -8
  17. package/lib/board-live-cards-mcp.d.ts +4 -8
  18. package/lib/board-live-cards-mcp.js +1 -1
  19. package/lib/board-live-cards-node.cjs +7 -7
  20. package/lib/board-live-cards-node.d.cts +15 -11
  21. package/lib/board-live-cards-node.d.ts +15 -11
  22. package/lib/board-live-cards-node.js +7 -7
  23. package/lib/{board-live-cards-public-async-CYjr4mgX.d.cts → board-live-cards-public-async-Cl0M3j5P.d.cts} +6 -4
  24. package/lib/{board-live-cards-public-async-DlyC3PgC.d.ts → board-live-cards-public-async-U98IXUBx.d.ts} +6 -4
  25. package/lib/board-live-cards-public.cjs +1 -1
  26. package/lib/board-live-cards-public.d.cts +325 -3
  27. package/lib/board-live-cards-public.d.ts +325 -3
  28. package/lib/board-live-cards-public.js +1 -1
  29. package/lib/board-live-cards-server-runtime.cjs +1 -1
  30. package/lib/board-live-cards-server-runtime.d.cts +8 -6
  31. package/lib/board-live-cards-server-runtime.d.ts +8 -6
  32. package/lib/board-live-cards-server-runtime.js +1 -1
  33. package/lib/{board-platform-adapter-async-JP9V-U5E.d.ts → board-platform-adapter-async-ByBRFn1G.d.ts} +4 -3
  34. package/lib/{board-platform-adapter-async-BZIftm36.d.cts → board-platform-adapter-async-DjBwE0m9.d.cts} +4 -3
  35. package/lib/board-state-reducer.cjs +2 -0
  36. package/lib/board-state-reducer.d.cts +53 -0
  37. package/lib/board-state-reducer.d.ts +53 -0
  38. package/lib/board-state-reducer.js +2 -0
  39. package/lib/card-store-public.cjs +1 -1
  40. package/lib/card-store-public.d.cts +11 -4
  41. package/lib/card-store-public.d.ts +11 -4
  42. package/lib/card-store-public.js +1 -1
  43. package/lib/chat-store-public.cjs +1 -1
  44. package/lib/chat-store-public.d.cts +15 -4
  45. package/lib/chat-store-public.d.ts +15 -4
  46. package/lib/chat-store-public.js +1 -1
  47. package/lib/chunk-2RIHC5TZ.cjs +2 -0
  48. package/lib/chunk-37FDOKDK.js +3 -0
  49. package/lib/chunk-3S7LV2HZ.cjs +2 -0
  50. package/lib/chunk-3T5TMAWG.cjs +3 -0
  51. package/lib/chunk-5XLFPPTY.cjs +2 -0
  52. package/lib/{chunk-R44X3RQB.cjs → chunk-6EJRZHFP.cjs} +2 -2
  53. package/lib/{chunk-NNSBBO5R.js → chunk-6MD6FVE3.js} +2 -2
  54. package/lib/chunk-7QNEV5S3.js +2 -0
  55. package/lib/{chunk-6OPXQPSC.js → chunk-7UJQMFVS.js} +2 -2
  56. package/lib/chunk-BJGK5FNL.js +2 -0
  57. package/lib/{chunk-ETW3BXHD.cjs → chunk-BQJVCCFU.cjs} +2 -2
  58. package/lib/chunk-DQPBAB5Z.cjs +2 -0
  59. package/lib/chunk-JB4Q2W7I.js +7 -0
  60. package/lib/chunk-JYVYAAXT.js +3 -0
  61. package/lib/chunk-LFVMX23W.cjs +7 -0
  62. package/lib/chunk-NIBLKYXN.js +2 -0
  63. package/lib/chunk-OF4FPYNI.cjs +2 -0
  64. package/lib/chunk-P3R6TXII.js +3 -0
  65. package/lib/{chunk-7BTZCOT5.js → chunk-QLB6PKOT.js} +2 -2
  66. package/lib/chunk-R4YASXSJ.js +2 -0
  67. package/lib/chunk-RZRIXQ6A.cjs +3 -0
  68. package/lib/chunk-SDJ3747Q.cjs +2 -0
  69. package/lib/chunk-SJANU3PL.cjs +3 -0
  70. package/lib/chunk-UGB7PC4P.js +2 -0
  71. package/lib/chunk-VBYNMTWC.js +2 -0
  72. package/lib/{chunk-37HDEW26.cjs → chunk-WRMOGPRG.cjs} +2 -2
  73. package/lib/cloud-storage.cjs +1 -1
  74. package/lib/cloud-storage.d.cts +44 -8
  75. package/lib/cloud-storage.d.ts +44 -8
  76. package/lib/cloud-storage.js +1 -1
  77. package/lib/firebase-storage/index.d.cts +2 -2
  78. package/lib/firebase-storage/index.d.ts +2 -2
  79. package/lib/firestore-storage/index.cjs +2 -2
  80. package/lib/firestore-storage/index.d.cts +6 -5
  81. package/lib/firestore-storage/index.d.ts +6 -5
  82. package/lib/firestore-storage/index.js +2 -2
  83. package/lib/index.cjs +2 -2
  84. package/lib/index.d.cts +1 -1
  85. package/lib/index.d.ts +1 -1
  86. package/lib/index.js +1 -1
  87. package/lib/localstorage-storage/index.cjs +1 -1
  88. package/lib/localstorage-storage/index.d.cts +6 -5
  89. package/lib/localstorage-storage/index.d.ts +6 -5
  90. package/lib/localstorage-storage/index.js +1 -1
  91. package/lib/notification-consumer/index.cjs +2 -0
  92. package/lib/notification-consumer/index.d.cts +17 -0
  93. package/lib/notification-consumer/index.d.ts +17 -0
  94. package/lib/notification-consumer/index.js +2 -0
  95. package/lib/queue-storage-public.cjs +2 -0
  96. package/lib/queue-storage-public.d.cts +13 -0
  97. package/lib/queue-storage-public.d.ts +13 -0
  98. package/lib/queue-storage-public.js +2 -0
  99. package/lib/server-jobs-queue-runner/index.cjs +1 -1
  100. package/lib/server-jobs-queue-runner/index.d.cts +9 -7
  101. package/lib/server-jobs-queue-runner/index.d.ts +9 -7
  102. package/lib/server-jobs-queue-runner/index.js +1 -1
  103. package/lib/server-runtime/index.cjs +1 -1
  104. package/lib/server-runtime/index.d.cts +9 -7
  105. package/lib/server-runtime/index.d.ts +9 -7
  106. package/lib/server-runtime/index.js +1 -1
  107. package/lib/server-runtime-agentface/index.d.cts +8 -6
  108. package/lib/server-runtime-agentface/index.d.ts +8 -6
  109. package/lib/server-runtime-controlface/index.cjs +1 -1
  110. package/lib/server-runtime-controlface/index.d.cts +8 -6
  111. package/lib/server-runtime-controlface/index.d.ts +8 -6
  112. package/lib/server-runtime-controlface/index.js +1 -1
  113. package/lib/server-runtime-core/index.cjs +1 -1
  114. package/lib/server-runtime-core/index.d.cts +16 -37
  115. package/lib/server-runtime-core/index.d.ts +16 -37
  116. package/lib/server-runtime-core/index.js +1 -1
  117. package/lib/server-runtime-watchers/index.cjs +1 -1
  118. package/lib/server-runtime-watchers/index.d.cts +11 -8
  119. package/lib/server-runtime-watchers/index.d.ts +11 -8
  120. package/lib/server-runtime-watchers/index.js +1 -1
  121. package/lib/server-runtime-webhooks/index.d.cts +8 -6
  122. package/lib/server-runtime-webhooks/index.d.ts +8 -6
  123. package/lib/{sse-hub-DM8bw-dO.d.ts → sse-hub-BRBvymHR.d.cts} +18 -15
  124. package/lib/{sse-hub-BDjWI7JR.d.cts → sse-hub-D_94fV87.d.ts} +18 -15
  125. package/lib/step-machine-public/index.d.cts +1 -1
  126. package/lib/step-machine-public/index.d.ts +1 -1
  127. package/lib/{storage-async-interface-CyO-zwVQ.d.cts → storage-async-interface-CLOXiqwC.d.cts} +2 -2
  128. package/lib/{storage-async-interface-CG0bMqvE.d.ts → storage-async-interface-Cb-SULrI.d.ts} +2 -2
  129. package/lib/{storage-interface-D-iEiTJA.d.cts → storage-interface-DogVPooM.d.cts} +1 -1
  130. package/lib/{storage-interface-D-iEiTJA.d.ts → storage-interface-DogVPooM.d.ts} +1 -1
  131. package/lib/stores/index.d.cts +1 -1
  132. package/lib/stores/index.d.ts +1 -1
  133. package/lib/stores/kv.d.cts +1 -1
  134. package/lib/stores/kv.d.ts +1 -1
  135. package/lib/{types-BsfXZyI3.d.ts → types-BsqzUZNl.d.ts} +6 -4
  136. package/lib/{types-CPnYv7RC.d.cts → types-DxhB6Toz.d.cts} +6 -4
  137. package/package.json +16 -1
  138. package/lib/board-live-cards-public-BT5HrgqZ.d.cts +0 -529
  139. package/lib/board-live-cards-public-DSRamFm8.d.ts +0 -529
  140. package/lib/chunk-35N7ONTH.js +0 -2
  141. package/lib/chunk-36QUKFL7.cjs +0 -3
  142. package/lib/chunk-3MMTBEAO.js +0 -2
  143. package/lib/chunk-7QQFDYBM.js +0 -3
  144. package/lib/chunk-7QZ267XP.cjs +0 -2
  145. package/lib/chunk-ABAVFLDP.js +0 -7
  146. package/lib/chunk-EPCJYP4N.js +0 -3
  147. package/lib/chunk-H22NK6KH.cjs +0 -7
  148. package/lib/chunk-KBELAKIY.js +0 -2
  149. package/lib/chunk-OJ2CAQ4C.cjs +0 -2
  150. package/lib/chunk-Q3OTUDIE.js +0 -2
  151. package/lib/chunk-QG6ERLZQ.cjs +0 -3
  152. package/lib/chunk-UB54HZA4.cjs +0 -2
  153. package/lib/chunk-YMEIPKLW.cjs +0 -2
  154. package/lib/chunk-ZENTBLLA.cjs +0 -3
  155. package/lib/chunk-ZWVT24YW.js +0 -3
@@ -0,0 +1,2394 @@
1
+ #!/usr/bin/env node
2
+ // @ts-nocheck
3
+ /**
4
+ * demo-http-test-browser.ts
5
+ *
6
+ * Smoke test for demo-board/server/board-server.js over HTTP + SSE.
7
+ * Uses yaml-flow's shared browser board-state reducer for SSE accumulation
8
+ * instead of stitching incremental notification state inside the test.
9
+ * Targets the 'live' board with --cards-pattern cardT* to load only the 3
10
+ * test cards (cardT-portfolio, cardT-market-prices, cardT-portfolio-value).
11
+ *
12
+ * T0: /sse streaming connect → upsert fixtures → wait for all cards to complete
13
+ * T1: PATCH holdings (+1 row) → verify recomputation (holdings +1, positions +1)
14
+ *
15
+ * Usage:
16
+ * npx tsx test/server-http-test-browser.ts [--port 7799]
17
+ */
18
+
19
+ import { spawn, spawnSync } from 'node:child_process';
20
+ import { fileURLToPath } from 'node:url';
21
+ import path from 'node:path';
22
+ import http from 'node:http';
23
+ import net from 'node:net';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import { buildBoardState, applyNotification } from '../../../src/board-state-reducer.ts';
27
+ import { runtimeNotificationsFromPayload } from '../../../src/notification-consumer/index.ts';
28
+
29
+ const ECHO_PROBE_MARKER = '__probe__echo__probe__';
30
+ const PROBE_IN_PROGRESS_TEXT = 'in-progress';
31
+
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = path.dirname(__filename);
34
+
35
+ const cliArgs = process.argv.slice(2);
36
+ const portArg = cliArgs.indexOf('--port');
37
+ const cliPort = portArg !== -1 ? parseInt(cliArgs[portArg + 1], 10) : NaN;
38
+ const skipT1 = cliArgs.includes('--skip-t1');
39
+ const skipT2 = cliArgs.includes('--skip-t2');
40
+ const skipT3 = cliArgs.includes('--skip-t3');
41
+ const skipT4 = cliArgs.includes('--skip-t4');
42
+ const skipT5 = cliArgs.includes('--skip-t5');
43
+ function isCopilotAvailable() {
44
+ try {
45
+ const r = spawnSync('copilot', ['--version'], { timeout: 5_000, stdio: 'ignore', windowsHide: true });
46
+ return !r.error;
47
+ } catch { return false; }
48
+ }
49
+
50
+ const skipT3a = cliArgs.includes('--skip-t3a') || !isCopilotAvailable();
51
+ const skipT3b = cliArgs.includes('--skip-t3b');
52
+ const skipT3e = cliArgs.includes('--skip-t3e');
53
+ const skipT3d = cliArgs.includes('--skip-t3d');
54
+ const RUN_ID = `run-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
55
+
56
+ const BOARD_ID = 'live';
57
+ const BOARD_DIR = path.resolve(__dirname, '..');
58
+ const SERVER_SCRIPT = path.resolve(BOARD_DIR, 'server', 'board-server.js');
59
+ // Force the board server to start with zero cards; the test upserts the
60
+ // three cardT-* fixtures itself in T0 so the SSE upsert/delta path is
61
+ // exercised end-to-end.
62
+ const CARD_PATTERN = '__none__*';
63
+ const CARDS_DIR = path.resolve(BOARD_DIR, 'cards');
64
+ const T0_CARD_FILES = [
65
+ 'cardT-portfolio.json',
66
+ 'cardT-market-prices.json',
67
+ 'cardT-portfolio-value.json',
68
+ ];
69
+ const T0_EXPECTED_CARD_IDS = ['card-market-prices', 'card-portfolio', 'card-portfolio-value'];
70
+ const T2_FILE_CARD_ID = 'card-market-prices';
71
+ const CHAT_CARD_ID = 'card-portfolio';
72
+
73
+ function findFreePort() {
74
+ return new Promise((resolve, reject) => {
75
+ const srv = net.createServer();
76
+ srv.listen(0, '127.0.0.1', () => {
77
+ const addr = /** @type {import('node:net').AddressInfo} */ (srv.address());
78
+ srv.close(() => resolve(addr.port));
79
+ });
80
+ srv.on('error', reject);
81
+ });
82
+ }
83
+
84
+ async function resolveServerPort() {
85
+ if (Number.isInteger(cliPort) && cliPort > 0) return cliPort;
86
+ return findFreePort();
87
+ }
88
+
89
+ const PORT = await resolveServerPort();
90
+ const BASE = `http://127.0.0.1:${PORT}/api/boards/${BOARD_ID}`;
91
+
92
+ // Always use a system temp directory so parallel runs and vitest don't collide.
93
+ function resolveSetupDirRoot() {
94
+ return os.tmpdir();
95
+ }
96
+
97
+ const SETUP_DIR = path.join(resolveSetupDirRoot(), RUN_ID);
98
+ const BOARD_SETUP_ROOT = path.join(SETUP_DIR, 'boards');
99
+ if (fs.existsSync(SETUP_DIR)) {
100
+ fs.rmSync(SETUP_DIR, { recursive: true, force: true });
101
+ console.log(`[demo-http-test] wiped setup dir: ${SETUP_DIR}`);
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Shared state — accumulated from SSE frames
106
+ // ---------------------------------------------------------------------------
107
+
108
+ const NS = {
109
+ boardState: null,
110
+ latestFullPayload: null,
111
+ statusSummary: null,
112
+ statusGeneration: 0,
113
+ computedValues: {},
114
+ chatEvents: [],
115
+ allChatNotifications: [],
116
+ boardEvents: [],
117
+ };
118
+
119
+ function normalizeRequiredTokens(requires) {
120
+ if (!Array.isArray(requires)) return [];
121
+ return requires.flatMap((entry) => {
122
+ if (typeof entry === 'string' && entry.trim()) return [entry.trim()];
123
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return [];
124
+ const bindTo = typeof entry.bindTo === 'string'
125
+ ? entry.bindTo.trim()
126
+ : typeof entry.key === 'string'
127
+ ? entry.key.trim()
128
+ : '';
129
+ return bindTo ? [bindTo] : [];
130
+ });
131
+ }
132
+
133
+ function normalizeChatState(chatSnapshot = null) {
134
+ return {
135
+ messages: Array.isArray(chatSnapshot?.messages) ? chatSnapshot.messages : [],
136
+ receiving: chatSnapshot?.receiving === true,
137
+ processing: chatSnapshot?.processing === true,
138
+ };
139
+ }
140
+
141
+ function buildStatusCardIndex(statusSnapshot) {
142
+ const index = new Map();
143
+ for (const entry of (statusSnapshot?.cards ?? [])) {
144
+ const cardId = typeof entry?.name === 'string' ? entry.name.trim() : '';
145
+ if (cardId) index.set(cardId, entry);
146
+ }
147
+ return index;
148
+ }
149
+
150
+ function summarizeBoardState(boardState) {
151
+ const summary = {
152
+ card_count: Array.isArray(boardState?.cardIds) ? boardState.cardIds.length : 0,
153
+ completed: 0,
154
+ failed: 0,
155
+ running: 0,
156
+ pending: 0,
157
+ };
158
+ for (const cardId of (boardState?.cardIds ?? [])) {
159
+ const taskStatus = String(boardState?.modelsById?.[cardId]?.runtime_state?.task_status || '');
160
+ if (taskStatus === 'completed') summary.completed += 1;
161
+ else if (taskStatus === 'failed') summary.failed += 1;
162
+ else if (taskStatus === 'running' || taskStatus === 'in-progress') summary.running += 1;
163
+ else summary.pending += 1;
164
+ }
165
+ return summary;
166
+ }
167
+
168
+ function syncProjectedStateFromBoardState() {
169
+ if (!NS.boardState) {
170
+ NS.computedValues = {};
171
+ return;
172
+ }
173
+ NS.statusSummary = summarizeBoardState(NS.boardState);
174
+ NS.computedValues = Object.fromEntries(
175
+ (NS.boardState.cardIds ?? []).map((cardId) => [cardId, NS.boardState.modelsById?.[cardId]?.computed_values ?? {}]),
176
+ );
177
+ }
178
+
179
+ function selectLiveCardModelFromPayload(payload, cardId) {
180
+ const cardDefinitions = Array.isArray(payload?.cardDefinitions) ? payload.cardDefinitions : [];
181
+ const card = cardDefinitions.find((entry) => entry?.id === cardId) ?? { id: cardId, card_data: {} };
182
+ const statusEntry = buildStatusCardIndex(payload?.statusSnapshot).get(cardId) ?? null;
183
+ const runtimeEntry = payload?.cardRuntimeById?.[cardId] ?? null;
184
+ const requires = {};
185
+ for (const token of normalizeRequiredTokens(card?.requires)) {
186
+ requires[token] = Object.prototype.hasOwnProperty.call(payload?.dataObjectsByToken ?? {}, token)
187
+ ? payload.dataObjectsByToken[token]
188
+ : null;
189
+ }
190
+ return {
191
+ id: cardId,
192
+ card,
193
+ card_data: card?.card_data ?? {},
194
+ requires,
195
+ computed_values: runtimeEntry?.computed_values ?? {},
196
+ runtime_state: {
197
+ task_status: statusEntry?.status ?? null,
198
+ card_status: statusEntry?.status ?? null,
199
+ runtime: statusEntry?.runtime ?? runtimeEntry?.runtime ?? {},
200
+ error: statusEntry?.error ?? null,
201
+ blocked_by: Array.isArray(statusEntry?.blocked_by) ? statusEntry.blocked_by : [],
202
+ requires_missing: Array.isArray(statusEntry?.requires_missing) ? statusEntry.requires_missing : [],
203
+ },
204
+ card_chats: payload?.cardChatsByCardId?.[cardId] ? normalizeChatState(payload.cardChatsByCardId[cardId]) : null,
205
+ };
206
+ }
207
+
208
+ function reducePayload(payload) {
209
+ if (payload && Array.isArray(payload.cardDefinitions)) {
210
+ NS.latestFullPayload = payload;
211
+ NS.boardState = buildBoardState(payload, NS.boardState, selectLiveCardModelFromPayload);
212
+ NS.statusGeneration += 1;
213
+ syncProjectedStateFromBoardState();
214
+ return [];
215
+ }
216
+
217
+ const notifications = runtimeNotificationsFromPayload(payload);
218
+ if (notifications.length === 0) {
219
+ return notifications;
220
+ }
221
+
222
+ if (NS.boardState) {
223
+ NS.boardState = applyNotification(
224
+ NS.boardState,
225
+ notifications,
226
+ selectLiveCardModelFromPayload,
227
+ () => NS.latestFullPayload,
228
+ );
229
+ syncProjectedStateFromBoardState();
230
+ }
231
+
232
+ for (const notification of notifications) {
233
+ if (notification?.kind === 'status' && notification.status?.summary) {
234
+ NS.statusGeneration += 1;
235
+ }
236
+ if (notification && (notification.kind === 'card_removed' || notification.kind === 'card_refreshed') && notification.cardId) {
237
+ NS.boardEvents.push({ kind: notification.kind, cardId: notification.cardId, at: Date.now() });
238
+ }
239
+ }
240
+
241
+ return notifications;
242
+ }
243
+
244
+ function applyFrame(payload) {
245
+ reducePayload(payload);
246
+ }
247
+
248
+ function normalizeSseChunkBuffer(buf, chunk) {
249
+ return (buf + chunk.replace(/\r\n/g, '\n'));
250
+ }
251
+
252
+ function parseSseBlocks(buffer) {
253
+ const payloads = [];
254
+ let buf = buffer;
255
+ while (true) {
256
+ const idx = buf.indexOf('\n\n');
257
+ if (idx === -1) break;
258
+ const block = buf.slice(0, idx);
259
+ buf = buf.slice(idx + 2);
260
+ const dataLines = [];
261
+ for (const line of block.split('\n')) {
262
+ if (line.startsWith('data:')) {
263
+ dataLines.push(line.slice(5).replace(/^ /, ''));
264
+ }
265
+ }
266
+ const data = dataLines.join('\n');
267
+ if (!data) continue;
268
+ try {
269
+ payloads.push(JSON.parse(data));
270
+ } catch { /* ignore malformed */ }
271
+ }
272
+ return { payloads, remainder: buf };
273
+ }
274
+
275
+ function parseRawSseBlocks(buffer) {
276
+ const frames = [];
277
+ let buf = buffer;
278
+ while (true) {
279
+ const idx = buf.indexOf('\n\n');
280
+ if (idx === -1) break;
281
+ const block = buf.slice(0, idx);
282
+ buf = buf.slice(idx + 2);
283
+ if (!block.trim()) continue;
284
+ const frame = { id: null, event: null, data: '', payload: null, raw: block };
285
+ const dataLines = [];
286
+ for (const line of block.split('\n')) {
287
+ if (line.startsWith(':')) continue;
288
+ if (line.startsWith('id:')) {
289
+ frame.id = line.slice(3).trim();
290
+ } else if (line.startsWith('event:')) {
291
+ frame.event = line.slice(6).trim();
292
+ } else if (line.startsWith('data:')) {
293
+ dataLines.push(line.slice(5).replace(/^ /, ''));
294
+ }
295
+ }
296
+ frame.data = dataLines.join('\n');
297
+ if (!frame.id && !frame.event && !frame.data) continue;
298
+ if (frame.data) {
299
+ try {
300
+ frame.payload = JSON.parse(frame.data);
301
+ } catch { /* ignore malformed */ }
302
+ }
303
+ frames.push(frame);
304
+ }
305
+ return { frames, remainder: buf };
306
+ }
307
+
308
+ function startSseClient(sseUrl, onPayload) {
309
+ const req = http.get(sseUrl, (res) => {
310
+ let buf = '';
311
+ res.setEncoding('utf-8');
312
+ res.on('data', (chunk) => {
313
+ buf = normalizeSseChunkBuffer(buf, chunk);
314
+ const parsed = parseSseBlocks(buf);
315
+ buf = parsed.remainder;
316
+ for (const payload of parsed.payloads) onPayload(payload);
317
+ });
318
+ });
319
+ req.on('error', () => {});
320
+ return {
321
+ close() {
322
+ try { req.destroy(); } catch { /* */ }
323
+ },
324
+ };
325
+ }
326
+
327
+ function startRawSseClient({ sseUrl, headers = {}, onResponse, onFrame, onClose, onError }) {
328
+ let closed = false;
329
+ const req = http.request(sseUrl, { headers }, (res) => {
330
+ let buf = '';
331
+ res.setEncoding('utf-8');
332
+ try { onResponse?.(res); } catch { /* ignore */ }
333
+ res.on('data', (chunk) => {
334
+ buf = normalizeSseChunkBuffer(buf, chunk);
335
+ const parsed = parseRawSseBlocks(buf);
336
+ buf = parsed.remainder;
337
+ for (const frame of parsed.frames) {
338
+ try { onFrame?.(frame, res); } catch { /* ignore */ }
339
+ }
340
+ });
341
+ const closeOnce = () => {
342
+ if (closed) return;
343
+ closed = true;
344
+ try { onClose?.(res); } catch { /* ignore */ }
345
+ };
346
+ res.on('end', closeOnce);
347
+ res.on('close', closeOnce);
348
+ res.on('error', (err) => {
349
+ try { onError?.(err); } catch { /* ignore */ }
350
+ });
351
+ });
352
+ req.on('error', (err) => {
353
+ try { onError?.(err); } catch { /* ignore */ }
354
+ });
355
+ req.end();
356
+ return {
357
+ close() {
358
+ try { req.destroy(); } catch { /* ignore */ }
359
+ },
360
+ };
361
+ }
362
+
363
+ function waitForRawSseFrames({ sseUrl, headers = {}, until, timeoutMs = 15_000, waitForClose = false }) {
364
+ return new Promise((resolve, reject) => {
365
+ const state = { statusCode: null, headers: {}, frames: [], closed: false };
366
+ let settled = false;
367
+ let client = null;
368
+ const predicate = typeof until === 'function' ? until : ((current) => current.frames.length > 0);
369
+
370
+ function maybeResolve() {
371
+ if (settled) return;
372
+ if (!predicate(state)) return;
373
+ if (waitForClose && !state.closed) return;
374
+ settled = true;
375
+ clearTimeout(timeout);
376
+ if (!waitForClose && client) client.close();
377
+ resolve(state);
378
+ }
379
+
380
+ const timeout = setTimeout(() => {
381
+ if (settled) return;
382
+ settled = true;
383
+ if (client) client.close();
384
+ reject(new Error(`Timeout (${timeoutMs}ms) waiting for raw SSE frames`));
385
+ }, timeoutMs);
386
+
387
+ client = startRawSseClient({
388
+ sseUrl,
389
+ headers,
390
+ onResponse(res) {
391
+ state.statusCode = res.statusCode ?? null;
392
+ state.headers = res.headers || {};
393
+ maybeResolve();
394
+ },
395
+ onFrame(frame) {
396
+ state.frames.push(frame);
397
+ maybeResolve();
398
+ },
399
+ onClose() {
400
+ state.closed = true;
401
+ maybeResolve();
402
+ },
403
+ onError(err) {
404
+ if (settled) return;
405
+ settled = true;
406
+ clearTimeout(timeout);
407
+ reject(err);
408
+ },
409
+ });
410
+ });
411
+ }
412
+
413
+ function waitForFirstSsePayload(sseUrl, timeoutMs = 15_000) {
414
+ return new Promise((resolve, reject) => {
415
+ let settled = false;
416
+ let client = null;
417
+ const timeout = setTimeout(() => {
418
+ if (settled) return;
419
+ settled = true;
420
+ if (client) client.close();
421
+ reject(new Error(`Timeout (${timeoutMs}ms) waiting for: first SSE payload`));
422
+ }, timeoutMs);
423
+
424
+ client = startSseClient(sseUrl, (payload) => {
425
+ if (settled) return;
426
+ settled = true;
427
+ clearTimeout(timeout);
428
+ client.close();
429
+ resolve(payload);
430
+ });
431
+ });
432
+ }
433
+
434
+ function captureChatEvents(payload, cardId) {
435
+ const notifications = reducePayload(payload);
436
+ for (const notification of notifications) {
437
+ if (!notification?.cardId) continue;
438
+ if (notification.kind !== 'card_chats' && notification.kind !== 'chat_messages' && notification.kind !== 'chat_processing') continue;
439
+ const chatState = normalizeChatState(NS.boardState?.modelsById?.[notification.cardId]?.card_chats ?? null);
440
+ const next = {
441
+ at: Date.now(),
442
+ cardId: notification.cardId,
443
+ processing: chatState.processing,
444
+ receiving: chatState.receiving,
445
+ messageCount: chatState.messages.length,
446
+ messages: chatState.messages,
447
+ };
448
+ NS.allChatNotifications.push(next);
449
+ if (notification.cardId === cardId) {
450
+ NS.chatEvents.push(next);
451
+ }
452
+ }
453
+ }
454
+
455
+ function assert(condition, message) {
456
+ if (!condition) {
457
+ console.error(`\n[ASSERT FAILED] ${message}`);
458
+ process.exit(1);
459
+ }
460
+ }
461
+
462
+ function randomTurnId() {
463
+ return String(Math.floor(100000 + Math.random() * 900000));
464
+ }
465
+
466
+ function waitUntil(predicate, timeoutMs, label) {
467
+ return new Promise((resolve, reject) => {
468
+ const deadline = Date.now() + timeoutMs;
469
+ const interval = setInterval(() => {
470
+ let result;
471
+ try { result = predicate(); } catch { /* retry */ }
472
+ if (result !== undefined && result !== null && result !== false) {
473
+ clearInterval(interval);
474
+ resolve(result);
475
+ return;
476
+ }
477
+ if (Date.now() > deadline) {
478
+ clearInterval(interval);
479
+ reject(new Error(`Timeout (${timeoutMs}ms) waiting for: ${label}`));
480
+ }
481
+ }, 150);
482
+ });
483
+ }
484
+
485
+ function waitUntilAsync(predicate, timeoutMs, label) {
486
+ return new Promise((resolve, reject) => {
487
+ const deadline = Date.now() + timeoutMs;
488
+ const interval = setInterval(async () => {
489
+ let result;
490
+ try { result = await predicate(); } catch { /* retry */ }
491
+ if (result !== undefined && result !== null && result !== false) {
492
+ clearInterval(interval);
493
+ resolve(result);
494
+ return;
495
+ }
496
+ if (Date.now() > deadline) {
497
+ clearInterval(interval);
498
+ reject(new Error(`Timeout (${timeoutMs}ms) waiting for: ${label}`));
499
+ }
500
+ }, 150);
501
+ });
502
+ }
503
+
504
+ function wait(ms) {
505
+ return new Promise((resolve) => setTimeout(resolve, ms));
506
+ }
507
+
508
+ function extractStatusSummaryFromPayload(payload) {
509
+ if (payload?.statusSnapshot?.summary && typeof payload.statusSnapshot.summary === 'object') {
510
+ return payload.statusSnapshot.summary;
511
+ }
512
+ if (payload?.kind === 'notification-batch' && Array.isArray(payload.notifications)) {
513
+ for (const notification of payload.notifications) {
514
+ if (notification?.kind === 'status' && notification.status?.summary && typeof notification.status.summary === 'object') {
515
+ return notification.status.summary;
516
+ }
517
+ }
518
+ }
519
+ return null;
520
+ }
521
+
522
+ function normalizeHydratedChatMessages(messages) {
523
+ return (Array.isArray(messages) ? messages : []).map((message) => ({
524
+ role: String(message?.role || ''),
525
+ text: String(message?.text || ''),
526
+ files: Array.isArray(message?.files) ? message.files : [],
527
+ }));
528
+ }
529
+
530
+ function readHeaderValue(headers, name) {
531
+ const raw = headers?.[name.toLowerCase()];
532
+ return Array.isArray(raw) ? String(raw[0] || '') : String(raw || '');
533
+ }
534
+
535
+ function buildTsStaticCard(cardId, label) {
536
+ return {
537
+ id: cardId,
538
+ meta: {
539
+ title: label,
540
+ tags: ['ts', 'sse'],
541
+ desc: `${label} disposable SSE delta card`,
542
+ },
543
+ compute: [],
544
+ view: {
545
+ elements: [
546
+ {
547
+ kind: 'markdown',
548
+ data: {
549
+ bind: 'card_data.text',
550
+ },
551
+ },
552
+ ],
553
+ layout: {
554
+ board: {
555
+ col: 2,
556
+ order: 99,
557
+ },
558
+ canvas: {
559
+ x: 1600,
560
+ y: 600,
561
+ w: 260,
562
+ h: 120,
563
+ },
564
+ },
565
+ features: {},
566
+ },
567
+ card_data: {
568
+ text: label,
569
+ },
570
+ };
571
+ }
572
+
573
+ function buildChatProbeCard(cardId, label) {
574
+ return {
575
+ id: cardId,
576
+ meta: {
577
+ title: label,
578
+ tags: ['chat', 'probe'],
579
+ desc: `${label} disposable chat probe card`,
580
+ },
581
+ provides: [
582
+ {
583
+ bindTo: 'holdings',
584
+ ref: 'card_data.holdings',
585
+ },
586
+ ],
587
+ compute: [],
588
+ view: {
589
+ elements: [
590
+ {
591
+ kind: 'editable-table',
592
+ label: 'Holdings',
593
+ data: {
594
+ bind: 'card_data.holdings',
595
+ writeTo: 'card_data.holdings',
596
+ columns: ['ticker', 'quantity', 'cost_basis'],
597
+ schema: {
598
+ properties: {
599
+ quantity: { type: 'number' },
600
+ cost_basis: { type: 'number' },
601
+ },
602
+ },
603
+ },
604
+ },
605
+ ],
606
+ layout: {
607
+ board: {
608
+ col: 3,
609
+ order: 98,
610
+ },
611
+ canvas: {
612
+ x: 1400,
613
+ y: 420,
614
+ w: 320,
615
+ h: 260,
616
+ },
617
+ },
618
+ features: {
619
+ chat: true,
620
+ },
621
+ },
622
+ card_data: {
623
+ holdings: [
624
+ { ticker: 'AAPL', quantity: 1, cost_basis: 150 },
625
+ ],
626
+ },
627
+ };
628
+ }
629
+
630
+ function assertObjectContains(actual, expected, label) {
631
+ assert(actual && typeof actual === 'object', `${label} actual value is not an object`);
632
+ assert(expected && typeof expected === 'object', `${label} expected value is not an object`);
633
+ for (const [key, value] of Object.entries(expected)) {
634
+ assert(Object.is(actual[key], value), `${label}.${key} mismatch: expected ${JSON.stringify(value)}, got ${JSON.stringify(actual[key])}`);
635
+ }
636
+ }
637
+
638
+ async function stopChildProcess(proc, label) {
639
+ if (!proc) return;
640
+ if (proc.exitCode !== null) return;
641
+
642
+ const exitPromise = new Promise((resolve) => {
643
+ proc.once('exit', (code, signal) => resolve({ code, signal }));
644
+ });
645
+
646
+ try {
647
+ proc.kill();
648
+ } catch {
649
+ return;
650
+ }
651
+
652
+ const gracefulExit = await Promise.race([
653
+ exitPromise,
654
+ wait(5_000).then(() => null),
655
+ ]);
656
+ if (gracefulExit) return;
657
+
658
+ if (proc.exitCode === null) {
659
+ try { proc.kill('SIGKILL'); } catch { /* ignore */ }
660
+ }
661
+
662
+ const forcedExit = await Promise.race([
663
+ exitPromise,
664
+ wait(5_000).then(() => null),
665
+ ]);
666
+ if (!forcedExit && proc.exitCode === null) {
667
+ throw new Error(`${label} did not exit after kill()`);
668
+ }
669
+ }
670
+
671
+ const waitForAllCompleted = (ms = 60_000, label = 'all completed') =>
672
+ waitUntil(() => {
673
+ const s = NS.statusSummary;
674
+ if (s && s.card_count > 0 && s.completed === s.card_count) return s;
675
+ return false;
676
+ }, ms, label);
677
+
678
+ const waitForChatPredicate = (predicate, ms, label) =>
679
+ waitUntil(() => predicate(NS.chatEvents) || false, ms, label);
680
+
681
+ function deriveProbeLifecycleMilestones(events, opts) {
682
+ const milestones = [];
683
+ let prevProcessing = Boolean(opts.beforeProcessing);
684
+ const prompt = String(opts.prompt || '');
685
+ const assistantText = opts.assistantText == null ? `Echo: ${prompt}` : String(opts.assistantText);
686
+ const inProgressText = String(opts.inProgressText || PROBE_IN_PROGRESS_TEXT);
687
+ const seenRelevantMessages = new Set();
688
+
689
+ for (const event of events) {
690
+ const messages = Array.isArray(event?.messages) ? event.messages : [];
691
+ for (const message of messages) {
692
+ const role = String(message?.role || '');
693
+ const text = String(message?.text || '');
694
+ const turn = typeof message?.turn === 'string' ? message.turn : '';
695
+ const signature = `${turn}|${role}|${text}`;
696
+ if (seenRelevantMessages.has(signature)) continue;
697
+
698
+ if (role === 'user' && text.includes(prompt)) {
699
+ milestones.push('user');
700
+ seenRelevantMessages.add(signature);
701
+ } else if (role === 'system' && text.trim().toLowerCase() === inProgressText) {
702
+ milestones.push('in-progress');
703
+ seenRelevantMessages.add(signature);
704
+ } else if (role === 'assistant' && text.includes(assistantText)) {
705
+ milestones.push('assistant');
706
+ seenRelevantMessages.add(signature);
707
+ }
708
+ }
709
+
710
+ const processing = Boolean(event?.processing);
711
+ if (processing !== prevProcessing) milestones.push(processing ? 'processing-true' : 'processing-false');
712
+
713
+ prevProcessing = processing;
714
+ }
715
+
716
+ return milestones;
717
+ }
718
+
719
+ function matchOrderedProbeLifecycle(events, opts) {
720
+ const milestones = deriveProbeLifecycleMilestones(events, opts);
721
+ const userIdx = milestones.indexOf('user');
722
+ const processingTrueIdx = milestones.indexOf('processing-true');
723
+ const assistantIdx = milestones.indexOf('assistant');
724
+ const processingFalseIdx = milestones.lastIndexOf('processing-false');
725
+ const inProgressIdx = milestones.indexOf('in-progress');
726
+
727
+ if (userIdx === -1 || processingTrueIdx === -1 || assistantIdx === -1 || processingFalseIdx === -1) {
728
+ return false;
729
+ }
730
+
731
+ if (Math.max(userIdx, processingTrueIdx) >= assistantIdx) return false;
732
+ if (assistantIdx >= processingFalseIdx) return false;
733
+ if (inProgressIdx !== -1 && (inProgressIdx <= processingTrueIdx || inProgressIdx >= assistantIdx)) return false;
734
+
735
+ return { milestones };
736
+ }
737
+
738
+ function httpGet(url) {
739
+ return new Promise((resolve, reject) => {
740
+ http.get(url, (res) => {
741
+ let body = '';
742
+ res.on('data', c => { body += c; });
743
+ res.on('end', () => {
744
+ try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
745
+ catch { resolve({ status: res.statusCode, data: body }); }
746
+ });
747
+ }).on('error', reject);
748
+ });
749
+ }
750
+
751
+ function httpGetRaw(url) {
752
+ return new Promise((resolve, reject) => {
753
+ http.get(url, (res) => {
754
+ const chunks = [];
755
+ res.on('data', c => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); });
756
+ res.on('end', () => {
757
+ resolve({
758
+ status: res.statusCode,
759
+ body: Buffer.concat(chunks),
760
+ headers: res.headers,
761
+ });
762
+ });
763
+ }).on('error', reject);
764
+ });
765
+ }
766
+
767
+ function httpJson(method, url, payload) {
768
+ return new Promise((resolve, reject) => {
769
+ const u = new URL(url);
770
+ const data = payload != null ? JSON.stringify(payload) : null;
771
+ const opts = {
772
+ hostname: u.hostname,
773
+ port: u.port,
774
+ path: u.pathname,
775
+ method,
776
+ headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
777
+ };
778
+ const req = http.request(opts, (res) => {
779
+ let body = '';
780
+ res.on('data', c => { body += c; });
781
+ res.on('end', () => {
782
+ try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
783
+ catch { resolve({ status: res.statusCode, data: body }); }
784
+ });
785
+ });
786
+ req.on('error', reject);
787
+ if (data) req.write(data);
788
+ req.end();
789
+ });
790
+ }
791
+
792
+ function httpMcp(tool, args) {
793
+ return httpJson('POST', `${BASE}/mcp`, { tool, args });
794
+ }
795
+
796
+ function httpMcpControlplane(tool, args) {
797
+ return httpJson('POST', `${BASE}/mcp-controlplane`, { tool, args });
798
+ }
799
+
800
+ function httpMcpRaw(tool, args) {
801
+ return new Promise((resolve, reject) => {
802
+ const u = new URL(`${BASE}/mcp-raw`);
803
+ const data = JSON.stringify({ tool, args });
804
+ const opts = {
805
+ hostname: u.hostname,
806
+ port: u.port,
807
+ path: u.pathname,
808
+ method: 'POST',
809
+ headers: {
810
+ 'Content-Type': 'application/json',
811
+ 'Content-Length': Buffer.byteLength(data),
812
+ },
813
+ };
814
+ const req = http.request(opts, (res) => {
815
+ const chunks = [];
816
+ res.on('data', c => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); });
817
+ res.on('end', () => {
818
+ resolve({
819
+ status: res.statusCode,
820
+ body: Buffer.concat(chunks),
821
+ headers: res.headers,
822
+ });
823
+ });
824
+ });
825
+ req.on('error', reject);
826
+ req.write(data);
827
+ req.end();
828
+ });
829
+ }
830
+
831
+ function httpUploadChatFile(url, fileName, content, contentType = 'text/plain; charset=utf-8') {
832
+ return new Promise((resolve, reject) => {
833
+ const u = new URL(url);
834
+ const data = Buffer.from(content, 'utf-8');
835
+ const opts = {
836
+ hostname: u.hostname,
837
+ port: u.port,
838
+ path: u.pathname + u.search,
839
+ method: 'POST',
840
+ headers: {
841
+ 'Content-Type': contentType,
842
+ 'Content-Length': data.length,
843
+ 'x-file-name': encodeURIComponent(fileName),
844
+ },
845
+ };
846
+ const req = http.request(opts, (res) => {
847
+ let body = '';
848
+ res.on('data', c => { body += c; });
849
+ res.on('end', () => {
850
+ try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
851
+ catch { resolve({ status: res.statusCode, data: body }); }
852
+ });
853
+ });
854
+ req.on('error', reject);
855
+ req.write(data);
856
+ req.end();
857
+ });
858
+ }
859
+
860
+ function deepCloneJson(value) {
861
+ return JSON.parse(JSON.stringify(value));
862
+ }
863
+
864
+ function expectMcpSuccess(httpResult, label) {
865
+ assert(httpResult.status === 200, `${label} returned ${httpResult.status}`);
866
+ assert(httpResult.data?.status === 'success', `${label} expected status=success, got ${JSON.stringify(httpResult.data)}`);
867
+ return httpResult.data.data;
868
+ }
869
+
870
+ function startServer(port) {
871
+ return new Promise((resolve, reject) => {
872
+ const proc = spawn(process.execPath, [SERVER_SCRIPT], {
873
+ stdio: ['ignore', 'pipe', 'pipe'],
874
+ windowsHide: true,
875
+ env: {
876
+ ...process.env,
877
+ DEMO_SERVER_PORT: String(port),
878
+ DEMO_SETUP_DIR: SETUP_DIR,
879
+ DEMO_BOARD_SETUP_ROOT: BOARD_SETUP_ROOT,
880
+ DEMO_CARDS_PATTERN: CARD_PATTERN,
881
+ BOARD_SERVER_ENABLE_TEST_REQ: '1',
882
+ },
883
+ });
884
+ let ready = false;
885
+
886
+ proc.stdout.on('data', (chunk) => {
887
+ const text = chunk.toString('utf-8');
888
+ process.stdout.write(`[server] ${text}`);
889
+ if (!ready && text.includes('listening on')) {
890
+ ready = true;
891
+ resolve(proc);
892
+ }
893
+ });
894
+ proc.stderr.on('data', (chunk) => process.stderr.write(`[server:err] ${chunk}`));
895
+ proc.on('error', reject);
896
+ proc.on('exit', (code) => {
897
+ if (!ready) reject(new Error(`Server exited early: code ${code}`));
898
+ });
899
+
900
+ const startupTimer = setTimeout(() => {
901
+ if (!ready) reject(new Error('Server startup timeout (15s)'));
902
+ }, 15_000);
903
+ startupTimer.unref?.();
904
+ });
905
+ }
906
+
907
+ // ---------------------------------------------------------------------------
908
+ // Test sequence
909
+ // ---------------------------------------------------------------------------
910
+
911
+ console.log('\n=== live board HTTP+SSE smoke test ===');
912
+ console.log(`target: ${BASE}`);
913
+ console.log(`card pattern: ${CARD_PATTERN}`);
914
+
915
+ const serverProc = await startServer(PORT);
916
+ let boardSseClient = null;
917
+ let chatSseClient = null;
918
+ let chatSseClientId = '';
919
+
920
+ try {
921
+ // ── T0: streaming SSE connect, upsert fixtures, wait for initial completion ──
922
+
923
+ // Register the 'live' board via POST (v8 runtime requires explicit registration)
924
+ const regRes = await httpJson('POST', `http://127.0.0.1:${PORT}/api/boards`, { id: BOARD_ID, label: 'Live' });
925
+ assert(regRes.status === 200 || regRes.status === 201 || regRes.status === 409,
926
+ `POST /api/boards returned ${regRes.status}: ${JSON.stringify(regRes.data)}`);
927
+ console.log(`[setup] board '${BOARD_ID}' registered (${regRes.status})`);
928
+
929
+ console.log('\n=== T0 Step 1: start SSE client (board expected empty) ===');
930
+ const sseClientId = `server-http-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
931
+ const sseUrl = `${BASE}/sse?clientId=${encodeURIComponent(sseClientId)}`;
932
+ boardSseClient = startSseClient(sseUrl, applyFrame);
933
+ // Give the streaming endpoint a moment to deliver the initial (empty) snapshot.
934
+ await wait(500);
935
+ console.log('[T0.1] SSE client connected');
936
+
937
+ console.log('\n=== T0 Step 2: upsert 3 cardT-* fixtures ===');
938
+ for (const fileName of T0_CARD_FILES) {
939
+ const cardJson = JSON.parse(fs.readFileSync(path.join(CARDS_DIR, fileName), 'utf-8'));
940
+ const cardId = cardJson?.id;
941
+ assert(typeof cardId === 'string' && cardId.length > 0, `fixture ${fileName} missing id`);
942
+ const upsertRes = await httpMcp('manage.upsert-card', {
943
+ card_id: cardId,
944
+ candidate_card_content: cardJson,
945
+ });
946
+ assert(upsertRes.status === 200, `manage.upsert-card(${cardId}) returned ${upsertRes.status}: ${JSON.stringify(upsertRes.data)}`);
947
+ assert(upsertRes.data?.status === 'success', `manage.upsert-card(${cardId}) failed: ${JSON.stringify(upsertRes.data)}`);
948
+ console.log(`[T0.2] upserted ${cardId}`);
949
+ }
950
+
951
+ console.log('\n=== T0 Step 3: wait for all 3 cards to complete via SSE ===');
952
+ const t0Summary = await waitUntil(() => {
953
+ const s = NS.statusSummary;
954
+ if (s && s.card_count === 3 && s.completed === 3) return s;
955
+ return false;
956
+ }, 60_000, 'T0 initial completion (3 cards)');
957
+ assert(t0Summary.failed === 0, `T0 expected failed=0, got ${t0Summary.failed}`);
958
+ console.log(`[T0.3] completed: ${JSON.stringify(t0Summary)}`);
959
+
960
+ console.log('\n=== T0 Step 4: board-status cross-check ===');
961
+ const statusMcpRes = await httpMcp('inspect.board-runtime-status', {});
962
+ assert(statusMcpRes.status === 200, `inspect.board-runtime-status returned ${statusMcpRes.status}`);
963
+ assert(statusMcpRes.data?.status === 'success', `inspect.board-runtime-status failed: ${JSON.stringify(statusMcpRes.data)}`);
964
+ const mcpSummary = statusMcpRes.data?.data?.summary;
965
+ assert(mcpSummary, 'summary missing from inspect.board-runtime-status');
966
+ assert(mcpSummary.card_count === T0_EXPECTED_CARD_IDS.length,
967
+ `expected card_count=${T0_EXPECTED_CARD_IDS.length}, got ${mcpSummary.card_count}`);
968
+ assert(mcpSummary.completed === mcpSummary.card_count, `not all complete: ${JSON.stringify(mcpSummary)}`);
969
+ console.log(`[T0.4] board-status: ${JSON.stringify(mcpSummary)}`);
970
+
971
+ // Verify computed_values arrived for portfolio-value card
972
+ const t0Positions = NS.computedValues['card-portfolio-value']?.positions;
973
+ assert(Array.isArray(t0Positions) && t0Positions.length > 0, 'T0 positions missing from computed_values');
974
+ console.log(`[T0] ok: ${t0Positions.length} positions computed`);
975
+
976
+ // ── T1: PATCH holdings (+1 row), verify recomputation ──
977
+ if (skipT1) {
978
+ console.log('\n=== T1: skipped (--skip-t1) ===');
979
+ } else {
980
+ console.log('\n=== T1: local mutation + manage.upsert-card (+1 row) ===');
981
+
982
+ // Read the live card document via inspect.card-definition-and-runtime before preparing the upsert payload.
983
+ const portfolioCardRes = await httpMcp('inspect.card-definition-and-runtime', { card_id: 'card-portfolio' });
984
+ assert(portfolioCardRes.status === 200, `inspect.card-definition-and-runtime returned ${portfolioCardRes.status}`);
985
+ assert(portfolioCardRes.data?.status === 'success', `inspect.card-definition-and-runtime failed: ${JSON.stringify(portfolioCardRes.data)}`);
986
+ const existingCard = portfolioCardRes.data?.data?.card_definition_and_static_data ?? null;
987
+ const existingHoldings = existingCard?.card_data?.holdings;
988
+ assert(Array.isArray(existingHoldings), 'card-portfolio.card_data.holdings missing');
989
+ const t0HoldingsCount = existingHoldings.length;
990
+ const t0PositionsCount = t0Positions.length;
991
+
992
+ // Pick a ticker not already in holdings
993
+ const candidates = ['AAPL', 'MSFT', 'AMZN', 'TSLA', 'META', 'GOOG', 'NVDA', 'NFLX', 'INTC', 'AMD',
994
+ 'IBM', 'ORCL', 'ADBE', 'CRM', 'QCOM'];
995
+ const existingTickers = new Set(existingHoldings.map(r => r.ticker));
996
+ const available = candidates.filter(t => !existingTickers.has(t));
997
+ assert(available.length > 0, 'No available ticker to add');
998
+ const newTicker = available[0];
999
+
1000
+ const newHoldings = [...existingHoldings, { ticker: newTicker, quantity: 1, cost_basis: 100 }];
1001
+ const nextCard = {
1002
+ ...existingCard,
1003
+ card_data: {
1004
+ ...(existingCard?.card_data || {}),
1005
+ holdings: newHoldings,
1006
+ },
1007
+ };
1008
+ const upsertRes = await httpMcp('manage.upsert-card', {
1009
+ card_id: 'card-portfolio',
1010
+ candidate_card_content: nextCard,
1011
+ });
1012
+ assert(upsertRes.status === 200, `manage.upsert-card returned ${upsertRes.status}`);
1013
+ assert(upsertRes.data?.status === 'success', `manage.upsert-card failed: ${JSON.stringify(upsertRes.data)}`);
1014
+
1015
+ // Wait for re-completion after the upsert triggers a new cycle
1016
+ NS.statusSummary = null;
1017
+ await new Promise(r => setTimeout(r, 4000));
1018
+ const t1Summary = await waitForAllCompleted(30_000, 'T1 holdings upsert');
1019
+ assert(t1Summary.failed === 0, `T1 failed=${t1Summary.failed}`);
1020
+
1021
+ // Verify holdings +1 from the live card document after upsert.
1022
+ const t1PortfolioRes = await httpMcp('inspect.card-definition-and-runtime', { card_id: 'card-portfolio' });
1023
+ assert(t1PortfolioRes.status === 200, `inspect.card-definition-and-runtime after upsert returned ${t1PortfolioRes.status}`);
1024
+ assert(t1PortfolioRes.data?.status === 'success', `inspect.card-definition-and-runtime after upsert failed: ${JSON.stringify(t1PortfolioRes.data)}`);
1025
+ const afterHoldings = t1PortfolioRes.data?.data?.card_definition_and_static_data?.card_data?.holdings;
1026
+ const afterHoldingsCount = Array.isArray(afterHoldings) ? afterHoldings.length : 0;
1027
+
1028
+ // Verify positions +1 from computed_values captured via SSE
1029
+ const afterPositions = NS.computedValues['card-portfolio-value']?.positions;
1030
+ const afterPositionsCount = Array.isArray(afterPositions) ? afterPositions.length : 0;
1031
+
1032
+ assert(afterHoldingsCount === t0HoldingsCount + 1,
1033
+ `Expected holdings rows +1 (before=${t0HoldingsCount}, after=${afterHoldingsCount})`);
1034
+ assert(afterPositionsCount === t0PositionsCount + 1,
1035
+ `Expected positions rows +1 (before=${t0PositionsCount}, after=${afterPositionsCount})`);
1036
+ console.log(`[T1] ok: holdings ${t0HoldingsCount}->${afterHoldingsCount}, ` +
1037
+ `positions ${t0PositionsCount}->${afterPositionsCount}, added=${newTicker}`);
1038
+ }
1039
+
1040
+ // ── T2: plain file upload API + card_data.files + download roundtrip ──
1041
+ if (skipT2) {
1042
+ console.log('\n=== T2: skipped (--skip-t2) ===');
1043
+ } else {
1044
+ console.log('\n=== T2: MCP file upload -> card_data.files -> download ===');
1045
+ const t2CardBefore = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
1046
+ assert(t2CardBefore.status === 200, `T2 pre card read returned ${t2CardBefore.status}`);
1047
+ const t2FilesBefore = Array.isArray(t2CardBefore.data?.data?.[0]?.card_data?.files)
1048
+ ? t2CardBefore.data.data[0].card_data.files
1049
+ : [];
1050
+ const t2BeforeCount = t2FilesBefore.length;
1051
+
1052
+ const t2UploadText = `plain-file-upload-${Date.now()}`;
1053
+ const t2UploadName = 't2-upload.txt';
1054
+ const t2UploadRes = await httpMcpControlplane('manage.upload-card-file', {
1055
+ board_id: BOARD_ID,
1056
+ card_id: T2_FILE_CARD_ID,
1057
+ file_name: t2UploadName,
1058
+ content_type: 'text/plain; charset=utf-8',
1059
+ base64: Buffer.from(t2UploadText, 'utf-8').toString('base64'),
1060
+ });
1061
+ assert(t2UploadRes.status === 200, `T2 file upload returned ${t2UploadRes.status}`);
1062
+ const t2UploadedFile = t2UploadRes.data?.data?.file;
1063
+ assert(t2UploadedFile && typeof t2UploadedFile === 'object', 'T2 upload response missing file metadata');
1064
+ assert(String(t2UploadedFile?.name || '') === t2UploadName, 'T2 uploaded file name mismatch');
1065
+
1066
+ const t2CardAfter = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
1067
+ assert(t2CardAfter.status === 200, `T2 post card read returned ${t2CardAfter.status}`);
1068
+ const t2FilesAfter = Array.isArray(t2CardAfter.data?.data?.[0]?.card_data?.files)
1069
+ ? t2CardAfter.data.data[0].card_data.files
1070
+ : [];
1071
+ assert(t2FilesAfter.length === t2BeforeCount + 1, `T2 expected files +1 (before=${t2BeforeCount}, after=${t2FilesAfter.length})`);
1072
+
1073
+ const t2FileIndex = t2FilesAfter.findIndex((f) => String(f?.stored_name || '') === String(t2UploadedFile?.stored_name || ''));
1074
+ assert(t2FileIndex >= 0, 'T2 uploaded file metadata not found in card_data.files');
1075
+
1076
+ const t2DownloadRes = await httpGetRaw(
1077
+ `${BASE}/cards/${T2_FILE_CARD_ID}/files/${t2FileIndex}?sn=${encodeURIComponent(String(t2UploadedFile?.stored_name || ''))}`,
1078
+ );
1079
+ assert(t2DownloadRes.status === 200, `T2 file download returned ${t2DownloadRes.status}`);
1080
+ const t2DownloadedText = t2DownloadRes.body.toString('utf-8');
1081
+ assert(t2DownloadedText === t2UploadText, 'T2 downloaded content mismatch');
1082
+ console.log('[T2] ok: card_data.files updated and file download endpoint returned exact bytes');
1083
+
1084
+ console.log('\n=== T2a: MCP controlplane file upload -> MCP raw file download ===');
1085
+ const t2aCardBefore = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
1086
+ assert(t2aCardBefore.status === 200, `T2a pre card read returned ${t2aCardBefore.status}`);
1087
+ assert(t2aCardBefore.data?.status === 'success', `T2a pre card read failed: ${JSON.stringify(t2aCardBefore.data)}`);
1088
+ const t2aCardBeforeObj = Array.isArray(t2aCardBefore.data?.data) ? t2aCardBefore.data.data[0] : null;
1089
+ const t2aFilesBefore = Array.isArray(t2aCardBeforeObj?.card_data?.files) ? t2aCardBeforeObj.card_data.files : [];
1090
+ const t2aBeforeCount = t2aFilesBefore.length;
1091
+
1092
+ const t2aUploadText = `mcp-file-upload-${Date.now()}`;
1093
+ const t2aUploadName = 't2a-upload.txt';
1094
+ const t2aUploadRes = await httpMcpControlplane('manage.upload-card-file', {
1095
+ board_id: BOARD_ID,
1096
+ card_id: T2_FILE_CARD_ID,
1097
+ file_name: t2aUploadName,
1098
+ content_type: 'text/plain; charset=utf-8',
1099
+ text: t2aUploadText,
1100
+ });
1101
+ assert(t2aUploadRes.status === 200, `T2a file upload returned ${t2aUploadRes.status}`);
1102
+ assert(t2aUploadRes.data?.status === 'success', `T2a file upload failed: ${JSON.stringify(t2aUploadRes.data)}`);
1103
+ const t2aUploadedFile = t2aUploadRes.data?.data?.file;
1104
+ assert(t2aUploadedFile && typeof t2aUploadedFile === 'object', 'T2a upload response missing file metadata');
1105
+ assert(String(t2aUploadedFile?.name || '') === t2aUploadName, 'T2a uploaded file name mismatch');
1106
+
1107
+ const t2aCardAfter = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
1108
+ assert(t2aCardAfter.status === 200, `T2a post card read returned ${t2aCardAfter.status}`);
1109
+ assert(t2aCardAfter.data?.status === 'success', `T2a post card read failed: ${JSON.stringify(t2aCardAfter.data)}`);
1110
+ const t2aCardAfterObj = Array.isArray(t2aCardAfter.data?.data) ? t2aCardAfter.data.data[0] : null;
1111
+ const t2aFilesAfter = Array.isArray(t2aCardAfterObj?.card_data?.files) ? t2aCardAfterObj.card_data.files : [];
1112
+ assert(t2aFilesAfter.length === t2aBeforeCount + 1, `T2a expected files +1 (before=${t2aBeforeCount}, after=${t2aFilesAfter.length})`);
1113
+
1114
+ const t2aFileIndex = t2aFilesAfter.findIndex((f) => String(f?.stored_name || '') === String(t2aUploadedFile?.stored_name || ''));
1115
+ assert(t2aFileIndex >= 0, 'T2a uploaded file metadata not found in card_data.files');
1116
+
1117
+ const t2aDownloadRes = await httpMcpRaw('inspect.file-contents', {
1118
+ card_id: T2_FILE_CARD_ID,
1119
+ file_idx: t2aFileIndex,
1120
+ });
1121
+ assert(t2aDownloadRes.status === 200, `T2a file download returned ${t2aDownloadRes.status}`);
1122
+ const t2aDownloadedText = t2aDownloadRes.body.toString('utf-8');
1123
+ assert(t2aDownloadedText === t2aUploadText, 'T2a downloaded content mismatch');
1124
+ console.log('[T2a] ok: mcp-controlplane upload and mcp-raw download returned exact bytes');
1125
+ }
1126
+
1127
+ // ── T3*: chat protocol over API + SSE ──
1128
+ {
1129
+ if (skipT3) {
1130
+ console.log('\n=== T3: skipped (--skip-t3) ===');
1131
+ } else {
1132
+ console.log(`\n[${new Date().toISOString()}] === T3: probe chat protocol (SSE lifecycle) ===`);
1133
+ const t3Dbg = (msg) => console.log(`[T3.DBG ${new Date().toISOString()}] ${msg}`);
1134
+ t3Dbg('step 1: creating chat SSE client');
1135
+ chatSseClientId = `chat-proto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1136
+ chatSseClient = startSseClient(`${BASE}/sse?clientId=${encodeURIComponent(chatSseClientId)}`, (payload) => {
1137
+ captureChatEvents(payload, CHAT_CARD_ID);
1138
+ });
1139
+ await new Promise((r) => setTimeout(r, 400));
1140
+ t3Dbg(`step 1: chat SSE client ready (clientId=${chatSseClientId})`);
1141
+
1142
+ t3Dbg('step 2: subscribing chat SSE client');
1143
+ const subRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/chats/subscribe-sse`, { clientId: chatSseClientId });
1144
+ t3Dbg(`step 2: subscribe returned status=${subRes.status}`);
1145
+ assert(subRes.status === 200, `chat subscribe returned ${subRes.status}`);
1146
+
1147
+ t3Dbg('step 3: fetching pre-chat transcript');
1148
+ const t2Before = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1149
+ t3Dbg(`step 3: pre-chat fetch returned status=${t2Before.status}`);
1150
+ assert(t2Before.status === 200, `T3 pre chats returned ${t2Before.status}`);
1151
+ const t2BeforeMessages = Array.isArray(t2Before.data?.data?.messages) ? t2Before.data.data.messages : [];
1152
+ const t2BeforeCount = t2BeforeMessages.length;
1153
+ const t2EventStart = NS.chatEvents.length;
1154
+ const t2ProbePrompt = `Probe protocol validation ${Date.now()}`;
1155
+ t3Dbg(`step 3: beforeCount=${t2BeforeCount}, eventStart=${t2EventStart}`);
1156
+
1157
+ const t3TurnId = randomTurnId();
1158
+ t3Dbg(`step 4: posting probe chat-send (turn-id=${t3TurnId})`);
1159
+ const t2SendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1160
+ tool: 'chat-send',
1161
+ args: {
1162
+ card_id: CHAT_CARD_ID,
1163
+ payload: {
1164
+ text: `${ECHO_PROBE_MARKER}${t2ProbePrompt}${ECHO_PROBE_MARKER}`,
1165
+ 'turn-id': t3TurnId,
1166
+ },
1167
+ },
1168
+ });
1169
+ t3Dbg(`step 4: chat-send returned status=${t2SendRes.status}`);
1170
+ assert(t2SendRes.status === 200, `T3 chat-send returned ${t2SendRes.status}`);
1171
+
1172
+ t3Dbg('step 5: waiting for ordered probe lifecycle on chat SSE');
1173
+ let t2Lifecycle;
1174
+ try {
1175
+ t2Lifecycle = await waitForChatPredicate((events) => {
1176
+ return matchOrderedProbeLifecycle(events.slice(t2EventStart), {
1177
+ beforeCount: t2BeforeCount,
1178
+ beforeProcessing: false,
1179
+ prompt: t2ProbePrompt,
1180
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1181
+ });
1182
+ }, 45_000, 'T3 ordered lifecycle');
1183
+ } catch (error) {
1184
+ t3Dbg(`step 5: lifecycle timeout; events=${JSON.stringify(NS.chatEvents.slice(t2EventStart), null, 2)}`);
1185
+ throw error;
1186
+ }
1187
+ t3Dbg('step 5: ordered lifecycle observed');
1188
+ assert(!!t2Lifecycle, 'T3 ordered lifecycle not observed');
1189
+
1190
+ t3Dbg('step 6: fetching post-chat transcript');
1191
+ const t2After = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1192
+ t3Dbg(`step 6: post-chat fetch returned status=${t2After.status}`);
1193
+ assert(t2After.status === 200, `T3 post chats returned ${t2After.status}`);
1194
+ const t2AfterMessages = Array.isArray(t2After.data?.data?.messages) ? t2After.data.data.messages : [];
1195
+ const t2NewMessages = t2AfterMessages.slice(t2BeforeCount);
1196
+ t3Dbg(`step 6: validating ${t2NewMessages.length} new messages`);
1197
+ assert(t2NewMessages.length >= 2, `T3 expected at least 2 new chat messages, got ${t2NewMessages.length}`);
1198
+ const t3McpAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3TurnId });
1199
+ const t3McpAfterData = expectMcpSuccess(t3McpAfter, 'T3 MCP post chats');
1200
+ const t3TurnMessages = Array.isArray(t3McpAfterData?.messages) ? t3McpAfterData.messages : [];
1201
+ t3Dbg(`step 6: MCP turn messages count=${t3TurnMessages.length}`);
1202
+ assert(t3TurnMessages.length >= 2, `T3 expected at least 2 MCP messages for turn ${t3TurnId}, got ${t3TurnMessages.length}`);
1203
+ for (const msg of t3TurnMessages) {
1204
+ assert(String(msg?.turn || '') === t3TurnId, 'T3 MCP turn id mismatch');
1205
+ }
1206
+ const toComparableTurnMessage = (msg) => ({
1207
+ id: String(msg?.id || ''),
1208
+ role: String(msg?.role || ''),
1209
+ text: String(msg?.text || ''),
1210
+ });
1211
+ const t3HttpComparable = t2NewMessages.map(toComparableTurnMessage);
1212
+ const t3McpComparable = t3TurnMessages.map(toComparableTurnMessage);
1213
+ assert(JSON.stringify(t3HttpComparable) === JSON.stringify(t3McpComparable),
1214
+ `T3 HTTP /chats messages mismatch vs inspect.chat-messages-on-cards: http=${JSON.stringify(t3HttpComparable)} mcp=${JSON.stringify(t3McpComparable)}`);
1215
+ const t2User = t2NewMessages.find((m) => m?.role === 'user');
1216
+ const t2InProgress = t2NewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
1217
+ const t2AssistantMsg = t2NewMessages.find((m) => m?.role === 'assistant');
1218
+ assert(!!t2User && typeof t2User.id === 'string', 'T3 user chat message missing id');
1219
+ assert(String(t2User?.text || '') === t2ProbePrompt, `T3 expected stored user text to equal prompt without probe envelope, got ${JSON.stringify(String(t2User?.text || ''))}`);
1220
+ assert(!String(t2User?.text || '').includes(ECHO_PROBE_MARKER), 'T3 stored user text should not include probe envelope markers');
1221
+ assert(!!t2AssistantMsg && typeof t2AssistantMsg.id === 'string', 'T3 assistant chat message missing id');
1222
+ assert(String(t2AssistantMsg?.text || '').includes(`Echo: ${t2ProbePrompt}`), 'T3 assistant echo file content mismatch');
1223
+ t3Dbg('step 6: all assertions passed');
1224
+ console.log(`[${new Date().toISOString()}] [T3] ok: ordered probe lifecycle observed (user+processing, in-progress, assistant+processing clear)`);
1225
+ }
1226
+
1227
+ // ── T3a: non-probe chat protocol over API + SSE ──
1228
+ // Disabled in the public example unless explicitly requested — requires a
1229
+ // configured Azure Foundry endpoint and agent_id in server-config.json.
1230
+ if (skipT3a) {
1231
+ console.log('\n=== T3a: skipped (--skip-t3a) ===');
1232
+ } else {
1233
+ console.log('\n=== T3a: non-probe chat protocol (expect paris) ===');
1234
+ const t3aDbg = (msg) => console.log(`[T3a.DBG ${new Date().toISOString()}] ${msg}`);
1235
+ t3aDbg('step 1: fetching pre-chat transcript');
1236
+ const t2aBefore = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1237
+ t3aDbg(`step 1: pre-chat fetch returned status=${t2aBefore.status}`);
1238
+ assert(t2aBefore.status === 200, `T3a pre chats returned ${t2aBefore.status}`);
1239
+ const t2aBeforeMessages = Array.isArray(t2aBefore.data?.data?.messages) ? t2aBefore.data.data.messages : [];
1240
+ const t2aBeforeCount = t2aBeforeMessages.length;
1241
+ const t2aPrompt = 'Just answer what is the capital of France. No Fluff. No COmmentary. No Markup Respond in lower case in one word.';
1242
+ t3aDbg(`step 1: beforeCount=${t2aBeforeCount}`);
1243
+
1244
+ const t3aTurnId = randomTurnId();
1245
+ t3aDbg(`step 2: posting non-probe chat-send (turn-id=${t3aTurnId})`);
1246
+ const t2aSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1247
+ tool: 'chat-send',
1248
+ args: {
1249
+ card_id: CHAT_CARD_ID,
1250
+ payload: {
1251
+ text: JSON.stringify({
1252
+ prompt: t2aPrompt,
1253
+ chatTimeoutMs: 180000,
1254
+ }),
1255
+ 'turn-id': t3aTurnId,
1256
+ },
1257
+ },
1258
+ });
1259
+ t3aDbg(`step 2: chat-send returned status=${t2aSendRes.status}`);
1260
+ assert(t2aSendRes.status === 200, `T3a chat-send returned ${t2aSendRes.status}`);
1261
+
1262
+ t3aDbg('step 3: waiting for assistant message containing paris on chat SSE');
1263
+ const t2aAssistant = await waitForChatPredicate((events) => {
1264
+ for (let i = events.length - 1; i >= 0; i -= 1) {
1265
+ const e = events[i];
1266
+ const messages = Array.isArray(e?.messages) ? e.messages : [];
1267
+ const assistant = [...messages].reverse().find((message) => message?.role === 'assistant');
1268
+ if (assistant?.role === 'assistant' && /paris/i.test(String(assistant.text || ''))) return e;
1269
+ }
1270
+ return false;
1271
+ }, 240_000, 'T3a assistant response with paris');
1272
+ t3aDbg('step 3: assistant SSE event observed');
1273
+ assert(!!t2aAssistant, 'T3a assistant response with paris not observed on SSE');
1274
+ const t2aSseLast = t2aAssistant.messages[t2aAssistant.messages.length - 1];
1275
+ t3aDbg(`step 3: assistant SSE text=${JSON.stringify(String(t2aSseLast?.text || '').slice(0, 400))}`);
1276
+
1277
+ t3aDbg('step 4: fetching post-chat transcript');
1278
+ const t2aAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1279
+ t3aDbg(`step 4: post-chat fetch returned status=${t2aAfter.status}`);
1280
+ assert(t2aAfter.status === 200, `T3a post chats returned ${t2aAfter.status}`);
1281
+ const t2aAfterMessages = Array.isArray(t2aAfter.data?.data?.messages) ? t2aAfter.data.data.messages : [];
1282
+ const t2aNewMessages = t2aAfterMessages.slice(t2aBeforeCount);
1283
+ t3aDbg(`step 4: validating ${t2aNewMessages.length} new messages`);
1284
+ assert(t2aNewMessages.length >= 2, `T3a expected at least 2 new chat messages, got ${t2aNewMessages.length}`);
1285
+ const t2aAssistantMsg = [...t2aNewMessages].reverse().find((m) => m?.role === 'assistant');
1286
+ assert(!!t2aAssistantMsg && typeof t2aAssistantMsg.id === 'string', 'T3a assistant chat message missing id');
1287
+ assert(/paris/i.test(String(t2aAssistantMsg?.text || '')), 'T3a assistant file content missing paris');
1288
+ t3aDbg('step 4: all assertions passed');
1289
+ console.log('[T3a] ok: non-probe response contains paris');
1290
+ }
1291
+
1292
+ // ── T3b: probe-echo chat + file upload protocol over API + SSE ──
1293
+ if (skipT3b) {
1294
+ console.log('\n=== T3b: skipped (--skip-t3b) ===');
1295
+ } else {
1296
+ console.log('\n=== T3b: probe-echo chat with file upload protocol ===');
1297
+ const t2bBefore = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1298
+ assert(t2bBefore.status === 200, `T3b pre chats returned ${t2bBefore.status}`);
1299
+ const t2bBeforeMessages = Array.isArray(t2bBefore.data?.data?.messages) ? t2bBefore.data.data.messages : [];
1300
+ const t2bBeforeCount = t2bBeforeMessages.length;
1301
+
1302
+ const t3bTurnId = randomTurnId();
1303
+ const t2bUploadRes = await httpMcpControlplane('manage.add-chat-attachment', {
1304
+ board_id: BOARD_ID,
1305
+ card_id: CHAT_CARD_ID,
1306
+ turn_id: t3bTurnId,
1307
+ file_name: 'q1.txt',
1308
+ content_type: 'text/plain; charset=utf-8',
1309
+ base64: Buffer.from('tokyo', 'utf-8').toString('base64'),
1310
+ });
1311
+ assert(t2bUploadRes.status === 200, `T3b file upload returned ${t2bUploadRes.status}`);
1312
+ const uploadedFile = t2bUploadRes.data?.data?.files?.[0];
1313
+ assert(uploadedFile && typeof uploadedFile === 'object', 'T3b upload response missing file metadata');
1314
+
1315
+ const t2bAfterUpload = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1316
+ assert(t2bAfterUpload.status === 200, `T3b chats after upload returned ${t2bAfterUpload.status}`);
1317
+ const t2bUploadMessages = Array.isArray(t2bAfterUpload.data?.data?.messages) ? t2bAfterUpload.data.data.messages : [];
1318
+ const t2bUploadNewMessages = t2bUploadMessages.slice(t2bBeforeCount);
1319
+ const t2bUploadSystem = t2bUploadNewMessages.find((m) => m?.role === 'system');
1320
+ assert(!!t2bUploadSystem, 'T3b upload protocol missing system chat file');
1321
+ assert(String(t2bUploadSystem?.text || '').toLowerCase().includes('file uploaded:'), 'T3b upload system message does not describe uploaded file');
1322
+
1323
+ const t2bCardAfterUpload = await httpMcp('manage.read-card', { card_id: CHAT_CARD_ID });
1324
+ assert(t2bCardAfterUpload.status === 200, `T3b card read after upload returned ${t2bCardAfterUpload.status}`);
1325
+ const t2bFilesAfterUpload = Array.isArray(t2bCardAfterUpload.data?.data?.[0]?.card_data?.files)
1326
+ ? t2bCardAfterUpload.data.data[0].card_data.files
1327
+ : [];
1328
+ const t2bFileIndex = t2bFilesAfterUpload.findIndex((f) => String(f?.stored_name || '') === String(uploadedFile?.stored_name || ''));
1329
+ assert(t2bFileIndex >= 0, 'T3b uploaded file metadata not found in card_data.files');
1330
+
1331
+ const t2bDownloadRes = await httpGetRaw(
1332
+ `${BASE}/cards/${CHAT_CARD_ID}/files/${t2bFileIndex}?sn=${encodeURIComponent(String(uploadedFile?.stored_name || ''))}`,
1333
+ );
1334
+ assert(t2bDownloadRes.status === 200, `T3b file download returned ${t2bDownloadRes.status}`);
1335
+ assert(t2bDownloadRes.body.toString('utf-8') === 'tokyo', 'T3b downloaded content mismatch');
1336
+
1337
+ const t2bSendBaseline = t2bUploadMessages.length;
1338
+ const t2bEventStart = NS.chatEvents.length;
1339
+
1340
+ const t2bPrompt = `probe echo file-upload validation ${Date.now()}`;
1341
+ const t2bSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1342
+ tool: 'chat-send',
1343
+ args: {
1344
+ card_id: CHAT_CARD_ID,
1345
+ payload: {
1346
+ text: `${ECHO_PROBE_MARKER}${t2bPrompt}${ECHO_PROBE_MARKER}`,
1347
+ 'turn-id': t3bTurnId,
1348
+ },
1349
+ },
1350
+ });
1351
+ assert(t2bSendRes.status === 200, `T3b chat-send returned ${t2bSendRes.status}`);
1352
+
1353
+ const t2bLifecycle = await waitForChatPredicate((events) => {
1354
+ return matchOrderedProbeLifecycle(events.slice(t2bEventStart), {
1355
+ beforeCount: t2bSendBaseline,
1356
+ beforeProcessing: false,
1357
+ prompt: t2bPrompt,
1358
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1359
+ });
1360
+ }, 60_000, 'T3b ordered lifecycle').catch(async (err) => {
1361
+ const t2bEvents = NS.chatEvents.slice(t2bEventStart);
1362
+ const t2bMilestones = deriveProbeLifecycleMilestones(t2bEvents, {
1363
+ beforeCount: t2bSendBaseline,
1364
+ beforeProcessing: false,
1365
+ prompt: t2bPrompt,
1366
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1367
+ });
1368
+ const t2bCurrent = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1369
+ console.error('[T3b.DBG timeout] milestones=', JSON.stringify(t2bMilestones));
1370
+ console.error('[T3b.DBG timeout] events=', JSON.stringify(t2bEvents));
1371
+ console.error('[T3b.DBG timeout] inspect=', JSON.stringify(t2bCurrent?.data ?? null));
1372
+ throw err;
1373
+ });
1374
+ assert(!!t2bLifecycle, 'T3b ordered lifecycle not observed');
1375
+
1376
+ const t2bAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1377
+ assert(t2bAfter.status === 200, `T3b post chats returned ${t2bAfter.status}`);
1378
+ const t2bAfterMessages = Array.isArray(t2bAfter.data?.data?.messages) ? t2bAfter.data.data.messages : [];
1379
+ const t2bNewMessages = t2bAfterMessages.slice(t2bSendBaseline);
1380
+ assert(t2bNewMessages.length >= 2, `T3b expected at least 2 chat messages after send, got ${t2bNewMessages.length}`);
1381
+
1382
+ const t2bUser = t2bNewMessages.find((m) => m?.role === 'user');
1383
+ const t2bInProgress = t2bNewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
1384
+ const t2bAssistantMsg = t2bNewMessages.find((m) => m?.role === 'assistant');
1385
+
1386
+ assert(!!t2bUser && typeof t2bUser.id === 'string', 'T3b missing user chat message notification');
1387
+ assert(!!t2bAssistantMsg && typeof t2bAssistantMsg.id === 'string', 'T3b missing assistant chat message notification');
1388
+ assert(!Array.isArray(t2bUser?.files) || t2bUser.files.length === 0, 'T3b user chat message should remain text-only after add-chat-attachment upload');
1389
+ assert(String(t2bAssistantMsg?.text || '').includes(`Echo: ${t2bPrompt}`), 'T3b assistant probe echo mismatch');
1390
+ console.log('[T3b] ok: add-chat-attachment upload plus text-only chat-send preserved the normal probe lifecycle');
1391
+
1392
+ if (skipT3e) {
1393
+ console.log('\n=== T3e: skipped (--skip-t3e) ===');
1394
+ } else {
1395
+ console.log('\n=== T3e: subscribed chat turn with attachment plus unsubscribed negative case ===');
1396
+ const t3eOtherCardId = `card-t3e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1397
+ const t3eOtherCard = buildChatProbeCard(t3eOtherCardId, 'T3e Chat Probe');
1398
+
1399
+ const t3eUpsertOtherRes = await httpMcp('manage.upsert-card', {
1400
+ card_id: t3eOtherCardId,
1401
+ candidate_card_content: t3eOtherCard,
1402
+ });
1403
+ assert(t3eUpsertOtherRes.status === 200, `T3e manage.upsert-card(${t3eOtherCardId}) returned ${t3eUpsertOtherRes.status}`);
1404
+ assert(t3eUpsertOtherRes.data?.status === 'success', `T3e manage.upsert-card(${t3eOtherCardId}) failed: ${JSON.stringify(t3eUpsertOtherRes.data)}`);
1405
+ await waitUntil(() => {
1406
+ const s = NS.statusSummary;
1407
+ if (s && s.card_count === T0_EXPECTED_CARD_IDS.length + 1) return s;
1408
+ return false;
1409
+ }, 30_000, 'T3e extra chat card visible in board summary');
1410
+
1411
+ try {
1412
+ const t3eBefore = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1413
+ assert(t3eBefore.status === 200, `T3e pre chats returned ${t3eBefore.status}`);
1414
+ const t3eBeforeMessages = Array.isArray(t3eBefore.data?.data?.messages) ? t3eBefore.data.data.messages : [];
1415
+ const t3eBeforeCount = t3eBeforeMessages.length;
1416
+
1417
+ const t3eTurnId = randomTurnId();
1418
+ const t3eUploadRes = await httpMcpControlplane('manage.add-chat-attachment', {
1419
+ board_id: BOARD_ID,
1420
+ card_id: CHAT_CARD_ID,
1421
+ turn_id: t3eTurnId,
1422
+ file_name: 't3e-probe.txt',
1423
+ content_type: 'text/plain; charset=utf-8',
1424
+ text: 'what is the capital of japan',
1425
+ });
1426
+ assert(t3eUploadRes.status === 200, `T3e file upload returned ${t3eUploadRes.status}`);
1427
+ assert(t3eUploadRes.data?.status === 'success', `T3e file upload failed: ${JSON.stringify(t3eUploadRes.data)}`);
1428
+ const t3eUploadedFile = t3eUploadRes.data?.data?.files?.[0];
1429
+ assert(t3eUploadedFile && typeof t3eUploadedFile === 'object', 'T3e upload response missing file metadata');
1430
+ assert(!Object.prototype.hasOwnProperty.call(t3eUploadedFile, 'path'), 'T3e uploaded file metadata should not expose path');
1431
+
1432
+ const t3eCardAfterUpload = await httpMcp('manage.read-card', { card_id: CHAT_CARD_ID });
1433
+ assert(t3eCardAfterUpload.status === 200, `T3e card read after upload returned ${t3eCardAfterUpload.status}`);
1434
+ const t3eStoredFiles = Array.isArray(t3eCardAfterUpload.data?.data?.[0]?.card_data?.files)
1435
+ ? t3eCardAfterUpload.data.data[0].card_data.files
1436
+ : [];
1437
+ const t3eStoredFile = t3eStoredFiles.find((file) => String(file?.stored_name || '') === String(t3eUploadedFile?.stored_name || ''));
1438
+ assert(!!t3eStoredFile, 'T3e stored file metadata missing after upload');
1439
+ assert(t3eStoredFile?.chat === true, 'T3e stored file should be marked as chat-origin');
1440
+ assert(!Object.prototype.hasOwnProperty.call(t3eStoredFile || {}, 'path'), 'T3e stored file metadata should not expose path');
1441
+
1442
+ const t3eUploadMessages = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3eTurnId });
1443
+ assert(t3eUploadMessages.status === 200, `T3e chats after upload returned ${t3eUploadMessages.status}`);
1444
+ const t3eUploadTurnMessages = Array.isArray(t3eUploadMessages.data?.data?.messages) ? t3eUploadMessages.data.data.messages : [];
1445
+ const t3eUploadSystem = t3eUploadTurnMessages.find((message) => message?.role === 'system');
1446
+ assert(!!t3eUploadSystem, 'T3e upload protocol missing system chat message');
1447
+ assert(String(t3eUploadSystem?.text || '').toLowerCase().includes('file uploaded:'), 'T3e upload system message does not describe uploaded file');
1448
+
1449
+ const t3eEventStart = NS.chatEvents.length;
1450
+ const t3eAllNotificationsStart = NS.allChatNotifications.length;
1451
+ const t3ePrompt = `attachment probe ${Date.now()}`;
1452
+ const t3eProbeText = `${ECHO_PROBE_MARKER}${t3ePrompt}${ECHO_PROBE_MARKER}`;
1453
+ const t3eSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1454
+ tool: 'chat-send',
1455
+ args: {
1456
+ card_id: CHAT_CARD_ID,
1457
+ payload: {
1458
+ text: t3eProbeText,
1459
+ 'turn-id': t3eTurnId,
1460
+ },
1461
+ },
1462
+ });
1463
+ assert(t3eSendRes.status === 200, `T3e chat-send returned ${t3eSendRes.status}`);
1464
+
1465
+ const t3eLifecycle = await waitForChatPredicate((events) => {
1466
+ return matchOrderedProbeLifecycle(events.slice(t3eEventStart), {
1467
+ beforeCount: t3eBeforeCount + t3eUploadTurnMessages.length,
1468
+ beforeProcessing: false,
1469
+ prompt: t3ePrompt,
1470
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1471
+ });
1472
+ }, 60_000, 'T3e ordered lifecycle');
1473
+ assert(!!t3eLifecycle, 'T3e ordered lifecycle not observed');
1474
+
1475
+ const t3eAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, turn_id: t3eTurnId });
1476
+ assert(t3eAfter.status === 200, `T3e post chats returned ${t3eAfter.status}`);
1477
+ const t3eFinalMessages = Array.isArray(t3eAfter.data?.data?.messages) ? t3eAfter.data.data.messages : [];
1478
+ const t3eFinalUser = t3eFinalMessages.find((message) => message?.role === 'user');
1479
+ const t3eFinalAssistant = t3eFinalMessages.find((message) => message?.role === 'assistant');
1480
+ assert(!!t3eFinalUser, `T3e final user message missing: ${JSON.stringify(t3eFinalMessages)}`);
1481
+ assert(!!t3eFinalAssistant, `T3e final assistant message missing: ${JSON.stringify(t3eFinalMessages)}`);
1482
+ assert(String(t3eFinalUser?.text || '') === t3ePrompt, `T3e final user text mismatch: ${JSON.stringify(t3eFinalUser)}`);
1483
+ assert(!Array.isArray(t3eFinalUser?.files) || t3eFinalUser.files.length === 0,
1484
+ `T3e final user message should remain text-only after controlplane attachment upload: ${JSON.stringify(t3eFinalUser)}`);
1485
+ assert(String(t3eFinalAssistant?.text || '').includes(`Echo: ${t3ePrompt}`), `T3e final probe reply mismatch: ${JSON.stringify(t3eFinalAssistant)}`);
1486
+
1487
+ const t3eNegativeTurnId = randomTurnId();
1488
+ const t3eNegativeSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1489
+ tool: 'chat-send',
1490
+ args: {
1491
+ card_id: t3eOtherCardId,
1492
+ payload: {
1493
+ text: `${ECHO_PROBE_MARKER}negative unsubscribed ${Date.now()}${ECHO_PROBE_MARKER}`,
1494
+ 'turn-id': t3eNegativeTurnId,
1495
+ },
1496
+ },
1497
+ });
1498
+ assert(t3eNegativeSendRes.status === 200, `T3e negative chat-send returned ${t3eNegativeSendRes.status}`);
1499
+
1500
+ const t3eNegativePersisted = await waitUntilAsync(async () => {
1501
+ const result = await httpMcp('inspect.chat-messages-on-cards', { card_id: t3eOtherCardId, turn_id: t3eNegativeTurnId });
1502
+ if (result.status !== 200) return false;
1503
+ const messages = Array.isArray(result.data?.data?.messages) ? result.data.data.messages : [];
1504
+ return messages.find((message) => message?.role === 'assistant') ? messages : false;
1505
+ }, 60_000, 'T3e negative turn persisted on unsubscribed card');
1506
+ assert(Array.isArray(t3eNegativePersisted), 'T3e negative turn did not persist as expected');
1507
+
1508
+ await wait(1_500);
1509
+ const t3eUnexpectedNotification = NS.allChatNotifications.slice(t3eAllNotificationsStart)
1510
+ .find((event) => event?.cardId === t3eOtherCardId);
1511
+ assert(!t3eUnexpectedNotification,
1512
+ `T3e unsubscribed client unexpectedly received chat notification for ${t3eOtherCardId}: ${JSON.stringify(t3eUnexpectedNotification)}`);
1513
+
1514
+ console.log('[T3e] ok: subscribed client received attachment-bearing turn and unsubscribed card produced no chat SSE notification');
1515
+ } finally {
1516
+ const t3eRemoveOtherRes = await httpMcp('manage.remove-card', { card_id: t3eOtherCardId });
1517
+ assert(t3eRemoveOtherRes.status === 200, `T3e manage.remove-card(${t3eOtherCardId}) returned ${t3eRemoveOtherRes.status}`);
1518
+ assert(t3eRemoveOtherRes.data?.status === 'success', `T3e manage.remove-card(${t3eOtherCardId}) failed: ${JSON.stringify(t3eRemoveOtherRes.data)}`);
1519
+ await waitUntil(() => {
1520
+ const s = NS.statusSummary;
1521
+ if (s && s.card_count === T0_EXPECTED_CARD_IDS.length) return s;
1522
+ return false;
1523
+ }, 30_000, 'T3e cleanup card_count back to 3');
1524
+ }
1525
+ }
1526
+
1527
+ console.log('\n=== T3c: fresh /sse connect hydrates current board state ===');
1528
+ const t3cInspectStatusRes = await httpMcp('inspect.board-runtime-status', {});
1529
+ assert(t3cInspectStatusRes.status === 200, `T3c inspect.board-runtime-status returned ${t3cInspectStatusRes.status}`);
1530
+ assert(t3cInspectStatusRes.data?.status === 'success', `T3c inspect.board-runtime-status failed: ${JSON.stringify(t3cInspectStatusRes.data)}`);
1531
+ const t3cExpectedSummary = t3cInspectStatusRes.data?.data?.summary;
1532
+ assert(t3cExpectedSummary, 'T3c summary missing from inspect.board-runtime-status');
1533
+
1534
+ const t3cExpectedCards = {};
1535
+ for (const cardId of T0_EXPECTED_CARD_IDS) {
1536
+ const t3cInspectCardRes = await httpMcp('inspect.card-definition-and-runtime', { card_id: cardId });
1537
+ assert(t3cInspectCardRes.status === 200, `T3c inspect.card-definition-and-runtime(${cardId}) returned ${t3cInspectCardRes.status}`);
1538
+ assert(t3cInspectCardRes.data?.status === 'success', `T3c inspect.card-definition-and-runtime(${cardId}) failed: ${JSON.stringify(t3cInspectCardRes.data)}`);
1539
+ const t3cInspectCardData = t3cInspectCardRes.data?.data;
1540
+ assert(t3cInspectCardData && typeof t3cInspectCardData === 'object', `T3c inspect.card-definition-and-runtime(${cardId}) missing data`);
1541
+ t3cExpectedCards[cardId] = t3cInspectCardData;
1542
+ }
1543
+
1544
+ const t3cRefreshClientId = `server-http-refresh-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1545
+ const t3cRefreshPayload = await waitForFirstSsePayload(`${BASE}/sse?clientId=${encodeURIComponent(t3cRefreshClientId)}`);
1546
+ assert(t3cRefreshPayload && typeof t3cRefreshPayload === 'object', 'T3c missing refresh SSE payload');
1547
+
1548
+ const t3cCardDefinitions = Array.isArray(t3cRefreshPayload.cardDefinitions) ? t3cRefreshPayload.cardDefinitions : [];
1549
+ const t3cCardIds = t3cCardDefinitions.map((card) => card?.id).filter((id) => typeof id === 'string').sort();
1550
+ assert(JSON.stringify(t3cCardIds) === JSON.stringify(T0_EXPECTED_CARD_IDS),
1551
+ `T3c refreshed SSE cardDefinitions mismatch: ${JSON.stringify(t3cCardIds)}`);
1552
+
1553
+ const t3cStatusSummary = t3cRefreshPayload.statusSnapshot?.summary;
1554
+ assert(t3cStatusSummary, 'T3c refresh SSE payload missing statusSnapshot.summary');
1555
+ assertObjectContains(t3cStatusSummary, t3cExpectedSummary, 'T3c refresh SSE summary');
1556
+
1557
+ const t3cCardRuntimeById = t3cRefreshPayload.cardRuntimeById && typeof t3cRefreshPayload.cardRuntimeById === 'object'
1558
+ ? t3cRefreshPayload.cardRuntimeById
1559
+ : {};
1560
+ for (const card of t3cCardDefinitions) {
1561
+ const cardId = card?.id;
1562
+ if (typeof cardId !== 'string' || !t3cExpectedCards[cardId]) continue;
1563
+ const t3cExpectedCard = t3cExpectedCards[cardId];
1564
+ assert(JSON.stringify(card) === JSON.stringify(t3cExpectedCard.card_definition_and_static_data),
1565
+ `T3c refresh SSE cardDefinitions[${cardId}] mismatch`);
1566
+
1567
+ const t3cHydratedCardRuntime = t3cCardRuntimeById[cardId];
1568
+ assert(t3cHydratedCardRuntime && typeof t3cHydratedCardRuntime === 'object', `T3c refresh SSE payload missing cardRuntimeById.${cardId}`);
1569
+ assert(JSON.stringify(t3cHydratedCardRuntime.card_data || {}) === JSON.stringify(t3cExpectedCard.card_definition_and_static_data?.card_data || {}),
1570
+ `T3c refresh SSE cardRuntimeById.${cardId}.card_data mismatch`);
1571
+ assert(JSON.stringify(t3cHydratedCardRuntime.computed_values || {}) === JSON.stringify(t3cExpectedCard.runtime_data?.computed_values || {}),
1572
+ `T3c refresh SSE cardRuntimeById.${cardId}.computed_values mismatch`);
1573
+ }
1574
+ console.log('[T3c] ok: fresh /sse first payload hydrated the current board state');
1575
+
1576
+ console.log('\n=== TS: one-shot, raw framing, replay, delta ordering, and chat hydration ===');
1577
+ const tsExpectedChatRes = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1578
+ assert(tsExpectedChatRes.status === 200, `TS inspect.chat-messages-on-cards returned ${tsExpectedChatRes.status}`);
1579
+ const tsExpectedChatMessages = normalizeHydratedChatMessages(tsExpectedChatRes.data?.data?.messages || []);
1580
+
1581
+ const tsOneShot = await waitForRawSseFrames({
1582
+ sseUrl: `${BASE}/sse?one-shot`,
1583
+ timeoutMs: 15_000,
1584
+ until: (state) => state.frames.length >= 1,
1585
+ waitForClose: true,
1586
+ });
1587
+ assert(tsOneShot.statusCode === 200, `TS one-shot returned ${tsOneShot.statusCode}`);
1588
+ assert(/text\/event-stream/i.test(readHeaderValue(tsOneShot.headers, 'content-type')),
1589
+ `TS one-shot content-type mismatch: ${readHeaderValue(tsOneShot.headers, 'content-type')}`);
1590
+ assert(tsOneShot.closed === true, 'TS one-shot connection should close after first frame');
1591
+ assert(tsOneShot.frames.length === 1, `TS one-shot expected exactly 1 frame, got ${tsOneShot.frames.length}`);
1592
+ const tsOneShotFrame = tsOneShot.frames[0];
1593
+ assert(/^\d+$/.test(String(tsOneShotFrame.id || '')), `TS one-shot frame missing numeric id: ${JSON.stringify(tsOneShotFrame)}`);
1594
+ assert(tsOneShotFrame.payload && typeof tsOneShotFrame.payload === 'object', 'TS one-shot frame missing JSON payload');
1595
+ const tsOneShotChatState = tsOneShotFrame.payload.cardChatsByCardId?.[CHAT_CARD_ID];
1596
+ assert(tsOneShotChatState && typeof tsOneShotChatState === 'object', `TS one-shot payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1597
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsOneShotChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1598
+ 'TS one-shot cardChatsByCardId hydration mismatch');
1599
+
1600
+ const tsDeltaClientId = `ts-delta-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1601
+ const tsRawFrames = [];
1602
+ const tsDeltaClient = startRawSseClient({
1603
+ sseUrl: `${BASE}/sse?clientId=${encodeURIComponent(tsDeltaClientId)}`,
1604
+ onFrame(frame) {
1605
+ tsRawFrames.push(frame);
1606
+ },
1607
+ });
1608
+
1609
+ try {
1610
+ const tsInitialFrame = await waitUntil(() => tsRawFrames[0] || false, 15_000, 'TS initial raw SSE frame');
1611
+ assert(/^\d+$/.test(String(tsInitialFrame.id || '')), `TS initial streaming frame missing numeric id: ${JSON.stringify(tsInitialFrame)}`);
1612
+ const tsInitialChatState = tsInitialFrame.payload?.cardChatsByCardId?.[CHAT_CARD_ID];
1613
+ assert(tsInitialChatState && typeof tsInitialChatState === 'object', `TS initial streaming payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1614
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsInitialChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1615
+ 'TS initial streaming cardChatsByCardId hydration mismatch');
1616
+
1617
+ const tsTempCards = [
1618
+ buildTsStaticCard(`card-ts-${Date.now()}-a`, 'TS Delta Card A'),
1619
+ buildTsStaticCard(`card-ts-${Date.now()}-b`, 'TS Delta Card B'),
1620
+ ];
1621
+ let tsLastEventId = Number(tsInitialFrame.id);
1622
+
1623
+ for (let idx = 0; idx < tsTempCards.length; idx += 1) {
1624
+ const tsCard = tsTempCards[idx];
1625
+ const tsExpectedCardCount = T0_EXPECTED_CARD_IDS.length + idx + 1;
1626
+ const tsFrameStart = tsRawFrames.length;
1627
+ const tsUpsertRes = await httpMcp('manage.upsert-card', {
1628
+ card_id: tsCard.id,
1629
+ candidate_card_content: tsCard,
1630
+ });
1631
+ assert(tsUpsertRes.status === 200, `TS manage.upsert-card(${tsCard.id}) returned ${tsUpsertRes.status}`);
1632
+ assert(tsUpsertRes.data?.status === 'success', `TS manage.upsert-card(${tsCard.id}) failed: ${JSON.stringify(tsUpsertRes.data)}`);
1633
+ const tsDeltaFrame = await waitUntil(() => {
1634
+ for (const frame of tsRawFrames.slice(tsFrameStart)) {
1635
+ const summary = extractStatusSummaryFromPayload(frame.payload);
1636
+ if (summary?.card_count === tsExpectedCardCount) return frame;
1637
+ }
1638
+ return false;
1639
+ }, 30_000, `TS board delta card_count=${tsExpectedCardCount}`);
1640
+ assert(Number(tsDeltaFrame.id) > tsLastEventId,
1641
+ `TS delta frame id did not increase: prev=${tsLastEventId}, next=${JSON.stringify(tsDeltaFrame.id)}`);
1642
+ tsLastEventId = Number(tsDeltaFrame.id);
1643
+ }
1644
+
1645
+ tsDeltaClient.close();
1646
+ await wait(250);
1647
+
1648
+ const tsReconnect = await waitForRawSseFrames({
1649
+ sseUrl: `${BASE}/sse?clientId=${encodeURIComponent(tsDeltaClientId)}`,
1650
+ headers: { 'Last-Event-ID': String(tsLastEventId) },
1651
+ timeoutMs: 15_000,
1652
+ until: (state) => state.frames.length >= 1,
1653
+ });
1654
+ assert(tsReconnect.statusCode === 200, `TS reconnect returned ${tsReconnect.statusCode}`);
1655
+ assert(/text\/event-stream/i.test(readHeaderValue(tsReconnect.headers, 'content-type')),
1656
+ `TS reconnect content-type mismatch: ${readHeaderValue(tsReconnect.headers, 'content-type')}`);
1657
+ const tsReconnectFrame = tsReconnect.frames[0];
1658
+ assert(Number(tsReconnectFrame.id) > tsLastEventId,
1659
+ `TS reconnect frame id did not advance beyond Last-Event-ID: prev=${tsLastEventId}, next=${JSON.stringify(tsReconnectFrame.id)}`);
1660
+ const tsReconnectPayload = tsReconnectFrame.payload;
1661
+ assert(tsReconnectPayload && typeof tsReconnectPayload === 'object', 'TS reconnect first frame missing JSON payload');
1662
+ const tsReconnectIds = (Array.isArray(tsReconnectPayload.cardDefinitions) ? tsReconnectPayload.cardDefinitions : [])
1663
+ .map((card) => card?.id)
1664
+ .filter((cardId) => typeof cardId === 'string')
1665
+ .sort();
1666
+ const tsExpectedReconnectIds = [...T0_EXPECTED_CARD_IDS, ...tsTempCards.map((card) => card.id)].sort();
1667
+ assert(JSON.stringify(tsReconnectIds) === JSON.stringify(tsExpectedReconnectIds),
1668
+ `TS reconnect snapshot mismatch: expected ${JSON.stringify(tsExpectedReconnectIds)}, got ${JSON.stringify(tsReconnectIds)}`);
1669
+ const tsReconnectChatState = tsReconnectPayload.cardChatsByCardId?.[CHAT_CARD_ID];
1670
+ assert(tsReconnectChatState && typeof tsReconnectChatState === 'object', `TS reconnect payload missing cardChatsByCardId.${CHAT_CARD_ID}`);
1671
+ assert(JSON.stringify(normalizeHydratedChatMessages(tsReconnectChatState.messages)) === JSON.stringify(tsExpectedChatMessages),
1672
+ 'TS reconnect cardChatsByCardId hydration mismatch');
1673
+
1674
+ for (const tsCard of tsTempCards) {
1675
+ const tsRemoveRes = await httpMcp('manage.remove-card', { card_id: tsCard.id });
1676
+ assert(tsRemoveRes.status === 200, `TS manage.remove-card(${tsCard.id}) returned ${tsRemoveRes.status}`);
1677
+ assert(tsRemoveRes.data?.status === 'success', `TS manage.remove-card(${tsCard.id}) failed: ${JSON.stringify(tsRemoveRes.data)}`);
1678
+ }
1679
+ await waitUntil(() => {
1680
+ const summary = NS.statusSummary;
1681
+ if (summary && summary.card_count === T0_EXPECTED_CARD_IDS.length) return summary;
1682
+ return false;
1683
+ }, 30_000, 'TS cleanup card_count back to 3');
1684
+ } finally {
1685
+ tsDeltaClient.close();
1686
+ }
1687
+ console.log('[TS] ok: one-shot framing, event ids, Last-Event-ID reconnect, ordered board deltas, and initial chat hydration verified');
1688
+ }
1689
+
1690
+ // ── T3d: probe-echo chat with one AI-generated attachment ──
1691
+ if (skipT3d) {
1692
+ console.log('\n=== T3d: skipped (--skip-t3d) ===');
1693
+ } else {
1694
+ console.log('\n=== T3d: probe-echo chat with AI-generated attachment ===');
1695
+
1696
+ // Ensure chat SSE subscription is active (T3 may have been skipped)
1697
+ let t3dOwnedSseClient = false;
1698
+ if (!chatSseClient) {
1699
+ chatSseClientId = `chat-proto-t3d-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1700
+ chatSseClient = startSseClient(`${BASE}/sse?clientId=${encodeURIComponent(chatSseClientId)}`, (payload) => {
1701
+ captureChatEvents(payload, CHAT_CARD_ID);
1702
+ });
1703
+ await new Promise((r) => setTimeout(r, 400));
1704
+ const t3dSubRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/chats/subscribe-sse`, { clientId: chatSseClientId });
1705
+ assert(t3dSubRes.status === 200, `T3d chat subscribe returned ${t3dSubRes.status}`);
1706
+ t3dOwnedSseClient = true;
1707
+ }
1708
+
1709
+ const t2dBeforeChats = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1710
+ assert(t2dBeforeChats.status === 200, `T3d pre chats returned ${t2dBeforeChats.status}`);
1711
+ const t2dBeforeMessages = Array.isArray(t2dBeforeChats.data?.data?.messages) ? t2dBeforeChats.data.data.messages : [];
1712
+ const t2dBeforeCount = t2dBeforeMessages.length;
1713
+
1714
+ const t2dBeforeCard = await httpMcp('manage.read-card', { card_id: CHAT_CARD_ID });
1715
+ assert(t2dBeforeCard.status === 200, `T3d pre card returned ${t2dBeforeCard.status}`);
1716
+ const t2dBeforeFiles = Array.isArray(t2dBeforeCard.data?.data?.[0]?.card_data?.files)
1717
+ ? t2dBeforeCard.data.data[0].card_data.files
1718
+ : [];
1719
+
1720
+ const t3dTurnId = randomTurnId();
1721
+ const t2dPrompt = `probe generated attachment validation ${Date.now()}`;
1722
+ const t2dEventStart = NS.chatEvents.length;
1723
+ const t2dSendRes = await httpJson('POST', `${BASE}/mcp-actions`, {
1724
+ tool: 'chat-send',
1725
+ args: {
1726
+ card_id: CHAT_CARD_ID,
1727
+ payload: {
1728
+ text: `${ECHO_PROBE_MARKER}echoattach__ ${t2dPrompt}${ECHO_PROBE_MARKER}`,
1729
+ 'turn-id': t3dTurnId,
1730
+ },
1731
+ },
1732
+ });
1733
+ assert(t2dSendRes.status === 200, `T3d chat-send returned ${t2dSendRes.status}`);
1734
+
1735
+ const t2dLifecycle = await waitForChatPredicate((events) => {
1736
+ return matchOrderedProbeLifecycle(events.slice(t2dEventStart), {
1737
+ beforeCount: t2dBeforeCount,
1738
+ beforeProcessing: false,
1739
+ prompt: t2dPrompt,
1740
+ assistantText: `Echo: ${t2dPrompt}`,
1741
+ inProgressText: PROBE_IN_PROGRESS_TEXT,
1742
+ });
1743
+ }, 60_000, 'T3d ordered lifecycle');
1744
+ assert(!!t2dLifecycle, 'T3d ordered lifecycle not observed');
1745
+
1746
+ const t2dAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, all_turns: true });
1747
+ assert(t2dAfter.status === 200, `T3d post chats returned ${t2dAfter.status}`);
1748
+ const t2dAfterMessages = Array.isArray(t2dAfter.data?.data?.messages) ? t2dAfter.data.data.messages : [];
1749
+ const t2dNewMessages = t2dAfterMessages.slice(t2dBeforeCount);
1750
+ assert(t2dNewMessages.length >= 3, `T3d expected at least 3 chat messages after send, got ${t2dNewMessages.length}`);
1751
+
1752
+ const t2dUser = t2dNewMessages.find((m) => m?.role === 'user');
1753
+ const t2dInProgress = t2dNewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
1754
+ const t2dAiGenerated = t2dNewMessages.find((m) => m?.role === 'system' && /^AI generated:/i.test(String(m?.text || '')));
1755
+ const t2dAssistantMsg = t2dNewMessages.find((m) => m?.role === 'assistant');
1756
+
1757
+ assert(!!t2dUser && typeof t2dUser.id === 'string', 'T3d missing user chat message');
1758
+ assert(!!t2dAiGenerated && typeof t2dAiGenerated.id === 'string', 'T3d missing AI-generated attachment system chat message');
1759
+ assert(/#\d+\s*$/.test(String(t2dAiGenerated?.text || '')), 'T3d AI-generated system message should include merged file index');
1760
+ assert(String(t2dAiGenerated?.turn || '') === t3dTurnId, 'T3d AI-generated system turn id mismatch');
1761
+ assert(!!t2dAssistantMsg && typeof t2dAssistantMsg.id === 'string', 'T3d missing assistant chat message');
1762
+ assert(String(t2dAssistantMsg?.text || '').includes(`Echo: ${t2dPrompt}`), 'T3d assistant content mismatch');
1763
+ assert(String(t2dAssistantMsg?.turn || '') === t3dTurnId, 'T3d assistant turn id mismatch');
1764
+
1765
+ const t2dFileIndexMatch = /#(\d+)\s*$/.exec(String(t2dAiGenerated?.text || ''));
1766
+ assert(!!t2dFileIndexMatch, 'T3d AI-generated message missing file index');
1767
+ const t2dFileIndex = Number.parseInt(t2dFileIndexMatch[1], 10);
1768
+ assert(Number.isInteger(t2dFileIndex) && t2dFileIndex >= 0, 'T3d AI-generated message file index should be non-negative');
1769
+
1770
+ const t2dAfterCard = await httpMcp('manage.read-card', { card_id: CHAT_CARD_ID });
1771
+ assert(t2dAfterCard.status === 200, `T3d post card returned ${t2dAfterCard.status}`);
1772
+ const t2dAfterFiles = Array.isArray(t2dAfterCard.data?.data?.[0]?.card_data?.files)
1773
+ ? t2dAfterCard.data.data[0].card_data.files
1774
+ : [];
1775
+ assert(t2dAfterFiles.length === t2dBeforeFiles.length + 1, `T3d expected exactly one new stored file, got ${t2dAfterFiles.length - t2dBeforeFiles.length}`);
1776
+ const t2dStoredFile = t2dAfterFiles[t2dFileIndex];
1777
+ assert(!!t2dStoredFile, `T3d stored file missing at merged index ${t2dFileIndex}`);
1778
+ assert(t2dStoredFile?.chat === true, 'T3d generated file should be marked as chat-origin');
1779
+ assert(String(t2dStoredFile?.stored_name || '').length > 0, 'T3d generated file stored_name missing');
1780
+ assert(!Object.prototype.hasOwnProperty.call(t2dStoredFile || {}, 'path'), 'T3d stored file metadata should not expose path');
1781
+ console.log('[T3d] ok: probe staged one AI-generated attachment and appended the final reply through the shared flow');
1782
+ }
1783
+
1784
+ if (skipT4) {
1785
+ console.log('\n=== T4: skipped (--skip-t4) ===');
1786
+ } else {
1787
+ console.log('\n=== T4: preflight MCP smoke checks ===');
1788
+
1789
+ const discoverSourceKindsData = expectMcpSuccess(
1790
+ await httpMcp('discover.source-kinds', {}),
1791
+ 'T4 discover.source-kinds',
1792
+ );
1793
+ assert(discoverSourceKindsData && typeof discoverSourceKindsData === 'object', 'T4 discover.source-kinds missing payload');
1794
+ assert(discoverSourceKindsData.sourceKinds && typeof discoverSourceKindsData.sourceKinds === 'object', 'T4 discover.source-kinds missing sourceKinds');
1795
+ const discoveredSourceKinds = Object.keys(discoverSourceKindsData.sourceKinds).sort();
1796
+ assert(
1797
+ JSON.stringify(discoveredSourceKinds) === JSON.stringify(['mock', 'sqlite', 'urls']),
1798
+ `T4 discover.source-kinds mismatch: ${JSON.stringify(discoveredSourceKinds)}`,
1799
+ );
1800
+ console.log('[T4.discover] ok: source kinds match demo task executor');
1801
+
1802
+ const getCardDefinition = (fileName) => {
1803
+ const filePath = path.join(BOARD_DIR, 'cards', fileName);
1804
+ const raw = fs.readFileSync(filePath, 'utf-8');
1805
+ return JSON.parse(raw);
1806
+ };
1807
+
1808
+ const expectPreflightSuccess = (res, label) => {
1809
+ assert(res.status === 200, `${label} returned ${res.status}`);
1810
+ assert(res.data?.status === 'success', `${label} expected status=success, got ${JSON.stringify(res.data)}`);
1811
+ assert(res.data?.data && typeof res.data.data === 'object', `${label} missing success data`);
1812
+ return res.data.data;
1813
+ };
1814
+
1815
+ const portfolioCard = getCardDefinition('cardT-portfolio.json');
1816
+ const marketCard = getCardDefinition('cardT-market-prices.json');
1817
+ const portfolioValueCard = getCardDefinition('cardT-portfolio-value.json');
1818
+ const baseHoldings = Array.isArray(portfolioCard?.card_data?.holdings) ? deepCloneJson(portfolioCard.card_data.holdings) : [];
1819
+
1820
+ const mockQuotes = {
1821
+ quoteResponse: {
1822
+ result: [
1823
+ { symbol: 'AAPL', shortName: 'Apple Inc.', regularMarketPrice: 198.15, regularMarketChange: 2.15, regularMarketChangePercent: 1.10 },
1824
+ { symbol: 'MSFT', shortName: 'Microsoft Corp.', regularMarketPrice: 415.32, regularMarketChange: -1.23, regularMarketChangePercent: -0.30 },
1825
+ { symbol: 'GOOGL', shortName: 'Alphabet Inc.', regularMarketPrice: 174.89, regularMarketChange: 0.89, regularMarketChangePercent: 0.51 },
1826
+ { symbol: 'TSLA', shortName: 'Tesla Inc.', regularMarketPrice: 247.12, regularMarketChange: 5.43, regularMarketChangePercent: 2.25 },
1827
+ ],
1828
+ error: null,
1829
+ },
1830
+ };
1831
+
1832
+ const makePortfolioVariant = (id, extraHolding) => {
1833
+ const card = deepCloneJson(portfolioCard);
1834
+ card.id = id;
1835
+ card.card_data.holdings = [...baseHoldings, extraHolding];
1836
+ return card;
1837
+ };
1838
+
1839
+ const makeMockSourceCard = ({ id, bindTo = 'quotes', secondBindTo = null, includeProjection = false, projectionExpr = '"ok"', missingMock = false }) => {
1840
+ const card = deepCloneJson(marketCard);
1841
+ card.id = id;
1842
+ card.requires = [];
1843
+ card.source_defs = [
1844
+ { bindTo, mock: missingMock ? 'missing-mock-key' : 'quotes' },
1845
+ ...(secondBindTo ? [{ bindTo: secondBindTo, mock: 'quotes' }] : []),
1846
+ ];
1847
+ if (includeProjection) {
1848
+ card.source_defs[0].projections = { passthrough: projectionExpr };
1849
+ } else {
1850
+ delete card.source_defs[0].projections;
1851
+ }
1852
+ delete card.source_defs[0].urls;
1853
+ if (card.source_defs[1]) delete card.source_defs[1].urls;
1854
+ return card;
1855
+ };
1856
+
1857
+ const portfolioVariantA = makePortfolioVariant('card-portfolio-preflight-a', { ticker: 'NVDA', quantity: 7, cost_basis: 121 });
1858
+ const portfolioVariantB = makePortfolioVariant('card-portfolio-preflight-b', { ticker: 'AMD', quantity: 9, cost_basis: 143 });
1859
+ const marketMockSourceCardA = makeMockSourceCard({ id: 'card-market-prices-preflight-source-a' });
1860
+ const marketMockSourceCardB = makeMockSourceCard({ id: 'card-market-prices-preflight-source-b', includeProjection: true });
1861
+ const marketMockSourceCardC = makeMockSourceCard({ id: 'card-market-prices-preflight-source-c', secondBindTo: 'quotesBackup' });
1862
+ const marketMockSourceCardD = makeMockSourceCard({ id: 'card-market-prices-preflight-source-d', bindTo: 'quotesPrimary' });
1863
+ const marketMockSourceCardE = makeMockSourceCard({ id: 'card-market-prices-preflight-source-e', bindTo: 'quotesEcho' });
1864
+ const marketMissingMockCard = makeMockSourceCard({ id: 'card-market-prices-preflight-missing', missingMock: true });
1865
+ const invalidCard = {
1866
+ ...deepCloneJson(marketCard),
1867
+ id: '',
1868
+ source_defs: [{ bindTo: '', mock: 'quotes' }],
1869
+ view: { layout: { kind: 'stack' }, elements: [{ id: 'broken' }] },
1870
+ };
1871
+
1872
+ const validateSuccessCases = [
1873
+ { name: 'portfolio live', card: portfolioCard, expectCardId: 'card-portfolio' },
1874
+ { name: 'market live', card: marketCard, expectCardId: 'card-market-prices' },
1875
+ { name: 'portfolio-value live', card: portfolioValueCard, expectCardId: 'card-portfolio-value' },
1876
+ { name: 'portfolio variant', card: portfolioVariantA, expectCardId: 'card-portfolio-preflight-a' },
1877
+ { name: 'portfolio variant B', card: portfolioVariantB, expectCardId: 'card-portfolio-preflight-b' },
1878
+ ];
1879
+ for (const tc of validateSuccessCases) {
1880
+ const body = expectPreflightSuccess(await httpMcp('preflight.validate-candidate-card-definition', {
1881
+ candidate_card_content: tc.card,
1882
+ }), `T4 validate success (${tc.name})`);
1883
+ assert(body.cardId === tc.expectCardId, `T4 validate ${tc.name} cardId mismatch`);
1884
+ assert(body.isValid === true, `T4 validate ${tc.name} expected isValid=true`);
1885
+ assert(Array.isArray(body.issues) && body.issues.length === 0, `T4 validate ${tc.name} expected no issues`);
1886
+ console.log(`[T4.validate] ok: ${tc.name}`);
1887
+ }
1888
+
1889
+ const validateFailureBody = expectPreflightSuccess(await httpMcp('preflight.validate-candidate-card-definition', {
1890
+ candidate_card_content: invalidCard,
1891
+ }), 'T4 validate failure (invalid card)');
1892
+ assert(validateFailureBody.isValid === false, 'T4 validate invalid card should be invalid');
1893
+ assert(Array.isArray(validateFailureBody.issues) && validateFailureBody.issues.length > 0, 'T4 validate invalid card should report issues');
1894
+ console.log('[T4.validate] ok: invalid card reports validation issues');
1895
+
1896
+ const materializeSuccessCases = [
1897
+ {
1898
+ name: 'portfolio live empty mocks',
1899
+ card: portfolioCard,
1900
+ mockRequires: {},
1901
+ mockFetchedSources: {},
1902
+ verify: (body) => {
1903
+ assert(Array.isArray(body.provides_outputs?.holdings), 'T4 materialize portfolio holdings missing');
1904
+ assert(body.provides_outputs.holdings.length === baseHoldings.length, 'T4 materialize portfolio holdings length mismatch');
1905
+ assert(body.rendered_view?.elements?.[0]?.kind === 'editable-table', 'T4 materialize portfolio rendered_view mismatch');
1906
+ },
1907
+ },
1908
+ {
1909
+ name: 'portfolio variant with extra holding',
1910
+ card: portfolioVariantA,
1911
+ mockRequires: {},
1912
+ mockFetchedSources: {},
1913
+ verify: (body) => {
1914
+ assert(Array.isArray(body.provides_outputs?.holdings), 'T4 materialize portfolio variant holdings missing');
1915
+ assert(body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 materialize portfolio variant holdings length mismatch');
1916
+ },
1917
+ },
1918
+ {
1919
+ name: 'portfolio variant B with extra holding',
1920
+ card: portfolioVariantB,
1921
+ mockRequires: {},
1922
+ mockFetchedSources: {},
1923
+ verify: (body) => {
1924
+ assert(Array.isArray(body.provides_outputs?.holdings), 'T4 materialize portfolio variant B holdings missing');
1925
+ assert(body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 materialize portfolio variant B holdings length mismatch');
1926
+ },
1927
+ },
1928
+ {
1929
+ name: 'portfolio-value live with mock requires',
1930
+ card: portfolioValueCard,
1931
+ mockRequires: { holdings: baseHoldings, quotes: mockQuotes },
1932
+ mockFetchedSources: {},
1933
+ verify: (body) => {
1934
+ assert(Array.isArray(body.computed_values?.positions) && body.computed_values.positions.length > 0, 'T4 materialize portfolio-value positions missing');
1935
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length > 0, 'T4 materialize portfolio-value provides missing');
1936
+ assert(body.rendered_view?.elements?.length === 3, 'T4 materialize portfolio-value rendered_view length mismatch');
1937
+ },
1938
+ },
1939
+ {
1940
+ name: 'portfolio-value subset requires',
1941
+ card: portfolioValueCard,
1942
+ mockRequires: {
1943
+ holdings: baseHoldings.slice(0, 2),
1944
+ quotes: { quoteResponse: { result: mockQuotes.quoteResponse.result.slice(0, 2), error: null } },
1945
+ },
1946
+ mockFetchedSources: {},
1947
+ verify: (body) => {
1948
+ assert(Array.isArray(body.computed_values?.positions) && body.computed_values.positions.length === 2, 'T4 materialize portfolio-value subset positions mismatch');
1949
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length === 2, 'T4 materialize portfolio-value subset provides mismatch');
1950
+ },
1951
+ },
1952
+ ];
1953
+ for (const tc of materializeSuccessCases) {
1954
+ const body = expectPreflightSuccess(await httpMcp('preflight.materialize-candidate-card', {
1955
+ candidate_card_content: tc.card,
1956
+ mock_requires: tc.mockRequires,
1957
+ mock_fetched_sources: tc.mockFetchedSources,
1958
+ }), `T4 materialize success (${tc.name})`);
1959
+ assert(body.ok === true, `T4 materialize ${tc.name} expected ok=true`);
1960
+ assert(Array.isArray(body.errors) && body.errors.length === 0, `T4 materialize ${tc.name} expected no errors`);
1961
+ tc.verify(body);
1962
+ console.log(`[T4.materialize] ok: ${tc.name}`);
1963
+ }
1964
+
1965
+ const materializeFailureRes = await httpMcp('preflight.materialize-candidate-card', {
1966
+ candidate_card_content: portfolioCard,
1967
+ });
1968
+ assert(materializeFailureRes.status === 400, `T4 materialize missing args expected 400, got ${materializeFailureRes.status}`);
1969
+ assert(materializeFailureRes.data?.error === 'MCP tool requires mock_requires', 'T4 materialize missing args error mismatch');
1970
+ console.log('[T4.materialize] ok: missing required mocks is rejected');
1971
+
1972
+ const probeSuccessCases = [
1973
+ { name: 'single mock source base', card: marketMockSourceCardA, sourceIdx: 0, bindTo: 'quotes', mockProjections: {} },
1974
+ { name: 'two mock sources first entry', card: marketMockSourceCardC, sourceIdx: 0, bindTo: 'quotes', mockProjections: {} },
1975
+ { name: 'two mock sources second entry', card: marketMockSourceCardC, sourceIdx: 1, bindTo: 'quotesBackup', mockProjections: {} },
1976
+ { name: 'single mock source alternate bindTo', card: marketMockSourceCardD, sourceIdx: 0, bindTo: 'quotesPrimary', mockProjections: {} },
1977
+ ];
1978
+ for (const tc of probeSuccessCases) {
1979
+ const body = expectPreflightSuccess(await httpMcp('preflight.probe-single-source-in-candidate-card', {
1980
+ candidate_card_content: tc.card,
1981
+ source_idx: tc.sourceIdx,
1982
+ mock_projections: tc.mockProjections,
1983
+ }), `T4 probe success (${tc.name})`);
1984
+ assert(body.bindTo === tc.bindTo, `T4 probe ${tc.name} bindTo mismatch`);
1985
+ assert(body.reachable === true, `T4 probe ${tc.name} expected reachable=true`);
1986
+ assert(typeof body.latencyMs === 'number', `T4 probe ${tc.name} expected numeric latencyMs`);
1987
+ console.log(`[T4.probe] ok: ${tc.name}`);
1988
+ }
1989
+
1990
+ const probeFailureRes = await httpMcp('preflight.probe-single-source-in-candidate-card', {
1991
+ candidate_card_content: marketMissingMockCard,
1992
+ source_idx: 0,
1993
+ mock_projections: {},
1994
+ });
1995
+ assert(probeFailureRes.status === 400, `T4 probe failure expected 400, got ${probeFailureRes.status}`);
1996
+ assert(typeof probeFailureRes.data?.error === 'string' && probeFailureRes.data.error.length > 0, 'T4 probe failure expected error text');
1997
+ console.log('[T4.probe] ok: missing mock source returns HTTP error');
1998
+
1999
+ const runSourceSuccessCases = [
2000
+ { name: 'single mock source base', card: marketMockSourceCardA, sourceIdx: 0, bindTo: 'quotes' },
2001
+ { name: 'two mock sources first entry', card: marketMockSourceCardC, sourceIdx: 0, bindTo: 'quotes' },
2002
+ { name: 'two mock sources second entry', card: marketMockSourceCardC, sourceIdx: 1, bindTo: 'quotesBackup' },
2003
+ { name: 'single mock source alternate bindTo', card: marketMockSourceCardE, sourceIdx: 0, bindTo: 'quotesEcho' },
2004
+ ];
2005
+ for (const tc of runSourceSuccessCases) {
2006
+ const body = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-candidate-card', {
2007
+ candidate_card_content: tc.card,
2008
+ source_idx: tc.sourceIdx,
2009
+ mock_projections: {},
2010
+ }), `T4 run-source success (${tc.name})`);
2011
+ assert(body.bindTo === tc.bindTo, `T4 run-source ${tc.name} bindTo mismatch`);
2012
+ assert(body.ok === true, `T4 run-source ${tc.name} expected ok=true`);
2013
+ assert(Array.isArray(body.issues) && body.issues.length === 0, `T4 run-source ${tc.name} expected no issues`);
2014
+ assert(Array.isArray(body.result?.quoteResponse?.result) && body.result.quoteResponse.result.length > 0, `T4 run-source ${tc.name} result shape mismatch`);
2015
+ console.log(`[T4.run-source] ok: ${tc.name}`);
2016
+ }
2017
+
2018
+ const runSourceFailureBody = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-candidate-card', {
2019
+ candidate_card_content: marketMissingMockCard,
2020
+ source_idx: 0,
2021
+ mock_projections: {},
2022
+ }), 'T4 run-source failure (missing mock source)');
2023
+ assert(runSourceFailureBody.ok === false, 'T4 run-source missing mock should set ok=false');
2024
+ assert(Array.isArray(runSourceFailureBody.issues) && runSourceFailureBody.issues.length > 0, 'T4 run-source missing mock should report issues');
2025
+ console.log('[T4.run-source] ok: missing mock source returns ok=false with issues');
2026
+
2027
+ const liveRunCardId = String(marketCard?.id || 'card-market-prices');
2028
+
2029
+ const liveRunSourceBody = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-live-card', {
2030
+ card_id: liveRunCardId,
2031
+ source_idx: 0,
2032
+ mock_requires: { holdings: baseHoldings },
2033
+ }), 'T4 run-source live card success');
2034
+ assert(liveRunSourceBody.bindTo === 'quotes', 'T4 run-source live card bindTo mismatch');
2035
+ assert(liveRunSourceBody.ok === true, 'T4 run-source live card expected ok=true');
2036
+ assert(Array.isArray(liveRunSourceBody.issues) && liveRunSourceBody.issues.length === 0, 'T4 run-source live card expected no issues');
2037
+ assert(Array.isArray(liveRunSourceBody.result) && liveRunSourceBody.result.length === baseHoldings.length, 'T4 run-source live card result shape mismatch');
2038
+ console.log('[T4.run-source-live] ok: live card source run returns candidate-compatible shape');
2039
+
2040
+ const liveRunRequiresBody = expectPreflightSuccess(await httpMcp('preflight.run-single-source-in-live-card', {
2041
+ card_id: liveRunCardId,
2042
+ source_idx: 0,
2043
+ mock_requires: { holdings: baseHoldings },
2044
+ }), 'T4 run-source live card uses mock_requires in projections');
2045
+ assert(liveRunRequiresBody.bindTo === 'quotes', 'T4 run-source live card requires bindTo mismatch');
2046
+ assert(liveRunRequiresBody.ok === true, 'T4 run-source live card requires expected ok=true');
2047
+ assert(Array.isArray(liveRunRequiresBody.issues) && liveRunRequiresBody.issues.length === 0, 'T4 run-source live card requires expected no issues');
2048
+ assert(Array.isArray(liveRunRequiresBody.result) && liveRunRequiresBody.result.length === baseHoldings.length, 'T4 run-source live card requires result shape mismatch');
2049
+ console.log('[T4.run-source-live] ok: non-empty mock_requires is consumed via source projections');
2050
+
2051
+ const liveRunOutOfRangeRes = await httpMcp('preflight.run-single-source-in-live-card', {
2052
+ card_id: liveRunCardId,
2053
+ source_idx: 9,
2054
+ mock_requires: {},
2055
+ });
2056
+ assert(liveRunOutOfRangeRes.status === 400, `T4 run-source live card out-of-range expected 400, got ${liveRunOutOfRangeRes.status}`);
2057
+ assert(typeof liveRunOutOfRangeRes.data?.error === 'string' && liveRunOutOfRangeRes.data.error.length > 0, 'T4 run-source live card out-of-range expected error text');
2058
+ console.log('[T4.run-source-live] ok: out-of-range source_idx is rejected with HTTP error');
2059
+
2060
+ const liveRunMissingMockRequiresRes = await httpMcp('preflight.run-single-source-in-live-card', {
2061
+ card_id: liveRunCardId,
2062
+ source_idx: 0,
2063
+ });
2064
+ assert(liveRunMissingMockRequiresRes.status === 400, `T4 run-source live card missing mock_requires expected 400, got ${liveRunMissingMockRequiresRes.status}`);
2065
+ assert(liveRunMissingMockRequiresRes.data?.error === 'MCP tool requires mock_requires', 'T4 run-source live card missing mock_requires error mismatch');
2066
+ console.log('[T4.run-source-live] ok: missing mock_requires is rejected');
2067
+
2068
+ const runCycleSuccessCases = [
2069
+ {
2070
+ name: 'portfolio live',
2071
+ card: portfolioCard,
2072
+ mockRequires: {},
2073
+ verify: (body) => {
2074
+ assert(Array.isArray(body.provides_outputs?.holdings) && body.provides_outputs.holdings.length === baseHoldings.length, 'T4 run-cycle portfolio provides mismatch');
2075
+ assert(body.rendered_view?.elements?.[0]?.kind === 'editable-table', 'T4 run-cycle portfolio rendered_view mismatch');
2076
+ },
2077
+ },
2078
+ {
2079
+ name: 'portfolio variant',
2080
+ card: portfolioVariantB,
2081
+ mockRequires: {},
2082
+ verify: (body) => {
2083
+ assert(Array.isArray(body.provides_outputs?.holdings) && body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 run-cycle portfolio variant provides mismatch');
2084
+ },
2085
+ },
2086
+ {
2087
+ name: 'portfolio variant B',
2088
+ card: portfolioVariantB,
2089
+ mockRequires: {},
2090
+ verify: (body) => {
2091
+ assert(Array.isArray(body.provides_outputs?.holdings) && body.provides_outputs.holdings.length === baseHoldings.length + 1, 'T4 run-cycle portfolio variant B provides mismatch');
2092
+ assert(body.rendered_view?.elements?.[0]?.kind === 'editable-table', 'T4 run-cycle portfolio variant B rendered_view mismatch');
2093
+ },
2094
+ },
2095
+ {
2096
+ name: 'portfolio-value with full requires',
2097
+ card: portfolioValueCard,
2098
+ mockRequires: { holdings: baseHoldings, quotes: mockQuotes },
2099
+ verify: (body) => {
2100
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length > 0, 'T4 run-cycle portfolio-value provides mismatch');
2101
+ assert(body.rendered_view?.elements?.length === 3, 'T4 run-cycle portfolio-value rendered_view mismatch');
2102
+ },
2103
+ },
2104
+ {
2105
+ name: 'portfolio-value subset requires',
2106
+ card: portfolioValueCard,
2107
+ mockRequires: {
2108
+ holdings: baseHoldings.slice(0, 2),
2109
+ quotes: { quoteResponse: { result: mockQuotes.quoteResponse.result.slice(0, 2), error: null } },
2110
+ },
2111
+ verify: (body) => {
2112
+ assert(Array.isArray(body.provides_outputs?.positions) && body.provides_outputs.positions.length === 2, 'T4 run-cycle portfolio-value subset length mismatch');
2113
+ },
2114
+ },
2115
+ {
2116
+ name: 'market-prices with live source simulation',
2117
+ card: marketCard,
2118
+ mockRequires: { holdings: baseHoldings.slice(0, 3) },
2119
+ verify: (body) => {
2120
+ const quoteRows = body.provides_outputs?.quotes?.quoteResponse?.result;
2121
+ assert(Array.isArray(quoteRows) && quoteRows.length === 3, 'T4 run-cycle market-prices provides result length mismatch');
2122
+ assert(typeof quoteRows[0]?.symbol === 'string' && quoteRows[0].symbol.length > 0, 'T4 run-cycle market-prices provides symbol missing');
2123
+
2124
+ const resolvedRows = body.rendered_view?.elements?.[0]?.resolved;
2125
+ assert(Array.isArray(resolvedRows) && resolvedRows.length === 3, 'T4 run-cycle market-prices rendered resolved length mismatch');
2126
+ assert(typeof resolvedRows[0]?.ticker === 'string' && resolvedRows[0].ticker.length > 0, 'T4 run-cycle market-prices rendered ticker missing');
2127
+ assert(typeof resolvedRows[0]?.price === 'number', 'T4 run-cycle market-prices rendered price missing');
2128
+ },
2129
+ },
2130
+ ];
2131
+ for (const tc of runCycleSuccessCases) {
2132
+ const body = expectPreflightSuccess(await httpMcp('preflight.run-one-cycle-with-candidate-card', {
2133
+ candidate_card_content: tc.card,
2134
+ mock_requires: tc.mockRequires,
2135
+ }), `T4 run-cycle success (${tc.name})`);
2136
+ assert(body.ok === true, `T4 run-cycle ${tc.name} expected ok=true`);
2137
+ assert(Array.isArray(body.issues) && body.issues.length === 0, `T4 run-cycle ${tc.name} expected no issues`);
2138
+ tc.verify(body);
2139
+ console.log(`[T4.run-cycle] ok: ${tc.name}`);
2140
+ }
2141
+
2142
+ console.log('\n[T4.remove-card] testing manage.remove-card lifecycle');
2143
+
2144
+ const T4_REMOVE_CARD_ID = 'card-t4-remove-test';
2145
+ const T4_REMOVE_CARD_V1 = {
2146
+ id: T4_REMOVE_CARD_ID,
2147
+ card_data: { label: 'v1', color: 'blue' },
2148
+ };
2149
+ const T4_REMOVE_CARD_V2 = {
2150
+ id: T4_REMOVE_CARD_ID,
2151
+ card_data: { label: 'v2', color: 'red' },
2152
+ };
2153
+
2154
+ const t4UpsertV1Res = await httpMcp('manage.upsert-card', {
2155
+ card_id: T4_REMOVE_CARD_ID,
2156
+ candidate_card_content: T4_REMOVE_CARD_V1,
2157
+ });
2158
+ assert(t4UpsertV1Res.status === 200, `T4.remove-card v1 upsert returned ${t4UpsertV1Res.status}`);
2159
+ const t4UpsertV1Data = expectMcpSuccess(t4UpsertV1Res, 'T4.remove-card v1 upsert');
2160
+ assert(t4UpsertV1Data?.board_result?.status === 'success', 'T4.remove-card v1 upsert board_result expected success');
2161
+ console.log('[T4.remove-card] ok: v1 card upserted');
2162
+
2163
+ let t4StatusBeforeRemove = null;
2164
+ for (let attempt = 0; attempt < 3; attempt += 1) {
2165
+ t4StatusBeforeRemove = expectMcpSuccess(
2166
+ await httpMcp('inspect.board-runtime-status', {}),
2167
+ 'T4.remove-card board-runtime-status before remove',
2168
+ );
2169
+ const cards = Array.isArray(t4StatusBeforeRemove?.cards) ? t4StatusBeforeRemove.cards : [];
2170
+ if (cards.some(c => c['card-id'] === T4_REMOVE_CARD_ID)) break;
2171
+ if (attempt < 2) {
2172
+ await new Promise((resolve) => setTimeout(resolve, 1_000));
2173
+ }
2174
+ }
2175
+ const t4CardsBefore = Array.isArray(t4StatusBeforeRemove?.cards) ? t4StatusBeforeRemove.cards : [];
2176
+ assert(t4CardsBefore.some(c => c['card-id'] === T4_REMOVE_CARD_ID), 'T4.remove-card: card not found in board-runtime-status before remove');
2177
+ const t4CardCountBefore = t4StatusBeforeRemove?.summary?.card_count ?? 0;
2178
+ console.log(`[T4.remove-card] ok: board-runtime-status has ${t4CardCountBefore} cards before remove (includes ${T4_REMOVE_CARD_ID})`);
2179
+
2180
+ const t4BoardEventsBefore = NS.boardEvents.length;
2181
+ const t4RemoveRes = await httpMcp('manage.remove-card', { card_id: T4_REMOVE_CARD_ID });
2182
+ assert(t4RemoveRes.status === 200, `T4.remove-card remove returned ${t4RemoveRes.status}`);
2183
+ const t4RemoveData = expectMcpSuccess(t4RemoveRes, 'T4.remove-card remove');
2184
+ assert(t4RemoveData?.board_result?.status === 'success', 'T4.remove-card board_result expected success');
2185
+ assert(t4RemoveData?.store_result?.status === 'success', 'T4.remove-card store_result expected success');
2186
+ console.log('[T4.remove-card] ok: manage.remove-card returned success for both board and store');
2187
+
2188
+ await new Promise((resolve) => setTimeout(resolve, 2_000));
2189
+
2190
+ const t4CardRemovedEvent = await waitUntil(
2191
+ () => NS.boardEvents.slice(t4BoardEventsBefore).find(e => e.kind === 'card_removed' && e.cardId === T4_REMOVE_CARD_ID) || false,
2192
+ 10_000,
2193
+ `card_removed SSE notification for ${T4_REMOVE_CARD_ID}`,
2194
+ );
2195
+ assert(t4CardRemovedEvent && t4CardRemovedEvent.kind === 'card_removed', 'T4.remove-card: card_removed SSE event not received');
2196
+ assert(t4CardRemovedEvent.cardId === T4_REMOVE_CARD_ID, 'T4.remove-card: card_removed SSE cardId mismatch');
2197
+ console.log(`[T4.remove-card] ok: card_removed SSE notification received for ${T4_REMOVE_CARD_ID}`);
2198
+
2199
+ const t4StatusAfterRemove = expectMcpSuccess(
2200
+ await httpMcp('inspect.board-runtime-status', {}),
2201
+ 'T4.remove-card board-runtime-status after remove',
2202
+ );
2203
+ const t4CardsAfter = Array.isArray(t4StatusAfterRemove?.cards) ? t4StatusAfterRemove.cards : [];
2204
+ assert(!t4CardsAfter.some(c => c['card-id'] === T4_REMOVE_CARD_ID), 'T4.remove-card: card still present in board-runtime-status after remove');
2205
+ const t4CardCountAfter = t4StatusAfterRemove?.summary?.card_count ?? 0;
2206
+ assert(t4CardCountAfter === t4CardCountBefore - 1, `T4.remove-card: card_count expected ${t4CardCountBefore - 1}, got ${t4CardCountAfter}`);
2207
+ console.log(`[T4.remove-card] ok: card absent from board-runtime-status after remove (count: ${t4CardCountBefore} → ${t4CardCountAfter})`);
2208
+
2209
+ const t4BoardEventsBeforeV2 = NS.boardEvents.length;
2210
+ const t4UpsertV2Res = await httpMcp('manage.upsert-card', {
2211
+ card_id: T4_REMOVE_CARD_ID,
2212
+ candidate_card_content: T4_REMOVE_CARD_V2,
2213
+ });
2214
+ assert(t4UpsertV2Res.status === 200, `T4.remove-card v2 upsert returned ${t4UpsertV2Res.status}`);
2215
+ const t4UpsertV2Data = expectMcpSuccess(t4UpsertV2Res, 'T4.remove-card v2 upsert');
2216
+ assert(t4UpsertV2Data?.board_result?.status === 'success', 'T4.remove-card v2 upsert board_result expected success');
2217
+ console.log('[T4.remove-card] ok: v2 card upserted under same id');
2218
+
2219
+ const t4CardRefreshedEvent = await waitUntil(
2220
+ () => NS.boardEvents.slice(t4BoardEventsBeforeV2).find(e => e.kind === 'card_refreshed' && e.cardId === T4_REMOVE_CARD_ID) || false,
2221
+ 10_000,
2222
+ `card_refreshed SSE notification for ${T4_REMOVE_CARD_ID} after v2 upsert`,
2223
+ );
2224
+ assert(t4CardRefreshedEvent && t4CardRefreshedEvent.kind === 'card_refreshed', 'T4.remove-card: card_refreshed SSE event not received after v2 upsert');
2225
+ console.log(`[T4.remove-card] ok: card_refreshed SSE notification received for v2 of ${T4_REMOVE_CARD_ID}`);
2226
+
2227
+ await waitForAllCompleted(30_000, 'T4 remove-card re-upsert completion');
2228
+
2229
+ const t4StatusAfterV2 = expectMcpSuccess(
2230
+ await httpMcp('inspect.board-runtime-status', {}),
2231
+ 'T4.remove-card board-runtime-status after v2 upsert',
2232
+ );
2233
+
2234
+ const t4CardsAfterV2 = Array.isArray(t4StatusAfterV2?.cards) ? t4StatusAfterV2.cards : [];
2235
+ assert(t4CardsAfterV2.some(c => c['card-id'] === T4_REMOVE_CARD_ID), 'T4.remove-card: v2 card missing from board-runtime-status');
2236
+ const t4CardCountAfterV2 = t4StatusAfterV2?.summary?.card_count ?? 0;
2237
+ assert(t4CardCountAfterV2 === t4CardCountBefore, `T4.remove-card: card_count after v2 upsert expected ${t4CardCountBefore}, got ${t4CardCountAfterV2}`);
2238
+ console.log('[T4.remove-card] ok: v2 card present in board-runtime-status');
2239
+
2240
+ const t4InspectV2Data = expectMcpSuccess(
2241
+ await httpMcp('inspect.card-definition-and-runtime', { card_id: T4_REMOVE_CARD_ID }),
2242
+ 'T4.remove-card inspect v2',
2243
+ );
2244
+ assert(t4InspectV2Data?.cardId === T4_REMOVE_CARD_ID, 'T4.remove-card inspect v2 cardId mismatch');
2245
+ const t4V2CardData = t4InspectV2Data?.card_definition_and_static_data?.card_data ?? null;
2246
+ assert(t4V2CardData?.label === 'v2', `T4.remove-card inspect v2 label expected "v2", got "${t4V2CardData?.label}"`);
2247
+ assert(t4V2CardData?.color === 'red', `T4.remove-card inspect v2 color expected "red", got "${t4V2CardData?.color}"`);
2248
+ console.log('[T4.remove-card] ok: inspect.card-definition-and-runtime reflects v2 card_data after re-upsert');
2249
+
2250
+ await httpMcp('manage.remove-card', { card_id: T4_REMOVE_CARD_ID });
2251
+ console.log('[T4.remove-card] cleanup done');
2252
+
2253
+ }
2254
+
2255
+ if (skipT5) {
2256
+ console.log('\n=== T5: skipped (--skip-t5) ===');
2257
+ } else {
2258
+ console.log('\n=== T5: mcp-controlplane setstate/getstate ===');
2259
+ const T5_CARD_ID = CHAT_CARD_ID;
2260
+
2261
+ const t5IsProcInit = expectMcpSuccess(
2262
+ await httpMcpControlplane('getstate.is-chat-processing', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
2263
+ 'T5 getstate.is-chat-processing initial',
2264
+ );
2265
+ assert(t5IsProcInit?.active === false, `T5 expected initial chat-processing=false, got ${JSON.stringify(t5IsProcInit)}`);
2266
+
2267
+ expectMcpSuccess(
2268
+ await httpMcpControlplane('setstate.chat-processing-started', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
2269
+ 'T5 setstate.chat-processing-started',
2270
+ );
2271
+ const t5IsProcStarted = expectMcpSuccess(
2272
+ await httpMcpControlplane('getstate.is-chat-processing', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
2273
+ 'T5 getstate.is-chat-processing after start',
2274
+ );
2275
+ assert(t5IsProcStarted?.active === true, 'T5 expected chat-processing=true after setstate.chat-processing-started');
2276
+
2277
+ expectMcpSuccess(
2278
+ await httpMcpControlplane('setstate.chat-processing-done', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
2279
+ 'T5 setstate.chat-processing-done',
2280
+ );
2281
+ const t5IsProcDone = expectMcpSuccess(
2282
+ await httpMcpControlplane('getstate.is-chat-processing', { board_id: BOARD_ID, card_id: T5_CARD_ID }),
2283
+ 'T5 getstate.is-chat-processing after done',
2284
+ );
2285
+ assert(t5IsProcDone?.active === false, 'T5 expected chat-processing=false after setstate.chat-processing-done');
2286
+ console.log('[T5] ok: setstate/getstate chat-processing round-trip');
2287
+
2288
+ const t5ThreadId = `thread-t5-${Date.now()}`;
2289
+ expectMcpSuccess(
2290
+ await httpMcpControlplane('setstate.card-private', { board_id: BOARD_ID, card_id: T5_CARD_ID, key: 'chat.foundry_thread_id', value: t5ThreadId }),
2291
+ 'T5 setstate.card-private',
2292
+ );
2293
+ const t5GetMeta = expectMcpSuccess(
2294
+ await httpMcpControlplane('getstate.card-private', { board_id: BOARD_ID, card_id: T5_CARD_ID, key: 'chat.foundry_thread_id' }),
2295
+ 'T5 getstate.card-private',
2296
+ );
2297
+ assert(t5GetMeta?.exists === true && t5GetMeta?.value === t5ThreadId, `T5 getstate.card-private mismatch: ${JSON.stringify(t5GetMeta)}`);
2298
+
2299
+ const t5ReadCards = expectMcpSuccess(
2300
+ await httpMcp('manage.read-card', { card_id: T5_CARD_ID }),
2301
+ 'T5 manage.read-card meta-redaction',
2302
+ );
2303
+ const t5ReadCard = Array.isArray(t5ReadCards) ? t5ReadCards[0] : null;
2304
+ assert(t5ReadCard && t5ReadCard.__private === undefined, 'T5 expected manage.read-card to redact __private');
2305
+
2306
+ const t5Inspect = expectMcpSuccess(
2307
+ await httpMcp('inspect.card-definition-and-runtime', { card_id: T5_CARD_ID }),
2308
+ 'T5 inspect.card-definition-and-runtime meta-redaction',
2309
+ );
2310
+ assert(t5Inspect?.card_definition_and_static_data?.__private === undefined, 'T5 expected inspect to redact card_definition_and_static_data.__private');
2311
+ console.log('[T5] ok: regular /mcp surfaces redact __private');
2312
+
2313
+ // ── T5: admin-only card round-trip ──────────────────────────────────────
2314
+ // 1. Read the existing card definition via the normal read-card path.
2315
+ const t5AdminCardId = T5_CARD_ID;
2316
+ const t5NormalRead = expectMcpSuccess(
2317
+ await httpMcp('manage.read-card', { card_id: t5AdminCardId }),
2318
+ 'T5 admin setup: manage.read-card before marking admin',
2319
+ );
2320
+ const t5OriginalCard = Array.isArray(t5NormalRead) ? t5NormalRead[0] : null;
2321
+ assert(t5OriginalCard, 'T5 expected card definition before marking as admin-only');
2322
+
2323
+ // 2. Upsert it as an admin-only card via the controlplane tool.
2324
+ const t5AdminUpsert = expectMcpSuccess(
2325
+ await httpMcpControlplane('manage.admin-upsert-card', {
2326
+ board_id: BOARD_ID,
2327
+ card_id: t5AdminCardId,
2328
+ candidate_card_content: t5OriginalCard,
2329
+ }),
2330
+ 'T5 manage.admin-upsert-card',
2331
+ );
2332
+ assert(t5AdminUpsert?.board_result, 'T5 expected board_result from admin upsert');
2333
+ console.log('[T5] ok: manage.admin-upsert-card succeeded');
2334
+
2335
+ // 3. Verify the card is now invisible on the regular /mcp surface.
2336
+ // Server throws with statusCode:404 for admin cards, so httpResult.status will be 404 (not 200).
2337
+ const t5HiddenRead = await httpMcp('manage.read-card', { card_id: t5AdminCardId });
2338
+ assert(t5HiddenRead?.status !== 200 || t5HiddenRead?.data?.status === 'fail',
2339
+ `T5 expected manage.read-card to be blocked after admin upsert, got: ${JSON.stringify(t5HiddenRead)}`);
2340
+ console.log('[T5] ok: manage.read-card blocked for admin-only card');
2341
+
2342
+ // 4. Verify the card IS visible via the controlplane admin-read-card tool.
2343
+ const t5AdminRead = expectMcpSuccess(
2344
+ await httpMcpControlplane('manage.admin-read-card', { board_id: BOARD_ID, card_id: t5AdminCardId }),
2345
+ 'T5 manage.admin-read-card',
2346
+ );
2347
+ const t5AdminCards = Array.isArray(t5AdminRead?.cards) ? t5AdminRead.cards : [];
2348
+ assert(t5AdminCards.length > 0, 'T5 expected admin-read-card to return the card');
2349
+ assert(t5AdminCards[0]?.__private?.visible_controlplane_only === true, `T5 expected __private.visible_controlplane_only=true, got: ${JSON.stringify(t5AdminCards[0]?.__private)}`);
2350
+ console.log('[T5] ok: manage.admin-read-card returns card with __private.visible_controlplane_only=true');
2351
+
2352
+ // 5. Guard: setstate.card-private must block changing the flag to a different value.
2353
+ const t5MetaGuard = await httpMcpControlplane('setstate.card-private', {
2354
+ board_id: BOARD_ID,
2355
+ card_id: t5AdminCardId,
2356
+ key: 'chat.visible_controlplane_only',
2357
+ value: false, // differs from current flag value (true) → must be rejected
2358
+ });
2359
+ assert(t5MetaGuard?.status !== 200,
2360
+ `T5 expected setstate.card-private to reject changing visible_controlplane_only, got: ${JSON.stringify(t5MetaGuard)}`);
2361
+ console.log('[T5] ok: setstate.card-private blocked flag mutation (false != true)');
2362
+
2363
+ // 6. Guard: same key with value matching the current flag (true) must pass (idempotent).
2364
+ const t5MetaIdempotent = expectMcpSuccess(
2365
+ await httpMcpControlplane('setstate.card-private', {
2366
+ board_id: BOARD_ID,
2367
+ card_id: t5AdminCardId,
2368
+ key: 'chat.visible_controlplane_only',
2369
+ value: true, // matches current flag value → idempotent, allowed
2370
+ }),
2371
+ 'T5 setstate.card-private idempotent same-value',
2372
+ );
2373
+ assert(t5MetaIdempotent, 'T5 expected setstate.card-private to succeed with matching flag value');
2374
+ console.log('[T5] ok: setstate.card-private idempotent same-value allowed');
2375
+ }
2376
+ }
2377
+
2378
+ console.log('\n=== All smoke checks passed ===\n');
2379
+ } finally {
2380
+ if (chatSseClientId) {
2381
+ try {
2382
+ await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/chats/unsubscribe-sse`, { clientId: chatSseClientId });
2383
+ } catch { /* ignore */ }
2384
+ }
2385
+ if (chatSseClient) chatSseClient.close();
2386
+ if (boardSseClient) boardSseClient.close();
2387
+ await stopChildProcess(serverProc, 'demo board server');
2388
+
2389
+ // Clean up the test setup directory
2390
+ if (fs.existsSync(SETUP_DIR)) {
2391
+ fs.rmSync(SETUP_DIR, { recursive: true, force: true });
2392
+ }
2393
+ console.log('[demo-http-test] server stopped, setup dir cleaned');
2394
+ }