@vdoninja/mcp 0.4.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/SKILL.md +90 -0
- package/assets/cloudflare/worker-proxy.js +89 -0
- package/assets/cloudflare/wrangler.toml.example +10 -0
- package/package.json +66 -0
- package/references/advanced-diagnostics.md +45 -0
- package/references/mcp-tool-contract.md +184 -0
- package/references/quickstart-and-compat.md +198 -0
- package/references/vdo-ninja-p2p-data-notes.md +156 -0
- package/scripts/demo-file-workflow.js +168 -0
- package/scripts/demo-message-workflow.js +167 -0
- package/scripts/install-mcp.js +302 -0
- package/scripts/lib/bridge-session.js +2331 -0
- package/scripts/lib/bridge-utils.js +185 -0
- package/scripts/lib/fake-network-sdk.js +293 -0
- package/scripts/lib/load-vdo-sdk.js +36 -0
- package/scripts/lib/mcp-stdio-parser.js +137 -0
- package/scripts/lib/tool-profiles.js +122 -0
- package/scripts/vdo-data-bridge.js +157 -0
- package/scripts/vdo-mcp-server.js +1425 -0
- package/scripts/vdo-mcp-streamable-http.js +270 -0
- package/server.json +134 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# VDO.Ninja P2P Data Notes for MCP Workflows
|
|
2
|
+
|
|
3
|
+
This file captures operational constraints from this repository's SDK docs.
|
|
4
|
+
|
|
5
|
+
## Hard Platform Constraints
|
|
6
|
+
|
|
7
|
+
1. Signaling is not an app-data tunnel.
|
|
8
|
+
- Only handshake/signaling data should traverse WebSocket.
|
|
9
|
+
- Application payloads are expected over WebRTC data channels.
|
|
10
|
+
|
|
11
|
+
2. Room scale is bounded.
|
|
12
|
+
- Documented guidance is about 80 peers per room (viewer limits can apply).
|
|
13
|
+
- For higher fanout, split rooms or use server-mediated patterns.
|
|
14
|
+
|
|
15
|
+
3. Serverless philosophy is enforced.
|
|
16
|
+
- No server-side state relay for your app protocol.
|
|
17
|
+
- Design as peer-to-peer first.
|
|
18
|
+
|
|
19
|
+
## Topologies That Work
|
|
20
|
+
|
|
21
|
+
1. Two-peer data link (recommended).
|
|
22
|
+
- Peer A: `announce({ streamID })`
|
|
23
|
+
- Peer B: `view(streamID, { audio:false, video:false })`
|
|
24
|
+
- Result: one bidirectional data channel.
|
|
25
|
+
|
|
26
|
+
2. Multi-peer data mesh.
|
|
27
|
+
- Use `autoConnect(room)` half-mesh mode to avoid duplicate pair connections.
|
|
28
|
+
- Move to full mesh only when media exchange actually needs two directional AV links.
|
|
29
|
+
|
|
30
|
+
3. Targeted delivery.
|
|
31
|
+
- Use `sendData(data, "uuid")` or advanced target objects.
|
|
32
|
+
- Keep `preference: "any"` unless you explicitly need publisher-only/viewer-only/all-channel behavior.
|
|
33
|
+
|
|
34
|
+
## Delivery Semantics
|
|
35
|
+
|
|
36
|
+
- WebRTC data channels are typically configured for reliable, ordered delivery in SDK defaults.
|
|
37
|
+
- Reliability applies after channel establishment; it does not guarantee every network can establish the channel.
|
|
38
|
+
- TURN improves establishment success in restrictive NAT/firewall environments but does not guarantee universal reachability.
|
|
39
|
+
- This MCP skill layers app-level reliability for file transfer:
|
|
40
|
+
- chunk SHA-256 validation
|
|
41
|
+
- ACK/NACK feedback
|
|
42
|
+
- resume from `next_seq` after interruption
|
|
43
|
+
|
|
44
|
+
## Firewall/NAT Guidance
|
|
45
|
+
|
|
46
|
+
1. Prefer TURN when needed.
|
|
47
|
+
- `forceTURN: true` can push connections through relay infrastructure.
|
|
48
|
+
- This helps restrictive networks but changes latency/cost characteristics.
|
|
49
|
+
|
|
50
|
+
2. Treat fallback as last resort.
|
|
51
|
+
- `allowFallback: true` allows `sendData` fallback through signaling when P2P is unavailable.
|
|
52
|
+
- Use for control/survivability, not sustained throughput.
|
|
53
|
+
|
|
54
|
+
3. Expect hard failure zones.
|
|
55
|
+
- Some enterprise/WSL/network setups can still block WebRTC UDP.
|
|
56
|
+
- Build retry and reconnection logic regardless of TURN.
|
|
57
|
+
|
|
58
|
+
## Keep-Alive and Session Continuity
|
|
59
|
+
|
|
60
|
+
1. Keep a heartbeat.
|
|
61
|
+
- Send lightweight periodic app-level heartbeats.
|
|
62
|
+
- Optionally send SDK pings (`sendPing`) to connected peers.
|
|
63
|
+
|
|
64
|
+
2. Reconnect predictably.
|
|
65
|
+
- On disconnect/error, recreate SDK instance, reconnect, re-join room, re-announce, and re-view targets.
|
|
66
|
+
- Make handlers idempotent to tolerate duplicate or delayed messages around reconnection.
|
|
67
|
+
|
|
68
|
+
3. Define canonical IDs.
|
|
69
|
+
- Room and stream IDs should use alphanumeric + underscore.
|
|
70
|
+
- Rely on sanitized IDs consistently across all peers.
|
|
71
|
+
|
|
72
|
+
## Bridge Runtime Knobs
|
|
73
|
+
|
|
74
|
+
`MCP/scripts/vdo-data-bridge.js` supports:
|
|
75
|
+
|
|
76
|
+
- `VDON_ROOM` (required in real deployments)
|
|
77
|
+
- `VDON_STREAM_ID` (optional; random if omitted)
|
|
78
|
+
- `VDON_TARGET_STREAM_ID` (optional; auto-view peer stream for two-peer links)
|
|
79
|
+
- `VDON_HOST` (default `wss://wss.vdo.ninja`)
|
|
80
|
+
- `VDON_PASSWORD` (`false` to disable encryption; omitted uses SDK default)
|
|
81
|
+
- `VDON_FORCE_TURN` (`true`/`false`)
|
|
82
|
+
- `VDON_HEARTBEAT_MS` (default `15000`)
|
|
83
|
+
- `VDON_RECONNECT_MS` (default `3000`)
|
|
84
|
+
- `VDON_MAX_RECONNECT_MS` (default `30000`)
|
|
85
|
+
- `VDON_DEBUG` (`true`/`false`)
|
|
86
|
+
|
|
87
|
+
## App Envelope Suggestions
|
|
88
|
+
|
|
89
|
+
Use explicit envelope types and IDs over raw strings:
|
|
90
|
+
|
|
91
|
+
```json
|
|
92
|
+
{
|
|
93
|
+
"type": "mcp.request",
|
|
94
|
+
"id": "req_123",
|
|
95
|
+
"method": "tool.call",
|
|
96
|
+
"payload": { "name": "lookup", "args": { "q": "abc" } }
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"type": "mcp.response",
|
|
103
|
+
"id": "req_123",
|
|
104
|
+
"ok": true,
|
|
105
|
+
"payload": { "result": "..." }
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Avoid reserved SDK pub/sub types (`subscribe`, `unsubscribe`, `channelMessage`) for custom envelopes.
|
|
110
|
+
|
|
111
|
+
## File Transfer Envelope Pattern Used Here
|
|
112
|
+
|
|
113
|
+
This MCP implementation reserves protocol envelopes with:
|
|
114
|
+
|
|
115
|
+
- `__vdo_mcp: \"vdo_mcp_bridge_v1\"`
|
|
116
|
+
- `kind: \"sync.*\" | \"file.*\"`
|
|
117
|
+
|
|
118
|
+
File transfer handshake:
|
|
119
|
+
|
|
120
|
+
1. `file.offer`
|
|
121
|
+
2. `file.accept`
|
|
122
|
+
3. `file.chunk` + `file.ack` / `file.nack`
|
|
123
|
+
4. `file.complete`
|
|
124
|
+
5. `file.complete_ack`
|
|
125
|
+
|
|
126
|
+
Resume flow:
|
|
127
|
+
|
|
128
|
+
1. `file.resume_req`
|
|
129
|
+
2. `file.resume_state` (`next_seq`)
|
|
130
|
+
|
|
131
|
+
## Disk Spooling And Large Files
|
|
132
|
+
|
|
133
|
+
- This MCP implementation can spool large incoming transfers to disk.
|
|
134
|
+
- Tune via `vdo_connect`:
|
|
135
|
+
- `file_spool_dir`
|
|
136
|
+
- `file_spool_threshold_bytes`
|
|
137
|
+
- `keep_spool_files`
|
|
138
|
+
- Use `vdo_file_send_path` to avoid base64 memory overhead on large local files.
|
|
139
|
+
- Use `vdo_file_save` to persist completed incoming transfers directly.
|
|
140
|
+
|
|
141
|
+
## Room State Sync (CRDT-lite)
|
|
142
|
+
|
|
143
|
+
- Shared state is replicated using `state.patch` and `state.snapshot`.
|
|
144
|
+
- Conflict resolution is tuple-based LWW:
|
|
145
|
+
- higher `clock` wins
|
|
146
|
+
- tie breaks by `actor` lexical order
|
|
147
|
+
- MCP surface:
|
|
148
|
+
- `vdo_state_set`
|
|
149
|
+
- `vdo_state_get`
|
|
150
|
+
- `vdo_state_sync`
|
|
151
|
+
|
|
152
|
+
## Local Source Pointers
|
|
153
|
+
|
|
154
|
+
- `README.md`
|
|
155
|
+
- `AI-INTEGRATION.md`
|
|
156
|
+
- `docs/api-reference.md`
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { VdoMcpServer } = require('./vdo-mcp-server');
|
|
5
|
+
const { createFakeSDKFactory } = require('./lib/fake-network-sdk');
|
|
6
|
+
|
|
7
|
+
function boolFromValue(value, fallback) {
|
|
8
|
+
if (value === undefined || value === null) return fallback;
|
|
9
|
+
if (typeof value === 'boolean') return value;
|
|
10
|
+
const text = String(value).trim().toLowerCase();
|
|
11
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(text)) return true;
|
|
12
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(text)) return false;
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function waitUntil(predicate, timeoutMs, stepMs) {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
while (Date.now() - start < timeoutMs) {
|
|
19
|
+
// eslint-disable-next-line no-await-in-loop
|
|
20
|
+
if (await predicate()) return true;
|
|
21
|
+
// eslint-disable-next-line no-await-in-loop
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitForIncomingTransfer(server, sessionId, timeoutMs) {
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
while (Date.now() - start < timeoutMs) {
|
|
30
|
+
// eslint-disable-next-line no-await-in-loop
|
|
31
|
+
const transfers = await server.callTool('vdo_file_transfers', {
|
|
32
|
+
session_id: sessionId,
|
|
33
|
+
direction: 'incoming'
|
|
34
|
+
});
|
|
35
|
+
const incoming = Array.isArray(transfers.incoming) ? transfers.incoming : [];
|
|
36
|
+
const completed = incoming.find((transfer) => transfer && transfer.status === 'completed');
|
|
37
|
+
if (completed) return completed;
|
|
38
|
+
// eslint-disable-next-line no-await-in-loop
|
|
39
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function main() {
|
|
45
|
+
const useFake = boolFromValue(process.env.MCP_DEMO_FAKE, true);
|
|
46
|
+
const forceTurn = boolFromValue(process.env.MCP_DEMO_FORCE_TURN, false);
|
|
47
|
+
const host = process.env.MCP_DEMO_HOST || process.env.LIVE_WSS_HOST || 'wss://wss.vdo.ninja';
|
|
48
|
+
const room = process.env.MCP_DEMO_ROOM || `mcp_demo_file_${Date.now()}`;
|
|
49
|
+
const fileName = process.env.MCP_DEMO_FILE_NAME || 'demo-note.txt';
|
|
50
|
+
const fileText = process.env.MCP_DEMO_FILE_TEXT || 'hello from MCP file transfer demo';
|
|
51
|
+
const connectTimeoutMs = Number.parseInt(process.env.MCP_DEMO_CONNECT_TIMEOUT_MS || '', 10) || 15000;
|
|
52
|
+
const transferTimeoutMs = Number.parseInt(process.env.MCP_DEMO_TRANSFER_TIMEOUT_MS || '', 10) || 12000;
|
|
53
|
+
|
|
54
|
+
const baseOptions = {
|
|
55
|
+
logger: () => {}
|
|
56
|
+
};
|
|
57
|
+
if (useFake) {
|
|
58
|
+
baseOptions.sdkFactory = createFakeSDKFactory();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const serverA = new VdoMcpServer(baseOptions);
|
|
62
|
+
const serverB = new VdoMcpServer(baseOptions);
|
|
63
|
+
let sessionA;
|
|
64
|
+
let sessionB;
|
|
65
|
+
const startedAt = new Date().toISOString();
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
console.log(`[demo-file] mode=${useFake ? 'fake' : 'live'} host=${host} force_turn=${forceTurn}`);
|
|
69
|
+
console.log(`[demo-file] room=${room}`);
|
|
70
|
+
|
|
71
|
+
const connectA = await serverA.callTool('vdo_connect', {
|
|
72
|
+
host,
|
|
73
|
+
room,
|
|
74
|
+
stream_id: 'demo_file_a',
|
|
75
|
+
force_turn: forceTurn
|
|
76
|
+
});
|
|
77
|
+
sessionA = connectA.session_id;
|
|
78
|
+
|
|
79
|
+
const connectB = await serverB.callTool('vdo_connect', {
|
|
80
|
+
host,
|
|
81
|
+
room,
|
|
82
|
+
stream_id: 'demo_file_b',
|
|
83
|
+
target_stream_id: 'demo_file_a',
|
|
84
|
+
force_turn: forceTurn
|
|
85
|
+
});
|
|
86
|
+
sessionB = connectB.session_id;
|
|
87
|
+
|
|
88
|
+
const connected = await waitUntil(async () => {
|
|
89
|
+
const [statusA, statusB] = await Promise.all([
|
|
90
|
+
serverA.callTool('vdo_status', { session_id: sessionA }),
|
|
91
|
+
serverB.callTool('vdo_status', { session_id: sessionB })
|
|
92
|
+
]);
|
|
93
|
+
return statusA.connected && statusB.connected;
|
|
94
|
+
}, connectTimeoutMs, 200);
|
|
95
|
+
if (!connected) {
|
|
96
|
+
throw new Error('demo-file connect timeout');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const payload = Buffer.from(fileText, 'utf8').toString('base64');
|
|
100
|
+
const sent = await serverA.callTool('vdo_file_send', {
|
|
101
|
+
session_id: sessionA,
|
|
102
|
+
data_base64: payload,
|
|
103
|
+
name: fileName,
|
|
104
|
+
mime: 'text/plain'
|
|
105
|
+
});
|
|
106
|
+
if (!sent.ok) {
|
|
107
|
+
throw new Error('demo-file send returned ok=false');
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const completed = await waitForIncomingTransfer(serverB, sessionB, transferTimeoutMs);
|
|
111
|
+
if (!completed) {
|
|
112
|
+
throw new Error('demo-file receive timeout');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const received = await serverB.callTool('vdo_file_receive', {
|
|
116
|
+
session_id: sessionB,
|
|
117
|
+
transfer_id: completed.transfer_id,
|
|
118
|
+
encoding: 'utf8'
|
|
119
|
+
});
|
|
120
|
+
if (received.data_text !== fileText) {
|
|
121
|
+
throw new Error('demo-file content mismatch');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const summary = {
|
|
125
|
+
ok: true,
|
|
126
|
+
started_at: startedAt,
|
|
127
|
+
finished_at: new Date().toISOString(),
|
|
128
|
+
mode: useFake ? 'fake' : 'live',
|
|
129
|
+
room,
|
|
130
|
+
force_turn: forceTurn,
|
|
131
|
+
host,
|
|
132
|
+
session_a: sessionA,
|
|
133
|
+
session_b: sessionB,
|
|
134
|
+
transfer_id: completed.transfer_id,
|
|
135
|
+
file_name: fileName,
|
|
136
|
+
bytes: Buffer.byteLength(fileText, 'utf8')
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
140
|
+
console.log('demo-file-workflow.js passed');
|
|
141
|
+
} finally {
|
|
142
|
+
try {
|
|
143
|
+
if (sessionA) await serverA.callTool('vdo_disconnect', { session_id: sessionA });
|
|
144
|
+
} catch (error) {
|
|
145
|
+
// Ignore cleanup failures.
|
|
146
|
+
}
|
|
147
|
+
try {
|
|
148
|
+
if (sessionB) await serverB.callTool('vdo_disconnect', { session_id: sessionB });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
// Ignore cleanup failures.
|
|
151
|
+
}
|
|
152
|
+
try {
|
|
153
|
+
await serverA.shutdownAll();
|
|
154
|
+
} catch (error) {
|
|
155
|
+
// Ignore cleanup failures.
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
await serverB.shutdownAll();
|
|
159
|
+
} catch (error) {
|
|
160
|
+
// Ignore cleanup failures.
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
main().catch((error) => {
|
|
166
|
+
console.error(error.message);
|
|
167
|
+
process.exit(1);
|
|
168
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { VdoMcpServer } = require('./vdo-mcp-server');
|
|
5
|
+
const { createFakeSDKFactory } = require('./lib/fake-network-sdk');
|
|
6
|
+
|
|
7
|
+
function boolFromValue(value, fallback) {
|
|
8
|
+
if (value === undefined || value === null) return fallback;
|
|
9
|
+
if (typeof value === 'boolean') return value;
|
|
10
|
+
const text = String(value).trim().toLowerCase();
|
|
11
|
+
if (['1', 'true', 'yes', 'y', 'on'].includes(text)) return true;
|
|
12
|
+
if (['0', 'false', 'no', 'n', 'off'].includes(text)) return false;
|
|
13
|
+
return fallback;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function waitUntil(predicate, timeoutMs, stepMs) {
|
|
17
|
+
const start = Date.now();
|
|
18
|
+
while (Date.now() - start < timeoutMs) {
|
|
19
|
+
// eslint-disable-next-line no-await-in-loop
|
|
20
|
+
if (await predicate()) return true;
|
|
21
|
+
// eslint-disable-next-line no-await-in-loop
|
|
22
|
+
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
23
|
+
}
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function waitForMessage(server, sessionId, id, timeoutMs) {
|
|
28
|
+
const start = Date.now();
|
|
29
|
+
while (Date.now() - start < timeoutMs) {
|
|
30
|
+
// eslint-disable-next-line no-await-in-loop
|
|
31
|
+
const received = await server.callTool('vdo_receive', {
|
|
32
|
+
session_id: sessionId,
|
|
33
|
+
max_events: 100,
|
|
34
|
+
wait_ms: 250
|
|
35
|
+
});
|
|
36
|
+
const events = Array.isArray(received.events) ? received.events : [];
|
|
37
|
+
const match = events.find((event) => (
|
|
38
|
+
event &&
|
|
39
|
+
event.type === 'data_received' &&
|
|
40
|
+
event.data &&
|
|
41
|
+
event.data.type === 'demo.message' &&
|
|
42
|
+
event.data.id === id
|
|
43
|
+
));
|
|
44
|
+
if (match) return match;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
const useFake = boolFromValue(process.env.MCP_DEMO_FAKE, true);
|
|
51
|
+
const forceTurn = boolFromValue(process.env.MCP_DEMO_FORCE_TURN, false);
|
|
52
|
+
const host = process.env.MCP_DEMO_HOST || process.env.LIVE_WSS_HOST || 'wss://wss.vdo.ninja';
|
|
53
|
+
const room = process.env.MCP_DEMO_ROOM || `mcp_demo_message_${Date.now()}`;
|
|
54
|
+
const message = process.env.MCP_DEMO_MESSAGE || 'hello from MCP message demo';
|
|
55
|
+
const connectTimeoutMs = Number.parseInt(process.env.MCP_DEMO_CONNECT_TIMEOUT_MS || '', 10) || 15000;
|
|
56
|
+
const messageTimeoutMs = Number.parseInt(process.env.MCP_DEMO_MESSAGE_TIMEOUT_MS || '', 10) || 8000;
|
|
57
|
+
|
|
58
|
+
const baseOptions = {
|
|
59
|
+
logger: () => {}
|
|
60
|
+
};
|
|
61
|
+
if (useFake) {
|
|
62
|
+
baseOptions.sdkFactory = createFakeSDKFactory();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const serverA = new VdoMcpServer(baseOptions);
|
|
66
|
+
const serverB = new VdoMcpServer(baseOptions);
|
|
67
|
+
let sessionA;
|
|
68
|
+
let sessionB;
|
|
69
|
+
const startedAt = new Date().toISOString();
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
console.log(`[demo-message] mode=${useFake ? 'fake' : 'live'} host=${host} force_turn=${forceTurn}`);
|
|
73
|
+
console.log(`[demo-message] room=${room}`);
|
|
74
|
+
|
|
75
|
+
const connectA = await serverA.callTool('vdo_connect', {
|
|
76
|
+
host,
|
|
77
|
+
room,
|
|
78
|
+
stream_id: 'demo_agent_a',
|
|
79
|
+
force_turn: forceTurn
|
|
80
|
+
});
|
|
81
|
+
sessionA = connectA.session_id;
|
|
82
|
+
|
|
83
|
+
const connectB = await serverB.callTool('vdo_connect', {
|
|
84
|
+
host,
|
|
85
|
+
room,
|
|
86
|
+
stream_id: 'demo_agent_b',
|
|
87
|
+
target_stream_id: 'demo_agent_a',
|
|
88
|
+
force_turn: forceTurn
|
|
89
|
+
});
|
|
90
|
+
sessionB = connectB.session_id;
|
|
91
|
+
|
|
92
|
+
const connected = await waitUntil(async () => {
|
|
93
|
+
const [statusA, statusB] = await Promise.all([
|
|
94
|
+
serverA.callTool('vdo_status', { session_id: sessionA }),
|
|
95
|
+
serverB.callTool('vdo_status', { session_id: sessionB })
|
|
96
|
+
]);
|
|
97
|
+
return statusA.connected && statusB.connected;
|
|
98
|
+
}, connectTimeoutMs, 200);
|
|
99
|
+
if (!connected) {
|
|
100
|
+
throw new Error('demo-message connect timeout');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const id = `demo_msg_${Date.now()}`;
|
|
104
|
+
const payload = {
|
|
105
|
+
type: 'demo.message',
|
|
106
|
+
id,
|
|
107
|
+
text: message,
|
|
108
|
+
sent_at: Date.now()
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const send = await serverA.callTool('vdo_send', {
|
|
112
|
+
session_id: sessionA,
|
|
113
|
+
data: payload
|
|
114
|
+
});
|
|
115
|
+
if (!send.ok) {
|
|
116
|
+
throw new Error('demo-message send returned ok=false');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const received = await waitForMessage(serverB, sessionB, id, messageTimeoutMs);
|
|
120
|
+
if (!received) {
|
|
121
|
+
throw new Error('demo-message receive timeout');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const summary = {
|
|
125
|
+
ok: true,
|
|
126
|
+
started_at: startedAt,
|
|
127
|
+
finished_at: new Date().toISOString(),
|
|
128
|
+
mode: useFake ? 'fake' : 'live',
|
|
129
|
+
room,
|
|
130
|
+
force_turn: forceTurn,
|
|
131
|
+
host,
|
|
132
|
+
session_a: sessionA,
|
|
133
|
+
session_b: sessionB,
|
|
134
|
+
sent_message: payload.text,
|
|
135
|
+
received_message: received.data.text
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
console.log(JSON.stringify(summary, null, 2));
|
|
139
|
+
console.log('demo-message-workflow.js passed');
|
|
140
|
+
} finally {
|
|
141
|
+
try {
|
|
142
|
+
if (sessionA) await serverA.callTool('vdo_disconnect', { session_id: sessionA });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Ignore cleanup failures.
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
if (sessionB) await serverB.callTool('vdo_disconnect', { session_id: sessionB });
|
|
148
|
+
} catch (error) {
|
|
149
|
+
// Ignore cleanup failures.
|
|
150
|
+
}
|
|
151
|
+
try {
|
|
152
|
+
await serverA.shutdownAll();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
// Ignore cleanup failures.
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
await serverB.shutdownAll();
|
|
158
|
+
} catch (error) {
|
|
159
|
+
// Ignore cleanup failures.
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
main().catch((error) => {
|
|
165
|
+
console.error(error.message);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
});
|