yaml-flow 8.4.23 → 8.5.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.
- package/browser/asset-integrity.json +3 -3
- package/cli/browser-api/board-live-cards-browser-adapter.d.ts +1 -1
- package/cli/browser-api/board-live-cards-browser-adapter.js +1 -1
- package/cli/bundled/board-live-cards-cli.mjs +14 -14
- package/cli/bundled/chat-store-cli.mjs +6 -6
- package/cli/{types-juH2nFpz.d.ts → types-D2s3VzyY.d.ts} +3 -2
- package/examples/board/demo-shell-with-server.html +2 -2
- package/examples/board/doc.html +2 -2
- package/examples/board/server/board-server.js +8 -0
- package/examples/board/server/chat-flow/copilot-chat/assistant.js +4 -2
- package/examples/board/server/chat-flow/flow-steps.json +70 -10
- package/examples/board/test/server-http-mcp-test.js +791 -0
- package/examples/board/test/server-http-test.js +32 -15
- package/examples/board-local/demo-shell-localstorage.html +3 -3
- package/lib/artifacts-store-public.d.cts +1 -1
- package/lib/artifacts-store-public.d.ts +1 -1
- package/lib/board-live-cards-mcp.cjs +2 -0
- package/lib/board-live-cards-mcp.d.cts +259 -0
- package/lib/board-live-cards-mcp.d.ts +259 -0
- package/lib/board-live-cards-mcp.js +2 -0
- package/lib/board-live-cards-node.cjs +14 -14
- package/lib/board-live-cards-node.d.cts +8 -6
- package/lib/board-live-cards-node.d.ts +8 -6
- package/lib/board-live-cards-node.js +14 -14
- package/lib/{board-live-cards-public-B4RcYPC_.d.cts → board-live-cards-public-CvkDfZQ7.d.cts} +1 -1
- package/lib/{board-live-cards-public-ydXuA4zh.d.ts → board-live-cards-public-DdVhH4M-.d.ts} +1 -1
- package/lib/board-live-cards-public.cjs +1 -1
- package/lib/board-live-cards-public.d.cts +1 -1
- package/lib/board-live-cards-public.d.ts +1 -1
- package/lib/board-live-cards-public.js +1 -1
- package/lib/board-live-cards-server-runtime.cjs +5 -4
- package/lib/board-live-cards-server-runtime.d.cts +3 -3
- package/lib/board-live-cards-server-runtime.d.ts +3 -3
- package/lib/board-live-cards-server-runtime.js +5 -4
- package/lib/card-store-public.d.cts +1 -1
- package/lib/card-store-public.d.ts +1 -1
- package/lib/{chat-storage-lib-B1wU27y3.d.cts → chat-storage-lib-0imhRX3l.d.cts} +2 -1
- package/lib/{chat-storage-lib-DsF4kPon.d.ts → chat-storage-lib-CJn7a6OH.d.ts} +2 -1
- package/lib/chat-store-public.cjs +1 -1
- package/lib/chat-store-public.d.cts +2 -2
- package/lib/chat-store-public.d.ts +2 -2
- package/lib/chat-store-public.js +1 -1
- package/lib/server-runtime/index.cjs +5 -4
- package/lib/server-runtime/index.d.cts +4 -4
- package/lib/server-runtime/index.d.ts +4 -4
- package/lib/server-runtime/index.js +5 -4
- package/lib/{types-D501gMQt.d.cts → types-NM_d_1oZ.d.cts} +5 -2
- package/lib/{types-1L1D33mr.d.ts → types-QNI__eAf.d.ts} +5 -2
- package/package.json +10 -2
- package/browser/board-livecards-localstorage.js +0 -10
|
@@ -0,0 +1,791 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* server-http-mcp-test.js
|
|
4
|
+
*
|
|
5
|
+
* Smoke test for demo-board/server/board-server.js over HTTP + SSE.
|
|
6
|
+
* Targets the 'live' board with --cards-pattern cardT* to load only the 3
|
|
7
|
+
* test cards (cardT-portfolio, cardT-market-prices, cardT-portfolio-value).
|
|
8
|
+
*
|
|
9
|
+
* T0: init-board -> SSE initial payload -> wait for all cards to complete
|
|
10
|
+
* T1: mutate holdings in memory -> manage.upsert-card over MCP -> verify recomputation
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* node test/server-http-mcp-test.js [--port 7799]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
17
|
+
import { Worker } from 'node:worker_threads';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import net from 'node:net';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
|
|
25
|
+
const ECHO_PROBE_MARKER = '__probe__echo__probe__';
|
|
26
|
+
const PROBE_IN_PROGRESS_TEXT = 'in-progress';
|
|
27
|
+
|
|
28
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
29
|
+
const __dirname = path.dirname(__filename);
|
|
30
|
+
|
|
31
|
+
const cliArgs = process.argv.slice(2);
|
|
32
|
+
const portArg = cliArgs.indexOf('--port');
|
|
33
|
+
const cliPort = portArg !== -1 ? parseInt(cliArgs[portArg + 1], 10) : NaN;
|
|
34
|
+
const skipT1 = cliArgs.includes('--skip-t1');
|
|
35
|
+
const skipT2 = cliArgs.includes('--skip-t2');
|
|
36
|
+
const skipT3 = cliArgs.includes('--skip-t3');
|
|
37
|
+
function isCopilotAvailable() {
|
|
38
|
+
try {
|
|
39
|
+
const r = spawnSync('copilot', ['--version'], { timeout: 5_000, stdio: 'ignore', windowsHide: true });
|
|
40
|
+
return !r.error;
|
|
41
|
+
} catch { return false; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const skipT3a = cliArgs.includes('--skip-t3a') || !isCopilotAvailable();
|
|
45
|
+
const skipT3b = cliArgs.includes('--skip-t3b');
|
|
46
|
+
const RUN_ID = `run-${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
|
|
47
|
+
|
|
48
|
+
const BOARD_ID = 'live';
|
|
49
|
+
const BOARD_DIR = path.resolve(__dirname, '..');
|
|
50
|
+
const SERVER_SCRIPT = path.resolve(BOARD_DIR, 'server', 'board-server.js');
|
|
51
|
+
const SSE_WORKER_SCRIPT = path.join(__dirname, 'sse-worker.js');
|
|
52
|
+
const CARD_PATTERN = 'cardT*';
|
|
53
|
+
const T2_FILE_CARD_ID = 'card-market-prices';
|
|
54
|
+
const CHAT_CARD_ID = 'card-portfolio';
|
|
55
|
+
|
|
56
|
+
function findFreePort() {
|
|
57
|
+
return new Promise((resolve, reject) => {
|
|
58
|
+
const srv = net.createServer();
|
|
59
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
60
|
+
const addr = /** @type {import('node:net').AddressInfo} */ (srv.address());
|
|
61
|
+
srv.close(() => resolve(addr.port));
|
|
62
|
+
});
|
|
63
|
+
srv.on('error', reject);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function resolveServerPort() {
|
|
68
|
+
if (Number.isInteger(cliPort) && cliPort > 0) return cliPort;
|
|
69
|
+
return findFreePort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PORT = await resolveServerPort();
|
|
73
|
+
const BASE = `http://127.0.0.1:${PORT}/api/boards/${BOARD_ID}`;
|
|
74
|
+
|
|
75
|
+
function resolveSetupDirRoot() {
|
|
76
|
+
return os.tmpdir();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const SETUP_DIR = path.join(resolveSetupDirRoot(), RUN_ID);
|
|
80
|
+
const BOARD_SETUP_ROOT = path.join(SETUP_DIR, 'boards');
|
|
81
|
+
if (fs.existsSync(SETUP_DIR)) {
|
|
82
|
+
fs.rmSync(SETUP_DIR, { recursive: true, force: true });
|
|
83
|
+
console.log(`[server-http-mcp-test] wiped setup dir: ${SETUP_DIR}`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const NS = {
|
|
87
|
+
initialPayload: null,
|
|
88
|
+
statusSummary: null,
|
|
89
|
+
statusGeneration: 0,
|
|
90
|
+
computedValues: {},
|
|
91
|
+
chatEvents: [],
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function applyFrame(payload) {
|
|
95
|
+
if (payload && Array.isArray(payload.cardDefinitions)) {
|
|
96
|
+
if (!NS.initialPayload && payload.cardDefinitions.length > 0) {
|
|
97
|
+
NS.initialPayload = payload;
|
|
98
|
+
}
|
|
99
|
+
const summary = payload.statusSnapshot && payload.statusSnapshot.summary;
|
|
100
|
+
if (summary) {
|
|
101
|
+
NS.statusSummary = summary;
|
|
102
|
+
NS.statusGeneration += 1;
|
|
103
|
+
}
|
|
104
|
+
if (payload.cardRuntimeById) {
|
|
105
|
+
for (const [cardId, runtime] of Object.entries(payload.cardRuntimeById)) {
|
|
106
|
+
if (runtime?.computed_values && Object.keys(runtime.computed_values).length > 0) {
|
|
107
|
+
NS.computedValues[cardId] = runtime.computed_values;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (payload && payload.kind === 'notification-batch' && Array.isArray(payload.notifications)) {
|
|
115
|
+
for (const n of payload.notifications) {
|
|
116
|
+
const summary = n && n.kind === 'status' && n.status && n.status.summary;
|
|
117
|
+
if (summary) {
|
|
118
|
+
NS.statusSummary = summary;
|
|
119
|
+
NS.statusGeneration += 1;
|
|
120
|
+
}
|
|
121
|
+
if (n && n.kind === 'computed_values' && n.cardId) {
|
|
122
|
+
NS.computedValues[n.cardId] = n.values;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizeSseChunkBuffer(buf, chunk) {
|
|
129
|
+
return (buf + chunk.replace(/\r\n/g, '\n'));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function parseSseBlocks(buffer) {
|
|
133
|
+
const payloads = [];
|
|
134
|
+
let buf = buffer;
|
|
135
|
+
while (true) {
|
|
136
|
+
const idx = buf.indexOf('\n\n');
|
|
137
|
+
if (idx === -1) break;
|
|
138
|
+
const block = buf.slice(0, idx);
|
|
139
|
+
buf = buf.slice(idx + 2);
|
|
140
|
+
const dataLines = [];
|
|
141
|
+
for (const line of block.split('\n')) {
|
|
142
|
+
if (line.startsWith('data:')) dataLines.push(line.slice(5).replace(/^ /, ''));
|
|
143
|
+
}
|
|
144
|
+
const data = dataLines.join('\n');
|
|
145
|
+
if (!data) continue;
|
|
146
|
+
try { payloads.push(JSON.parse(data)); } catch { /* ignore malformed */ }
|
|
147
|
+
}
|
|
148
|
+
return { payloads, remainder: buf };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function startSseClient(sseUrl, onPayload) {
|
|
152
|
+
const req = http.get(sseUrl, (res) => {
|
|
153
|
+
let buf = '';
|
|
154
|
+
res.setEncoding('utf-8');
|
|
155
|
+
res.on('data', (chunk) => {
|
|
156
|
+
buf = normalizeSseChunkBuffer(buf, chunk);
|
|
157
|
+
const parsed = parseSseBlocks(buf);
|
|
158
|
+
buf = parsed.remainder;
|
|
159
|
+
for (const payload of parsed.payloads) onPayload(payload);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
req.on('error', () => {});
|
|
163
|
+
return {
|
|
164
|
+
close() {
|
|
165
|
+
try { req.destroy(); } catch { /* */ }
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function captureChatEvents(payload, cardId) {
|
|
171
|
+
if (!payload || payload.kind !== 'notification-batch' || !Array.isArray(payload.notifications)) return;
|
|
172
|
+
for (const n of payload.notifications) {
|
|
173
|
+
if (n && n.kind === 'card_chats' && n.cardId === cardId) {
|
|
174
|
+
const messages = Array.isArray(n.messages) ? n.messages : [];
|
|
175
|
+
NS.chatEvents.push({
|
|
176
|
+
at: Date.now(),
|
|
177
|
+
cardId: n.cardId,
|
|
178
|
+
processing: !!n.processing,
|
|
179
|
+
receiving: !!n.receiving,
|
|
180
|
+
messageCount: messages.length,
|
|
181
|
+
messages,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function assert(condition, message) {
|
|
188
|
+
if (!condition) {
|
|
189
|
+
console.error(`\n[ASSERT FAILED] ${message}`);
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function randomTurnId() {
|
|
195
|
+
return String(Math.floor(100000 + Math.random() * 900000));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function waitUntil(predicate, timeoutMs, label) {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const deadline = Date.now() + timeoutMs;
|
|
201
|
+
const interval = setInterval(() => {
|
|
202
|
+
let result;
|
|
203
|
+
try { result = predicate(); } catch { /* retry */ }
|
|
204
|
+
if (result !== undefined && result !== null && result !== false) {
|
|
205
|
+
clearInterval(interval);
|
|
206
|
+
resolve(result);
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (Date.now() > deadline) {
|
|
210
|
+
clearInterval(interval);
|
|
211
|
+
reject(new Error(`Timeout (${timeoutMs}ms) waiting for: ${label}`));
|
|
212
|
+
}
|
|
213
|
+
}, 150);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const waitForInitialPayload = (ms = 15_000) => waitUntil(() => NS.initialPayload || false, ms, 'initial SSE payload');
|
|
218
|
+
const waitForAllCompleted = (ms = 60_000, label = 'all completed') => waitUntil(() => {
|
|
219
|
+
const s = NS.statusSummary;
|
|
220
|
+
if (s && s.card_count > 0 && s.completed === s.card_count) return s;
|
|
221
|
+
return false;
|
|
222
|
+
}, ms, label);
|
|
223
|
+
const waitForChatPredicate = (predicate, ms, label) => waitUntil(() => predicate(NS.chatEvents) || false, ms, label);
|
|
224
|
+
|
|
225
|
+
function deriveProbeLifecycleMilestones(events, opts) {
|
|
226
|
+
const milestones = [];
|
|
227
|
+
let prevMessageCount = Number(opts.beforeCount || 0);
|
|
228
|
+
let prevProcessing = Boolean(opts.beforeProcessing);
|
|
229
|
+
const prompt = String(opts.prompt || '');
|
|
230
|
+
const assistantText = opts.assistantText == null ? `Echo: ${prompt}` : String(opts.assistantText);
|
|
231
|
+
const inProgressText = String(opts.inProgressText || PROBE_IN_PROGRESS_TEXT);
|
|
232
|
+
|
|
233
|
+
for (const event of events) {
|
|
234
|
+
const messages = Array.isArray(event?.messages) ? event.messages : [];
|
|
235
|
+
const nextMessageCount = Number(event?.messageCount || messages.length || 0);
|
|
236
|
+
const newMessages = nextMessageCount > prevMessageCount ? messages.slice(prevMessageCount, nextMessageCount) : [];
|
|
237
|
+
|
|
238
|
+
for (const message of newMessages) {
|
|
239
|
+
const role = String(message?.role || '');
|
|
240
|
+
const text = String(message?.text || '');
|
|
241
|
+
if (role === 'user' && text.includes(prompt)) milestones.push('user');
|
|
242
|
+
else if (role === 'system' && text.trim().toLowerCase() === inProgressText) milestones.push('in-progress');
|
|
243
|
+
else if (role === 'assistant' && text.includes(assistantText)) milestones.push('assistant');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const processing = Boolean(event?.processing);
|
|
247
|
+
if (processing !== prevProcessing) milestones.push(processing ? 'processing-true' : 'processing-false');
|
|
248
|
+
|
|
249
|
+
prevMessageCount = nextMessageCount;
|
|
250
|
+
prevProcessing = processing;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return milestones;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function matchOrderedProbeLifecycle(events, opts) {
|
|
257
|
+
const milestones = deriveProbeLifecycleMilestones(events, opts);
|
|
258
|
+
if (milestones.length !== 5) return false;
|
|
259
|
+
const firstPair = milestones.slice(0, 2);
|
|
260
|
+
const lastPair = milestones.slice(3, 5);
|
|
261
|
+
const firstOk = firstPair.includes('user') && firstPair.includes('processing-true');
|
|
262
|
+
const middleOk = milestones[2] === 'in-progress';
|
|
263
|
+
const lastOk = lastPair.includes('assistant') && lastPair.includes('processing-false');
|
|
264
|
+
return (firstOk && middleOk && lastOk) ? { milestones } : false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function httpGet(url) {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
http.get(url, (res) => {
|
|
270
|
+
let body = '';
|
|
271
|
+
res.on('data', c => { body += c; });
|
|
272
|
+
res.on('end', () => {
|
|
273
|
+
try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
|
|
274
|
+
catch { resolve({ status: res.statusCode, data: body }); }
|
|
275
|
+
});
|
|
276
|
+
}).on('error', reject);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function httpGetRaw(url) {
|
|
281
|
+
return new Promise((resolve, reject) => {
|
|
282
|
+
http.get(url, (res) => {
|
|
283
|
+
const chunks = [];
|
|
284
|
+
res.on('data', c => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); });
|
|
285
|
+
res.on('end', () => {
|
|
286
|
+
resolve({ status: res.statusCode, body: Buffer.concat(chunks), headers: res.headers });
|
|
287
|
+
});
|
|
288
|
+
}).on('error', reject);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function httpJson(method, url, payload) {
|
|
293
|
+
return new Promise((resolve, reject) => {
|
|
294
|
+
const u = new URL(url);
|
|
295
|
+
const data = payload != null ? JSON.stringify(payload) : null;
|
|
296
|
+
const opts = {
|
|
297
|
+
hostname: u.hostname,
|
|
298
|
+
port: u.port,
|
|
299
|
+
path: u.pathname,
|
|
300
|
+
method,
|
|
301
|
+
headers: data ? { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(data) } : {},
|
|
302
|
+
};
|
|
303
|
+
const req = http.request(opts, (res) => {
|
|
304
|
+
let body = '';
|
|
305
|
+
res.on('data', c => { body += c; });
|
|
306
|
+
res.on('end', () => {
|
|
307
|
+
try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
|
|
308
|
+
catch { resolve({ status: res.statusCode, data: body }); }
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
req.on('error', reject);
|
|
312
|
+
if (data) req.write(data);
|
|
313
|
+
req.end();
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function httpMcp(tool, args) {
|
|
318
|
+
return httpJson('POST', `${BASE}/mcp`, { tool, args });
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function httpMcpRaw(tool, args) {
|
|
322
|
+
return new Promise((resolve, reject) => {
|
|
323
|
+
const u = new URL(`${BASE}/mcp-raw`);
|
|
324
|
+
const data = JSON.stringify({ tool, args });
|
|
325
|
+
const opts = {
|
|
326
|
+
hostname: u.hostname,
|
|
327
|
+
port: u.port,
|
|
328
|
+
path: u.pathname,
|
|
329
|
+
method: 'POST',
|
|
330
|
+
headers: {
|
|
331
|
+
'Content-Type': 'application/json',
|
|
332
|
+
'Content-Length': Buffer.byteLength(data),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
const req = http.request(opts, (res) => {
|
|
336
|
+
const chunks = [];
|
|
337
|
+
res.on('data', c => { chunks.push(Buffer.isBuffer(c) ? c : Buffer.from(c)); });
|
|
338
|
+
res.on('end', () => {
|
|
339
|
+
resolve({
|
|
340
|
+
status: res.statusCode,
|
|
341
|
+
body: Buffer.concat(chunks),
|
|
342
|
+
headers: res.headers,
|
|
343
|
+
});
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
req.on('error', reject);
|
|
347
|
+
req.write(data);
|
|
348
|
+
req.end();
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function httpUploadChatFile(url, fileName, content, contentType = 'text/plain; charset=utf-8') {
|
|
353
|
+
return new Promise((resolve, reject) => {
|
|
354
|
+
const u = new URL(url);
|
|
355
|
+
const data = Buffer.from(content, 'utf-8');
|
|
356
|
+
const opts = {
|
|
357
|
+
hostname: u.hostname,
|
|
358
|
+
port: u.port,
|
|
359
|
+
path: u.pathname + u.search,
|
|
360
|
+
method: 'POST',
|
|
361
|
+
headers: {
|
|
362
|
+
'Content-Type': contentType,
|
|
363
|
+
'Content-Length': data.length,
|
|
364
|
+
'x-file-name': encodeURIComponent(fileName),
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
const req = http.request(opts, (res) => {
|
|
368
|
+
let body = '';
|
|
369
|
+
res.on('data', c => { body += c; });
|
|
370
|
+
res.on('end', () => {
|
|
371
|
+
try { resolve({ status: res.statusCode, data: JSON.parse(body) }); }
|
|
372
|
+
catch { resolve({ status: res.statusCode, data: body }); }
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
req.on('error', reject);
|
|
376
|
+
req.write(data);
|
|
377
|
+
req.end();
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function startServer(port) {
|
|
382
|
+
return new Promise((resolve, reject) => {
|
|
383
|
+
const proc = spawn(process.execPath, [SERVER_SCRIPT], {
|
|
384
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
385
|
+
windowsHide: true,
|
|
386
|
+
env: {
|
|
387
|
+
...process.env,
|
|
388
|
+
DEMO_SERVER_PORT: String(port),
|
|
389
|
+
DEMO_SETUP_DIR: SETUP_DIR,
|
|
390
|
+
DEMO_BOARD_SETUP_ROOT: BOARD_SETUP_ROOT,
|
|
391
|
+
DEMO_CARDS_PATTERN: CARD_PATTERN,
|
|
392
|
+
},
|
|
393
|
+
});
|
|
394
|
+
let ready = false;
|
|
395
|
+
|
|
396
|
+
proc.stdout.on('data', (chunk) => {
|
|
397
|
+
const text = chunk.toString('utf-8');
|
|
398
|
+
process.stdout.write(`[server] ${text}`);
|
|
399
|
+
if (!ready && text.includes('listening on')) {
|
|
400
|
+
ready = true;
|
|
401
|
+
resolve(proc);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
proc.stderr.on('data', (chunk) => process.stderr.write(`[server:err] ${chunk}`));
|
|
405
|
+
proc.on('error', reject);
|
|
406
|
+
proc.on('exit', (code) => {
|
|
407
|
+
if (!ready) reject(new Error(`Server exited early: code ${code}`));
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
setTimeout(() => {
|
|
411
|
+
if (!ready) reject(new Error('Server startup timeout (15s)'));
|
|
412
|
+
}, 15_000);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
console.log('\n=== live board HTTP+SSE MCP smoke test ===');
|
|
417
|
+
console.log(`target: ${BASE}`);
|
|
418
|
+
console.log(`card pattern: ${CARD_PATTERN}`);
|
|
419
|
+
|
|
420
|
+
const serverProc = await startServer(PORT);
|
|
421
|
+
let sseWorker = null;
|
|
422
|
+
let chatSseClient = null;
|
|
423
|
+
let chatSseClientId = '';
|
|
424
|
+
|
|
425
|
+
try {
|
|
426
|
+
const regRes = await httpJson('POST', `http://127.0.0.1:${PORT}/api/boards`, { id: BOARD_ID, label: 'Live' });
|
|
427
|
+
assert(regRes.status === 200 || regRes.status === 201 || regRes.status === 409,
|
|
428
|
+
`POST /api/boards returned ${regRes.status}: ${JSON.stringify(regRes.data)}`);
|
|
429
|
+
console.log(`[setup] board '${BOARD_ID}' registered (${regRes.status})`);
|
|
430
|
+
|
|
431
|
+
console.log('\n=== T0 Step 1: init-board ===');
|
|
432
|
+
const initRes = await httpGet(`${BASE}/init-board`);
|
|
433
|
+
assert(initRes.status === 200, `init-board returned ${initRes.status}`);
|
|
434
|
+
console.log('[T0.1] init-board ok');
|
|
435
|
+
|
|
436
|
+
console.log('\n=== T0 Step 2: start SSE worker ===');
|
|
437
|
+
const sseClientId = `server-http-mcp-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
438
|
+
const sseUrl = `${BASE}/sse?clientId=${encodeURIComponent(sseClientId)}`;
|
|
439
|
+
sseWorker = new Worker(SSE_WORKER_SCRIPT, { workerData: { sseUrl } });
|
|
440
|
+
sseWorker.on('message', (msg) => {
|
|
441
|
+
if (msg.type === 'frame') applyFrame(msg.payload);
|
|
442
|
+
else if (msg.type === 'error') console.error(`[sse-worker] ${msg.message}`);
|
|
443
|
+
});
|
|
444
|
+
sseWorker.on('error', (err) => console.error(`[sse-worker] uncaught: ${err.message}`));
|
|
445
|
+
|
|
446
|
+
const initialPayload = await waitForInitialPayload();
|
|
447
|
+
const cardCount = Array.isArray(initialPayload.cardDefinitions) ? initialPayload.cardDefinitions.length : 0;
|
|
448
|
+
assert(cardCount === 3, `expected 3 cards (cardT*), got ${cardCount}`);
|
|
449
|
+
const cardIds = initialPayload.cardDefinitions.map(c => c.id).sort();
|
|
450
|
+
console.log(`[T0.2] SSE initial payload received (${cardCount} cards: ${cardIds.join(', ')})`);
|
|
451
|
+
|
|
452
|
+
console.log('\n=== T0 Step 3: wait for all cards to complete ===');
|
|
453
|
+
const t0Summary = await waitForAllCompleted(30_000, 'T0 initial completion');
|
|
454
|
+
assert(t0Summary.failed === 0, `T0 expected failed=0, got ${t0Summary.failed}`);
|
|
455
|
+
console.log(`[T0.3] completed: ${JSON.stringify(t0Summary)}`);
|
|
456
|
+
|
|
457
|
+
console.log('\n=== T0 Step 4: board-status cross-check ===');
|
|
458
|
+
const statusRes = await httpMcp('inspect.board-runtime-status', {});
|
|
459
|
+
assert(statusRes.status === 200, `inspect.board-runtime-status returned ${statusRes.status}`);
|
|
460
|
+
const httpSummary = statusRes.data?.summary;
|
|
461
|
+
assert(httpSummary, 'statusSnapshot.summary missing from board-status');
|
|
462
|
+
assert(httpSummary.completed === httpSummary.card_count, `not all complete: ${JSON.stringify(httpSummary)}`);
|
|
463
|
+
console.log(`[T0.4] board-status: ${JSON.stringify(httpSummary)}`);
|
|
464
|
+
|
|
465
|
+
const t0Positions = NS.computedValues['card-portfolio-value']?.positions;
|
|
466
|
+
assert(Array.isArray(t0Positions) && t0Positions.length > 0, 'T0 positions missing from computed_values');
|
|
467
|
+
console.log(`[T0] ok: ${t0Positions.length} positions computed`);
|
|
468
|
+
|
|
469
|
+
if (skipT1) {
|
|
470
|
+
console.log('\n=== T1: skipped (--skip-t1) ===');
|
|
471
|
+
} else {
|
|
472
|
+
console.log('\n=== T1: local mutation + manage.upsert-card (+1 row) ===');
|
|
473
|
+
|
|
474
|
+
const portfolioCardRes = await httpMcp('manage.read-card', { card_id: 'card-portfolio' });
|
|
475
|
+
assert(portfolioCardRes.status === 200, `manage.read-card returned ${portfolioCardRes.status}`);
|
|
476
|
+
const existingCard = Array.isArray(portfolioCardRes.data) ? portfolioCardRes.data[0] : null;
|
|
477
|
+
const existingHoldings = existingCard?.card_data?.holdings;
|
|
478
|
+
assert(Array.isArray(existingHoldings), 'card-portfolio.card_data.holdings missing');
|
|
479
|
+
const t0HoldingsCount = existingHoldings.length;
|
|
480
|
+
const t0PositionsCount = t0Positions.length;
|
|
481
|
+
|
|
482
|
+
const candidates = ['AAPL', 'MSFT', 'AMZN', 'TSLA', 'META', 'GOOG', 'NVDA', 'NFLX', 'INTC', 'AMD',
|
|
483
|
+
'IBM', 'ORCL', 'ADBE', 'CRM', 'QCOM'];
|
|
484
|
+
const existingTickers = new Set(existingHoldings.map(r => r.ticker));
|
|
485
|
+
const available = candidates.filter(t => !existingTickers.has(t));
|
|
486
|
+
assert(available.length > 0, 'No available ticker to add');
|
|
487
|
+
const newTicker = available[0];
|
|
488
|
+
|
|
489
|
+
const newHoldings = [...existingHoldings, { ticker: newTicker, quantity: 1, cost_basis: 100 }];
|
|
490
|
+
const nextCard = {
|
|
491
|
+
...existingCard,
|
|
492
|
+
card_data: {
|
|
493
|
+
...(existingCard.card_data || {}),
|
|
494
|
+
holdings: newHoldings,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
const upsertRes = await httpJson('POST', `${BASE}/mcp`, {
|
|
499
|
+
tool: 'manage.upsert-card',
|
|
500
|
+
args: {
|
|
501
|
+
card_id: 'card-portfolio',
|
|
502
|
+
candidate_card_content: nextCard,
|
|
503
|
+
},
|
|
504
|
+
});
|
|
505
|
+
assert(upsertRes.status === 200, `manage.upsert-card returned ${upsertRes.status}`);
|
|
506
|
+
assert(upsertRes.data?.status === 'success', `manage.upsert-card failed: ${JSON.stringify(upsertRes.data)}`);
|
|
507
|
+
|
|
508
|
+
NS.statusSummary = null;
|
|
509
|
+
await new Promise(r => setTimeout(r, 4000));
|
|
510
|
+
const t1Summary = await waitForAllCompleted(30_000, 'T1 holdings upsert');
|
|
511
|
+
assert(t1Summary.failed === 0, `T1 failed=${t1Summary.failed}`);
|
|
512
|
+
|
|
513
|
+
const t1PortfolioRes = await httpMcp('manage.read-card', { card_id: 'card-portfolio' });
|
|
514
|
+
assert(t1PortfolioRes.status === 200, `manage.read-card after upsert returned ${t1PortfolioRes.status}`);
|
|
515
|
+
const afterCard = Array.isArray(t1PortfolioRes.data) ? t1PortfolioRes.data[0] : null;
|
|
516
|
+
const afterHoldings = afterCard?.card_data?.holdings;
|
|
517
|
+
const afterHoldingsCount = Array.isArray(afterHoldings) ? afterHoldings.length : 0;
|
|
518
|
+
|
|
519
|
+
const afterPositions = NS.computedValues['card-portfolio-value']?.positions;
|
|
520
|
+
const afterPositionsCount = Array.isArray(afterPositions) ? afterPositions.length : 0;
|
|
521
|
+
|
|
522
|
+
assert(afterHoldingsCount === t0HoldingsCount + 1,
|
|
523
|
+
`Expected holdings rows +1 (before=${t0HoldingsCount}, after=${afterHoldingsCount})`);
|
|
524
|
+
assert(afterPositionsCount === t0PositionsCount + 1,
|
|
525
|
+
`Expected positions rows +1 (before=${t0PositionsCount}, after=${afterPositionsCount})`);
|
|
526
|
+
console.log(`[T1] ok: holdings ${t0HoldingsCount}->${afterHoldingsCount}, ` +
|
|
527
|
+
`positions ${t0PositionsCount}->${afterPositionsCount}, added=${newTicker}`);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
if (skipT2) {
|
|
531
|
+
console.log('\n=== T2: skipped (--skip-t2) ===');
|
|
532
|
+
} else {
|
|
533
|
+
console.log('\n=== T2: plain file upload -> card_data.files -> download ===');
|
|
534
|
+
const t2CardBefore = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
|
|
535
|
+
assert(t2CardBefore.status === 200, `T2 pre card read returned ${t2CardBefore.status}`);
|
|
536
|
+
const t2CardBeforeObj = Array.isArray(t2CardBefore.data) ? t2CardBefore.data[0] : null;
|
|
537
|
+
const t2FilesBefore = Array.isArray(t2CardBeforeObj?.card_data?.files) ? t2CardBeforeObj.card_data.files : [];
|
|
538
|
+
const t2BeforeCount = t2FilesBefore.length;
|
|
539
|
+
|
|
540
|
+
const t2UploadText = `plain-file-upload-${Date.now()}`;
|
|
541
|
+
const t2UploadName = 't2-upload.txt';
|
|
542
|
+
const t2UploadRes = await httpMcp('manage.upload-card-file', {
|
|
543
|
+
card_id: T2_FILE_CARD_ID,
|
|
544
|
+
file_name: t2UploadName,
|
|
545
|
+
content_type: 'text/plain; charset=utf-8',
|
|
546
|
+
text: t2UploadText,
|
|
547
|
+
});
|
|
548
|
+
assert(t2UploadRes.status === 200, `T2 file upload returned ${t2UploadRes.status}`);
|
|
549
|
+
const t2UploadedFile = t2UploadRes.data?.file;
|
|
550
|
+
assert(t2UploadedFile && typeof t2UploadedFile === 'object', 'T2 upload response missing file metadata');
|
|
551
|
+
assert(String(t2UploadedFile?.name || '') === t2UploadName, 'T2 uploaded file name mismatch');
|
|
552
|
+
|
|
553
|
+
const t2CardAfter = await httpMcp('manage.read-card', { card_id: T2_FILE_CARD_ID });
|
|
554
|
+
assert(t2CardAfter.status === 200, `T2 post card read returned ${t2CardAfter.status}`);
|
|
555
|
+
const t2CardAfterObj = Array.isArray(t2CardAfter.data) ? t2CardAfter.data[0] : null;
|
|
556
|
+
const t2FilesAfter = Array.isArray(t2CardAfterObj?.card_data?.files) ? t2CardAfterObj.card_data.files : [];
|
|
557
|
+
assert(t2FilesAfter.length === t2BeforeCount + 1, `T2 expected files +1 (before=${t2BeforeCount}, after=${t2FilesAfter.length})`);
|
|
558
|
+
|
|
559
|
+
const t2FileIndex = t2FilesAfter.findIndex((f) => String(f?.stored_name || '') === String(t2UploadedFile?.stored_name || ''));
|
|
560
|
+
assert(t2FileIndex >= 0, 'T2 uploaded file metadata not found in card_data.files');
|
|
561
|
+
|
|
562
|
+
const t2DownloadRes = await httpMcpRaw('inspect.file-contents', {
|
|
563
|
+
card_id: T2_FILE_CARD_ID,
|
|
564
|
+
file_idx: t2FileIndex,
|
|
565
|
+
});
|
|
566
|
+
assert(t2DownloadRes.status === 200, `T2 file download returned ${t2DownloadRes.status}`);
|
|
567
|
+
const t2DownloadedText = t2DownloadRes.body.toString('utf-8');
|
|
568
|
+
assert(t2DownloadedText === t2UploadText, 'T2 downloaded content mismatch');
|
|
569
|
+
console.log('[T2] ok: card_data.files updated and file download endpoint returned exact bytes');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
{
|
|
573
|
+
if (skipT3) {
|
|
574
|
+
console.log('\n=== T3: skipped (--skip-t3) ===');
|
|
575
|
+
} else {
|
|
576
|
+
console.log(`\n[${new Date().toISOString()}] === T3: probe chat protocol (SSE lifecycle) ===`);
|
|
577
|
+
chatSseClientId = `chat-proto-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
578
|
+
chatSseClient = startSseClient(`${BASE}/sse?clientId=${encodeURIComponent(chatSseClientId)}`, (payload) => {
|
|
579
|
+
captureChatEvents(payload, CHAT_CARD_ID);
|
|
580
|
+
});
|
|
581
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
582
|
+
|
|
583
|
+
const subRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/chats/subscribe-sse`, { clientId: chatSseClientId });
|
|
584
|
+
assert(subRes.status === 200, `chat subscribe returned ${subRes.status}`);
|
|
585
|
+
|
|
586
|
+
const t3Before = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, 'all-turns': true });
|
|
587
|
+
assert(t3Before.status === 200, `T3 MCP pre chats returned ${t3Before.status}`);
|
|
588
|
+
const t3BeforeMessages = Array.isArray(t3Before.data?.messages) ? t3Before.data.messages : [];
|
|
589
|
+
const t3BeforeCount = t3BeforeMessages.length;
|
|
590
|
+
const t3EventStart = NS.chatEvents.length;
|
|
591
|
+
const t3ProbePrompt = `Probe protocol validation ${Date.now()}`;
|
|
592
|
+
const t3TurnId = randomTurnId();
|
|
593
|
+
|
|
594
|
+
const t3SendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
|
|
595
|
+
actionType: 'chat-send',
|
|
596
|
+
payload: {
|
|
597
|
+
text: `${ECHO_PROBE_MARKER}${t3ProbePrompt}${ECHO_PROBE_MARKER}`,
|
|
598
|
+
'turn-id': t3TurnId,
|
|
599
|
+
},
|
|
600
|
+
});
|
|
601
|
+
assert(t3SendRes.status === 200, `T3 chat-send returned ${t3SendRes.status}`);
|
|
602
|
+
|
|
603
|
+
const t3Lifecycle = await waitForChatPredicate((events) => {
|
|
604
|
+
return matchOrderedProbeLifecycle(events.slice(t3EventStart), {
|
|
605
|
+
beforeCount: t3BeforeCount,
|
|
606
|
+
beforeProcessing: false,
|
|
607
|
+
prompt: t3ProbePrompt,
|
|
608
|
+
inProgressText: PROBE_IN_PROGRESS_TEXT,
|
|
609
|
+
});
|
|
610
|
+
}, 45_000, 'T3 ordered lifecycle');
|
|
611
|
+
assert(!!t3Lifecycle, 'T3 ordered lifecycle not observed');
|
|
612
|
+
|
|
613
|
+
const t3After = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, 'all-turns': true });
|
|
614
|
+
assert(t3After.status === 200, `T3 MCP post chats returned ${t3After.status}`);
|
|
615
|
+
const t3AfterMessages = Array.isArray(t3After.data?.messages) ? t3After.data.messages : [];
|
|
616
|
+
const t3NewMessages = t3AfterMessages.slice(t3BeforeCount);
|
|
617
|
+
assert(t3NewMessages.length >= 3, `T3 expected at least 3 new chat messages, got ${t3NewMessages.length}`);
|
|
618
|
+
const t3User = t3NewMessages.find((m) => m?.role === 'user');
|
|
619
|
+
const t3InProgress = t3NewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
|
|
620
|
+
const t3AssistantMsg = t3NewMessages.find((m) => m?.role === 'assistant');
|
|
621
|
+
assert(!!t3User && typeof t3User.id === 'string', 'T3 user chat message missing id');
|
|
622
|
+
assert(String(t3User?.text || '').includes(t3ProbePrompt), 'T3 user file text mismatch');
|
|
623
|
+
assert(String(t3User?.turn || '') === t3TurnId, 'T3 user turn id mismatch');
|
|
624
|
+
assert(!!t3InProgress && typeof t3InProgress.id === 'string', 'T3 in-progress system message missing id');
|
|
625
|
+
assert(String(t3InProgress?.turn || '') === t3TurnId, 'T3 in-progress system turn id mismatch');
|
|
626
|
+
assert(!!t3AssistantMsg && typeof t3AssistantMsg.id === 'string', 'T3 assistant chat message missing id');
|
|
627
|
+
assert(String(t3AssistantMsg?.text || '').includes(`Echo: ${t3ProbePrompt}`), 'T3 assistant echo file content mismatch');
|
|
628
|
+
assert(String(t3AssistantMsg?.turn || '') === t3TurnId, 'T3 assistant turn id mismatch');
|
|
629
|
+
console.log(`[${new Date().toISOString()}] [T3] ok: ordered probe lifecycle observed (user+processing, in-progress, assistant+processing clear)`);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
if (skipT3a) {
|
|
633
|
+
console.log('\n=== T3a: skipped (--skip-t3a) ===');
|
|
634
|
+
} else {
|
|
635
|
+
console.log('\n=== T3a: non-probe chat protocol (expect paris) ===');
|
|
636
|
+
const t3aBefore = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, 'all-turns': true });
|
|
637
|
+
assert(t3aBefore.status === 200, `T3a MCP pre chats returned ${t3aBefore.status}`);
|
|
638
|
+
const t3aBeforeMessages = Array.isArray(t3aBefore.data?.messages) ? t3aBefore.data.messages : [];
|
|
639
|
+
const t3aBeforeCount = t3aBeforeMessages.length;
|
|
640
|
+
const t3aPrompt = 'Just answer what is the capital of France. No Fluff. No COmmentary. No Markup Respond in lower case in one word.';
|
|
641
|
+
const t3aTurnId = randomTurnId();
|
|
642
|
+
|
|
643
|
+
const t3aSendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
|
|
644
|
+
actionType: 'chat-send',
|
|
645
|
+
payload: {
|
|
646
|
+
'turn-id': t3aTurnId,
|
|
647
|
+
text: JSON.stringify({
|
|
648
|
+
prompt: t3aPrompt,
|
|
649
|
+
chatTimeoutMs: 180000,
|
|
650
|
+
}),
|
|
651
|
+
},
|
|
652
|
+
});
|
|
653
|
+
assert(t3aSendRes.status === 200, `T3a chat-send returned ${t3aSendRes.status}`);
|
|
654
|
+
|
|
655
|
+
const t3aAssistant = await waitForChatPredicate((events) => {
|
|
656
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
657
|
+
const e = events[i];
|
|
658
|
+
if (e.messageCount < t3aBeforeCount + 2) continue;
|
|
659
|
+
const last = e.messages[e.messages.length - 1];
|
|
660
|
+
if (last?.role === 'assistant' && /paris/i.test(String(last.text || ''))) return e;
|
|
661
|
+
}
|
|
662
|
+
return false;
|
|
663
|
+
}, 240_000, 'T3a assistant response with paris');
|
|
664
|
+
assert(!!t3aAssistant, 'T3a assistant response with paris not observed on SSE');
|
|
665
|
+
|
|
666
|
+
const t3aAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, 'all-turns': true });
|
|
667
|
+
assert(t3aAfter.status === 200, `T3a MCP post chats returned ${t3aAfter.status}`);
|
|
668
|
+
const t3aAfterMessages = Array.isArray(t3aAfter.data?.messages) ? t3aAfter.data.messages : [];
|
|
669
|
+
const t3aNewMessages = t3aAfterMessages.slice(t3aBeforeCount);
|
|
670
|
+
assert(t3aNewMessages.length >= 2, `T3a expected at least 2 new chat messages, got ${t3aNewMessages.length}`);
|
|
671
|
+
const t3aUser = t3aNewMessages.find((m) => m?.role === 'user');
|
|
672
|
+
const t3aAssistantMsg = [...t3aNewMessages].reverse().find((m) => m?.role === 'assistant');
|
|
673
|
+
assert(!!t3aUser && typeof t3aUser.id === 'string', 'T3a user chat message missing id');
|
|
674
|
+
assert(String(t3aUser?.turn || '') === t3aTurnId, 'T3a user turn id mismatch');
|
|
675
|
+
assert(!!t3aAssistantMsg && typeof t3aAssistantMsg.id === 'string', 'T3a assistant chat message missing id');
|
|
676
|
+
assert(/paris/i.test(String(t3aAssistantMsg?.text || '')), 'T3a assistant file content missing paris');
|
|
677
|
+
assert(String(t3aAssistantMsg?.turn || '') === t3aTurnId, 'T3a assistant turn id mismatch');
|
|
678
|
+
for (const msg of t3aNewMessages.filter((m) => m?.role === 'system')) {
|
|
679
|
+
assert(String(msg?.turn || '') === t3aTurnId, 'T3a system turn id mismatch');
|
|
680
|
+
}
|
|
681
|
+
console.log('[T3a] ok: non-probe response contains paris');
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
if (skipT3b) {
|
|
685
|
+
console.log('\n=== T3b: skipped (--skip-t3b) ===');
|
|
686
|
+
} else {
|
|
687
|
+
console.log('\n=== T3b: probe-echo chat with file upload protocol ===');
|
|
688
|
+
const t3bBefore = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, 'all-turns': true });
|
|
689
|
+
assert(t3bBefore.status === 200, `T3b MCP pre chats returned ${t3bBefore.status}`);
|
|
690
|
+
const t3bBeforeMessages = Array.isArray(t3bBefore.data?.messages) ? t3bBefore.data.messages : [];
|
|
691
|
+
const t3bBeforeCount = t3bBeforeMessages.length;
|
|
692
|
+
const t3bTurnId = randomTurnId();
|
|
693
|
+
|
|
694
|
+
const t3bUploadRes = await httpUploadChatFile(
|
|
695
|
+
`${BASE}/cards/${CHAT_CARD_ID}/files?inChat=true&turn-id=${encodeURIComponent(t3bTurnId)}`,
|
|
696
|
+
'q1.txt',
|
|
697
|
+
'tokyo',
|
|
698
|
+
);
|
|
699
|
+
assert(t3bUploadRes.status === 200, `T3b file upload returned ${t3bUploadRes.status}`);
|
|
700
|
+
const uploadedFile = t3bUploadRes.data?.file;
|
|
701
|
+
assert(uploadedFile && typeof uploadedFile === 'object', 'T3b upload response missing file metadata');
|
|
702
|
+
|
|
703
|
+
const t3bAfterUpload = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, 'all-turns': true });
|
|
704
|
+
assert(t3bAfterUpload.status === 200, `T3b MCP chats after upload returned ${t3bAfterUpload.status}`);
|
|
705
|
+
const t3bUploadMessages = Array.isArray(t3bAfterUpload.data?.messages) ? t3bAfterUpload.data.messages : [];
|
|
706
|
+
const t3bUploadNewMessages = t3bUploadMessages.slice(t3bBeforeCount);
|
|
707
|
+
const t3bUploadSystem = t3bUploadNewMessages.find((m) => m?.role === 'system');
|
|
708
|
+
assert(!!t3bUploadSystem, 'T3b upload protocol missing system chat file');
|
|
709
|
+
assert(String(t3bUploadSystem?.text || '').toLowerCase().includes('file uploaded:'), 'T3b upload system message does not describe uploaded file');
|
|
710
|
+
assert(String(t3bUploadSystem?.turn || '') === t3bTurnId, 'T3b upload system turn id mismatch');
|
|
711
|
+
|
|
712
|
+
const t3bCardAfterUpload = await httpMcp('manage.read-card', { card_id: CHAT_CARD_ID });
|
|
713
|
+
assert(t3bCardAfterUpload.status === 200, `T3b manage.read-card after upload returned ${t3bCardAfterUpload.status}`);
|
|
714
|
+
const t3bCardAfterUploadValue = Array.isArray(t3bCardAfterUpload.data) ? t3bCardAfterUpload.data[0] : null;
|
|
715
|
+
const t3bFilesAfterUpload = Array.isArray(t3bCardAfterUploadValue?.card_data?.files)
|
|
716
|
+
? t3bCardAfterUploadValue.card_data.files
|
|
717
|
+
: [];
|
|
718
|
+
const t3bFileIndex = t3bFilesAfterUpload.findIndex((f) => String(f?.stored_name || '') === String(uploadedFile?.stored_name || ''));
|
|
719
|
+
assert(t3bFileIndex >= 0, 'T3b uploaded file metadata not found in card_data.files');
|
|
720
|
+
|
|
721
|
+
const t3bDownloadRes = await httpMcpRaw('inspect.file-contents', {
|
|
722
|
+
card_id: CHAT_CARD_ID,
|
|
723
|
+
file_idx: t3bFileIndex,
|
|
724
|
+
});
|
|
725
|
+
assert(t3bDownloadRes.status === 200, `T3b file download returned ${t3bDownloadRes.status}`);
|
|
726
|
+
assert(t3bDownloadRes.body.toString('utf-8') === 'tokyo', 'T3b downloaded content mismatch');
|
|
727
|
+
|
|
728
|
+
const t3bSendBaseline = t3bUploadMessages.length;
|
|
729
|
+
const t3bEventStart = NS.chatEvents.length;
|
|
730
|
+
|
|
731
|
+
const t3bPrompt = `probe echo file-upload validation ${Date.now()}`;
|
|
732
|
+
const t3bSendRes = await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/actions`, {
|
|
733
|
+
actionType: 'chat-send',
|
|
734
|
+
payload: {
|
|
735
|
+
'turn-id': t3bTurnId,
|
|
736
|
+
text: `${ECHO_PROBE_MARKER}${t3bPrompt}${ECHO_PROBE_MARKER}`,
|
|
737
|
+
files: [uploadedFile],
|
|
738
|
+
},
|
|
739
|
+
});
|
|
740
|
+
assert(t3bSendRes.status === 200, `T3b chat-send returned ${t3bSendRes.status}`);
|
|
741
|
+
|
|
742
|
+
const t3bLifecycle = await waitForChatPredicate((events) => {
|
|
743
|
+
return matchOrderedProbeLifecycle(events.slice(t3bEventStart), {
|
|
744
|
+
beforeCount: t3bSendBaseline,
|
|
745
|
+
beforeProcessing: false,
|
|
746
|
+
prompt: t3bPrompt,
|
|
747
|
+
assistantText: 'tokyo',
|
|
748
|
+
inProgressText: PROBE_IN_PROGRESS_TEXT,
|
|
749
|
+
});
|
|
750
|
+
}, 60_000, 'T3b ordered lifecycle');
|
|
751
|
+
assert(!!t3bLifecycle, 'T3b ordered lifecycle not observed');
|
|
752
|
+
|
|
753
|
+
const t3bAfter = await httpMcp('inspect.chat-messages-on-cards', { card_id: CHAT_CARD_ID, 'all-turns': true });
|
|
754
|
+
assert(t3bAfter.status === 200, `T3b MCP post chats returned ${t3bAfter.status}`);
|
|
755
|
+
const t3bAfterMessages = Array.isArray(t3bAfter.data?.messages) ? t3bAfter.data.messages : [];
|
|
756
|
+
const t3bNewMessages = t3bAfterMessages.slice(t3bSendBaseline);
|
|
757
|
+
assert(t3bNewMessages.length >= 3, `T3b expected at least 3 chat messages after send, got ${t3bNewMessages.length}`);
|
|
758
|
+
|
|
759
|
+
const t3bUser = t3bNewMessages.find((m) => m?.role === 'user');
|
|
760
|
+
const t3bInProgress = t3bNewMessages.find((m) => m?.role === 'system' && String(m?.text || '').trim().toLowerCase() === PROBE_IN_PROGRESS_TEXT);
|
|
761
|
+
const t3bAssistantMsg = t3bNewMessages.find((m) => m?.role === 'assistant');
|
|
762
|
+
|
|
763
|
+
assert(!!t3bUser && typeof t3bUser.id === 'string', 'T3b missing user chat message notification');
|
|
764
|
+
assert(String(t3bUser?.turn || '') === t3bTurnId, 'T3b user turn id mismatch');
|
|
765
|
+
assert(!!t3bInProgress && typeof t3bInProgress.id === 'string', 'T3b missing in-progress system chat message');
|
|
766
|
+
assert(String(t3bInProgress?.turn || '') === t3bTurnId, 'T3b in-progress system turn id mismatch');
|
|
767
|
+
assert(!!t3bAssistantMsg && typeof t3bAssistantMsg.id === 'string', 'T3b missing assistant chat message notification');
|
|
768
|
+
assert(Array.isArray(t3bUser?.files) && t3bUser.files.length === 1, 'T3b user chat message missing uploaded file metadata');
|
|
769
|
+
assert(String(t3bAssistantMsg?.text || '').trim() === 'tokyo', 'T3b assistant attachment content mismatch');
|
|
770
|
+
assert(String(t3bAssistantMsg?.turn || '') === t3bTurnId, 'T3b assistant turn id mismatch');
|
|
771
|
+
console.log('[T3b] ok: upload protocol and ordered probe lifecycle observed with attachment-derived assistant reply');
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
console.log('\n=== All smoke checks passed ===\n');
|
|
776
|
+
} finally {
|
|
777
|
+
if (chatSseClientId) {
|
|
778
|
+
try {
|
|
779
|
+
await httpJson('POST', `${BASE}/cards/${CHAT_CARD_ID}/chats/unsubscribe-sse`, { clientId: chatSseClientId });
|
|
780
|
+
} catch { /* ignore */ }
|
|
781
|
+
}
|
|
782
|
+
if (chatSseClient) chatSseClient.close();
|
|
783
|
+
serverProc.kill();
|
|
784
|
+
await new Promise((r) => serverProc.on('exit', r));
|
|
785
|
+
if (sseWorker) await sseWorker.terminate();
|
|
786
|
+
|
|
787
|
+
if (fs.existsSync(SETUP_DIR)) {
|
|
788
|
+
fs.rmSync(SETUP_DIR, { recursive: true, force: true });
|
|
789
|
+
}
|
|
790
|
+
console.log('[server-http-mcp-test] server stopped, setup dir cleaned');
|
|
791
|
+
}
|