@mseep/anklebreaker-unity-mcp 2.30.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/.github/workflows/npm-publish.yml +34 -0
- package/.mcpbignore +9 -0
- package/CHANGELOG.md +85 -0
- package/LICENSE +69 -0
- package/README.md +368 -0
- package/claude-desktop-config.json +12 -0
- package/docs/unity-mcp-architecture.gif +0 -0
- package/docs/unity-mcp-features.gif +0 -0
- package/docs/unity-mcp-showcase-brickbreaker.gif +0 -0
- package/docs/unity-mcp-showcase-castle.gif +0 -0
- package/docs/unity-mcp-showcase-village.gif +0 -0
- package/icon.png +0 -0
- package/manifest.json +178 -0
- package/package.json +26 -0
- package/src/config.js +52 -0
- package/src/index.js +529 -0
- package/src/instance-discovery.js +501 -0
- package/src/state-persistence.js +97 -0
- package/src/tool-tiers.js +348 -0
- package/src/tools/context-tools.js +33 -0
- package/src/tools/editor-tools.js +4521 -0
- package/src/tools/hub-tools.js +96 -0
- package/src/tools/instance-tools.js +114 -0
- package/src/tools/uma-tools.js +627 -0
- package/src/uma-bridge.js +63 -0
- package/src/unity-editor-bridge.js +1690 -0
- package/src/unity-hub.js +125 -0
- package/tests/multi-agent-stress-test.mjs +400 -0
package/src/unity-hub.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Unity Hub CLI wrapper
|
|
2
|
+
import { execFile } from "child_process";
|
|
3
|
+
import { promisify } from "util";
|
|
4
|
+
import { CONFIG } from "./config.js";
|
|
5
|
+
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Execute a Unity Hub CLI command
|
|
10
|
+
*/
|
|
11
|
+
async function runHubCommand(args, timeoutMs = 30000) {
|
|
12
|
+
const hubPath = CONFIG.unityHubPath;
|
|
13
|
+
|
|
14
|
+
// Strategies in order: modern CLI (3.x+), legacy CLI (2.x), shell-based fallback (Windows)
|
|
15
|
+
const strategies = [
|
|
16
|
+
{ name: "modern", args: ["--headless", ...args] },
|
|
17
|
+
{ name: "legacy", args: ["--", "--headless", ...args] },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const errors = [];
|
|
21
|
+
|
|
22
|
+
for (const strategy of strategies) {
|
|
23
|
+
try {
|
|
24
|
+
const { stdout, stderr } = await execFileAsync(hubPath, strategy.args, {
|
|
25
|
+
timeout: timeoutMs,
|
|
26
|
+
windowsHide: true,
|
|
27
|
+
// Capture output even on non-zero exit codes
|
|
28
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
29
|
+
});
|
|
30
|
+
const out = (stdout || "").trim();
|
|
31
|
+
const err = (stderr || "").trim();
|
|
32
|
+
// Some Hub versions return data on stderr, check both
|
|
33
|
+
if (out || err) {
|
|
34
|
+
return { success: true, stdout: out, stderr: err };
|
|
35
|
+
}
|
|
36
|
+
} catch (error) {
|
|
37
|
+
const msg = error.message || String(error);
|
|
38
|
+
const out = (error.stdout || "").trim();
|
|
39
|
+
const err = (error.stderr || "").trim();
|
|
40
|
+
errors.push({ strategy: strategy.name, message: msg, stdout: out, stderr: err });
|
|
41
|
+
// If Hub returned data despite non-zero exit code, it might still be usable
|
|
42
|
+
if (out && !msg.includes("ENOENT")) {
|
|
43
|
+
return { success: true, stdout: out, stderr: err };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// All strategies failed — build helpful error message
|
|
49
|
+
const lastErr = errors[errors.length - 1] || {};
|
|
50
|
+
const isNotFound = errors.some((e) => e.message.includes("ENOENT"));
|
|
51
|
+
const hint = isNotFound
|
|
52
|
+
? ` Unity Hub not found at "${hubPath}". Set UNITY_HUB_PATH environment variable to the correct path.`
|
|
53
|
+
: " Ensure Unity Hub is installed and supports CLI mode (--headless).";
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
error: (lastErr.message || "Unknown error") + hint,
|
|
57
|
+
stdout: lastErr.stdout || "",
|
|
58
|
+
stderr: lastErr.stderr || "",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* List installed Unity Editor versions
|
|
64
|
+
*/
|
|
65
|
+
export async function listInstalledEditors() {
|
|
66
|
+
const result = await runHubCommand(["editors", "--installed"]);
|
|
67
|
+
if (!result.success) return { error: result.error, raw: result.stderr };
|
|
68
|
+
|
|
69
|
+
const editors = [];
|
|
70
|
+
const lines = result.stdout.split("\n").filter((l) => l.trim());
|
|
71
|
+
for (const line of lines) {
|
|
72
|
+
// Parse lines like: "2022.3.0f1 , installed at C:\Program Files\Unity\..."
|
|
73
|
+
const match = line.match(/^([\d.]+\w+)\s*,?\s*installed at\s+(.+)$/i);
|
|
74
|
+
if (match) {
|
|
75
|
+
editors.push({ version: match[1].trim(), path: match[2].trim() });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { editors, raw: result.stdout };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* List available Unity Editor releases
|
|
83
|
+
*/
|
|
84
|
+
export async function listAvailableReleases() {
|
|
85
|
+
const result = await runHubCommand(["editors", "--releases"]);
|
|
86
|
+
if (!result.success) return { error: result.error };
|
|
87
|
+
return { raw: result.stdout };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Install a Unity Editor version with optional modules
|
|
92
|
+
*/
|
|
93
|
+
export async function installEditor(version, modules = []) {
|
|
94
|
+
const args = ["install", "--version", version];
|
|
95
|
+
for (const mod of modules) {
|
|
96
|
+
args.push("--module", mod);
|
|
97
|
+
}
|
|
98
|
+
const result = await runHubCommand(args, 600000); // 10min timeout for installs
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Install modules to an existing editor
|
|
104
|
+
*/
|
|
105
|
+
export async function installModules(version, modules) {
|
|
106
|
+
const args = ["install-modules", "--version", version];
|
|
107
|
+
for (const mod of modules) {
|
|
108
|
+
args.push("--module", mod);
|
|
109
|
+
}
|
|
110
|
+
const result = await runHubCommand(args, 300000);
|
|
111
|
+
return result;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Get or set the editor installation path
|
|
116
|
+
*/
|
|
117
|
+
export async function getInstallPath() {
|
|
118
|
+
const result = await runHubCommand(["install-path"]);
|
|
119
|
+
return result;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function setInstallPath(path) {
|
|
123
|
+
const result = await runHubCommand(["install-path", "--set", path]);
|
|
124
|
+
return result;
|
|
125
|
+
}
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* AnkleBreaker Unity MCP Multi-Agent Stress Test
|
|
4
|
+
*
|
|
5
|
+
* Simulates N agents each submitting M requests concurrently
|
|
6
|
+
* to validate queue fairness, ticket tracking, and timeout handling.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* node tests/multi-agent-stress-test.mjs [--agents N] [--requests M] [--mock]
|
|
10
|
+
*
|
|
11
|
+
* --mock : Start a local mock Unity bridge (no real Unity needed)
|
|
12
|
+
* --agents : Number of concurrent agents (default: 5)
|
|
13
|
+
* --requests: Requests per agent (default: 6)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import http from "http";
|
|
17
|
+
import { randomBytes } from "crypto";
|
|
18
|
+
|
|
19
|
+
// ─── CLI args ────────────────────────────────────────────────────────
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const MOCK_MODE = args.includes("--mock");
|
|
22
|
+
const NUM_AGENTS = parseInt(args.find((_, i, a) => a[i - 1] === "--agents") || "5");
|
|
23
|
+
const REQUESTS_PER_AGENT = parseInt(args.find((_, i, a) => a[i - 1] === "--requests") || "6");
|
|
24
|
+
const BRIDGE_PORT = 7890;
|
|
25
|
+
const BRIDGE_HOST = "127.0.0.1";
|
|
26
|
+
|
|
27
|
+
// ─── Colors ──────────────────────────────────────────────────────────
|
|
28
|
+
const C = {
|
|
29
|
+
reset: "\x1b[0m", bold: "\x1b[1m",
|
|
30
|
+
red: "\x1b[31m", green: "\x1b[32m", yellow: "\x1b[33m",
|
|
31
|
+
blue: "\x1b[34m", magenta: "\x1b[35m", cyan: "\x1b[36m",
|
|
32
|
+
gray: "\x1b[90m",
|
|
33
|
+
};
|
|
34
|
+
const AGENT_COLORS = [C.cyan, C.magenta, C.yellow, C.green, C.blue, C.red];
|
|
35
|
+
|
|
36
|
+
function log(color, prefix, msg) {
|
|
37
|
+
const ts = new Date().toISOString().slice(11, 23);
|
|
38
|
+
console.log(`${C.gray}[${ts}]${C.reset} ${color}${prefix}${C.reset} ${msg}`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── HTTP helpers ────────────────────────────────────────────────────
|
|
42
|
+
function httpRequest(method, path, body = null) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const opts = { hostname: BRIDGE_HOST, port: BRIDGE_PORT, path, method, headers: {} };
|
|
45
|
+
if (body) {
|
|
46
|
+
const data = JSON.stringify(body);
|
|
47
|
+
opts.headers["Content-Type"] = "application/json";
|
|
48
|
+
opts.headers["Content-Length"] = Buffer.byteLength(data);
|
|
49
|
+
}
|
|
50
|
+
const req = http.request(opts, (res) => {
|
|
51
|
+
let chunks = [];
|
|
52
|
+
res.on("data", (c) => chunks.push(c));
|
|
53
|
+
res.on("end", () => {
|
|
54
|
+
const raw = Buffer.concat(chunks).toString();
|
|
55
|
+
try { resolve({ status: res.statusCode, body: JSON.parse(raw) }); }
|
|
56
|
+
catch { resolve({ status: res.statusCode, body: raw }); }
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
req.on("error", reject);
|
|
60
|
+
if (body) req.write(JSON.stringify(body));
|
|
61
|
+
req.end();
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
66
|
+
|
|
67
|
+
// ─── Mock Unity Bridge ──────────────────────────────────────────────
|
|
68
|
+
// Simulates queue/submit, queue/status, queue/info endpoints
|
|
69
|
+
// with realistic processing delays (50-300ms per request)
|
|
70
|
+
let mockServer = null;
|
|
71
|
+
const mockTickets = new Map();
|
|
72
|
+
let mockTicketCounter = 0;
|
|
73
|
+
|
|
74
|
+
function startMockBridge() {
|
|
75
|
+
return new Promise((resolve) => {
|
|
76
|
+
mockServer = http.createServer((req, res) => {
|
|
77
|
+
let body = "";
|
|
78
|
+
req.on("data", (c) => (body += c));
|
|
79
|
+
req.on("end", () => {
|
|
80
|
+
const url = new URL(req.url, `http://${BRIDGE_HOST}:${BRIDGE_PORT}`);
|
|
81
|
+
const path = url.pathname;
|
|
82
|
+
|
|
83
|
+
// ── queue/submit ──
|
|
84
|
+
if (path === "/api/queue/submit" && req.method === "POST") {
|
|
85
|
+
const payload = JSON.parse(body);
|
|
86
|
+
const ticketId = `ticket-${++mockTicketCounter}`;
|
|
87
|
+
const processingTime = 50 + Math.random() * 250; // 50-300ms
|
|
88
|
+
|
|
89
|
+
const ticket = {
|
|
90
|
+
ticketId,
|
|
91
|
+
status: "Queued",
|
|
92
|
+
agentId: payload.agentId || "unknown",
|
|
93
|
+
apiPath: payload.apiPath,
|
|
94
|
+
submittedAt: new Date().toISOString(),
|
|
95
|
+
result: null,
|
|
96
|
+
};
|
|
97
|
+
mockTickets.set(ticketId, ticket);
|
|
98
|
+
|
|
99
|
+
// Simulate async processing
|
|
100
|
+
setTimeout(() => {
|
|
101
|
+
ticket.status = "Completed";
|
|
102
|
+
ticket.completedAt = new Date().toISOString();
|
|
103
|
+
ticket.result = {
|
|
104
|
+
success: true,
|
|
105
|
+
data: `Mock result for ${payload.apiPath} (agent: ${payload.agentId})`,
|
|
106
|
+
processingTimeMs: Math.round(processingTime),
|
|
107
|
+
};
|
|
108
|
+
}, processingTime);
|
|
109
|
+
|
|
110
|
+
res.writeHead(202, { "Content-Type": "application/json" });
|
|
111
|
+
res.end(JSON.stringify({ ticketId, status: "Queued", position: mockTickets.size }));
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── queue/status ──
|
|
116
|
+
if (path === "/api/queue/status") {
|
|
117
|
+
const ticketId = url.searchParams.get("ticketId");
|
|
118
|
+
const ticket = mockTickets.get(ticketId);
|
|
119
|
+
if (!ticket) {
|
|
120
|
+
res.writeHead(404, { "Content-Type": "application/json" });
|
|
121
|
+
res.end(JSON.stringify({ error: "Ticket not found" }));
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
125
|
+
res.end(JSON.stringify(ticket));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── queue/info ──
|
|
130
|
+
if (path === "/api/queue/info") {
|
|
131
|
+
const agents = new Map();
|
|
132
|
+
let pending = 0;
|
|
133
|
+
for (const t of mockTickets.values()) {
|
|
134
|
+
if (t.status === "Queued") {
|
|
135
|
+
pending++;
|
|
136
|
+
agents.set(t.agentId, (agents.get(t.agentId) || 0) + 1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
140
|
+
res.end(JSON.stringify({
|
|
141
|
+
totalPending: pending,
|
|
142
|
+
activeAgents: agents.size,
|
|
143
|
+
perAgent: Object.fromEntries(agents),
|
|
144
|
+
completedCacheSize: [...mockTickets.values()].filter((t) => t.status === "Completed").length,
|
|
145
|
+
}));
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ── ping ──
|
|
150
|
+
if (path === "/api/ping") {
|
|
151
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
152
|
+
res.end(JSON.stringify({ status: "ok", mock: true }));
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Fallback: simulate legacy endpoints ──
|
|
157
|
+
const delay = 30 + Math.random() * 100;
|
|
158
|
+
setTimeout(() => {
|
|
159
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
160
|
+
res.end(JSON.stringify({ success: true, mock: true, path, data: {} }));
|
|
161
|
+
}, delay);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
mockServer.listen(BRIDGE_PORT, BRIDGE_HOST, () => {
|
|
166
|
+
log(C.green, "[MOCK]", `Mock Unity bridge running on ${BRIDGE_HOST}:${BRIDGE_PORT}`);
|
|
167
|
+
resolve();
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Agent Simulation ────────────────────────────────────────────────
|
|
173
|
+
// Each agent: submit request → get ticket → poll until done
|
|
174
|
+
async function simulateAgent(agentIdx, agentId) {
|
|
175
|
+
const color = AGENT_COLORS[agentIdx % AGENT_COLORS.length];
|
|
176
|
+
const prefix = `[Agent-${agentIdx}]`;
|
|
177
|
+
const results = [];
|
|
178
|
+
|
|
179
|
+
// Varied API paths to test read batching vs write serialization
|
|
180
|
+
const apiPaths = [
|
|
181
|
+
"editor/state", // read
|
|
182
|
+
"scene/hierarchy", // read
|
|
183
|
+
"gameobject/info", // read
|
|
184
|
+
"gameobject/create", // write
|
|
185
|
+
"component/set_property", // write
|
|
186
|
+
"scene/info", // read
|
|
187
|
+
"asset/list", // read
|
|
188
|
+
"script/create", // write
|
|
189
|
+
"material/create", // write
|
|
190
|
+
"editor/ping", // read
|
|
191
|
+
];
|
|
192
|
+
|
|
193
|
+
log(color, prefix, `Starting — will submit ${REQUESTS_PER_AGENT} requests (id: ${agentId})`);
|
|
194
|
+
|
|
195
|
+
for (let i = 0; i < REQUESTS_PER_AGENT; i++) {
|
|
196
|
+
const apiPath = apiPaths[i % apiPaths.length];
|
|
197
|
+
const startTime = Date.now();
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
// 1. Submit to queue
|
|
201
|
+
const submitRes = await httpRequest("POST", "/api/queue/submit", {
|
|
202
|
+
apiPath,
|
|
203
|
+
body: JSON.stringify({ test: true, agentIdx, requestIdx: i }),
|
|
204
|
+
agentId,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
if (submitRes.status !== 202) {
|
|
208
|
+
log(C.red, prefix, `Submit FAILED (HTTP ${submitRes.status}): ${JSON.stringify(submitRes.body)}`);
|
|
209
|
+
results.push({ apiPath, status: "submit_failed", elapsed: Date.now() - startTime });
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const ticketId = submitRes.body.ticketId;
|
|
214
|
+
log(color, prefix, `Submitted ${C.bold}${apiPath}${C.reset} → ticket ${ticketId}`);
|
|
215
|
+
|
|
216
|
+
// 2. Poll for result (exponential backoff)
|
|
217
|
+
let pollInterval = 50;
|
|
218
|
+
let completed = false;
|
|
219
|
+
const pollStart = Date.now();
|
|
220
|
+
|
|
221
|
+
while (Date.now() - pollStart < 30000) { // 30s timeout
|
|
222
|
+
await sleep(pollInterval);
|
|
223
|
+
pollInterval = Math.min(pollInterval * 1.5, 1000);
|
|
224
|
+
|
|
225
|
+
const statusRes = await httpRequest("GET", `/api/queue/status?ticketId=${ticketId}`);
|
|
226
|
+
|
|
227
|
+
if (statusRes.body.status === "Completed") {
|
|
228
|
+
const elapsed = Date.now() - startTime;
|
|
229
|
+
log(color, prefix, ` ✓ ${ticketId} completed in ${C.bold}${elapsed}ms${C.reset}`);
|
|
230
|
+
results.push({ apiPath, ticketId, status: "completed", elapsed });
|
|
231
|
+
completed = true;
|
|
232
|
+
break;
|
|
233
|
+
} else if (statusRes.body.status === "Failed" || statusRes.body.status === "TimedOut") {
|
|
234
|
+
const elapsed = Date.now() - startTime;
|
|
235
|
+
log(C.red, prefix, ` ✗ ${ticketId} ${statusRes.body.status}: ${statusRes.body.errorMessage || "unknown"}`);
|
|
236
|
+
results.push({ apiPath, ticketId, status: statusRes.body.status, elapsed });
|
|
237
|
+
completed = true;
|
|
238
|
+
break;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (!completed) {
|
|
243
|
+
log(C.red, prefix, ` ✗ ${ticketId} POLL TIMEOUT (30s)`);
|
|
244
|
+
results.push({ apiPath, ticketId, status: "poll_timeout", elapsed: Date.now() - startTime });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
} catch (err) {
|
|
248
|
+
log(C.red, prefix, `Error: ${err.message}`);
|
|
249
|
+
results.push({ apiPath, status: "error", error: err.message, elapsed: Date.now() - startTime });
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Small stagger between requests from same agent (10-50ms)
|
|
253
|
+
await sleep(10 + Math.random() * 40);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
log(color, prefix, `Finished — ${results.filter(r => r.status === "completed").length}/${REQUESTS_PER_AGENT} succeeded`);
|
|
257
|
+
return { agentId, agentIdx, results };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ─── Queue Info Poller ───────────────────────────────────────────────
|
|
261
|
+
// Periodically checks queue state during the test
|
|
262
|
+
async function pollQueueInfo(abortSignal) {
|
|
263
|
+
while (!abortSignal.aborted) {
|
|
264
|
+
try {
|
|
265
|
+
const res = await httpRequest("GET", "/api/queue/info");
|
|
266
|
+
if (res.body.totalPending > 0) {
|
|
267
|
+
const agentInfo = res.body.perAgent
|
|
268
|
+
? Object.entries(res.body.perAgent).map(([a, c]) => `${a.slice(-8)}:${c}`).join(", ")
|
|
269
|
+
: "n/a";
|
|
270
|
+
log(C.gray, "[QUEUE]", `Pending: ${res.body.totalPending} | Agents: ${res.body.activeAgents} | [${agentInfo}]`);
|
|
271
|
+
}
|
|
272
|
+
} catch { /* bridge might be between requests */ }
|
|
273
|
+
await sleep(500);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── Main ────────────────────────────────────────────────────────────
|
|
278
|
+
async function main() {
|
|
279
|
+
console.log(`\n${C.bold}═══════════════════════════════════════════════════════════${C.reset}`);
|
|
280
|
+
console.log(`${C.bold} AB Unity MCP Multi-Agent Stress Test${C.reset}`);
|
|
281
|
+
console.log(`${C.bold}═══════════════════════════════════════════════════════════${C.reset}`);
|
|
282
|
+
console.log(` Agents: ${NUM_AGENTS} | Requests/agent: ${REQUESTS_PER_AGENT} | Total: ${NUM_AGENTS * REQUESTS_PER_AGENT}`);
|
|
283
|
+
console.log(` Mode: ${MOCK_MODE ? "MOCK (simulated bridge)" : "LIVE (real Unity bridge)"}`);
|
|
284
|
+
console.log(`${C.bold}═══════════════════════════════════════════════════════════${C.reset}\n`);
|
|
285
|
+
|
|
286
|
+
// Start mock if needed
|
|
287
|
+
if (MOCK_MODE) {
|
|
288
|
+
await startMockBridge();
|
|
289
|
+
} else {
|
|
290
|
+
// Verify bridge is running
|
|
291
|
+
try {
|
|
292
|
+
await httpRequest("GET", "/api/ping");
|
|
293
|
+
log(C.green, "[INIT]", "Unity bridge is reachable");
|
|
294
|
+
} catch {
|
|
295
|
+
console.error(`${C.red}ERROR: Unity bridge not reachable at ${BRIDGE_HOST}:${BRIDGE_PORT}`);
|
|
296
|
+
console.error(`Run with --mock to use a simulated bridge${C.reset}`);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Generate agent IDs
|
|
302
|
+
const agents = Array.from({ length: NUM_AGENTS }, (_, i) => ({
|
|
303
|
+
idx: i,
|
|
304
|
+
id: `test-agent-${process.pid}-${randomBytes(3).toString("hex")}`,
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
log(C.green, "[INIT]", `Agent IDs: ${agents.map(a => a.id.slice(-8)).join(", ")}`);
|
|
308
|
+
|
|
309
|
+
// Start queue info polling
|
|
310
|
+
const abortController = new AbortController();
|
|
311
|
+
const queuePollPromise = pollQueueInfo(abortController.signal);
|
|
312
|
+
|
|
313
|
+
// Launch all agents concurrently
|
|
314
|
+
const testStart = Date.now();
|
|
315
|
+
const agentPromises = agents.map((a) => simulateAgent(a.idx, a.id));
|
|
316
|
+
const allResults = await Promise.all(agentPromises);
|
|
317
|
+
const totalElapsed = Date.now() - testStart;
|
|
318
|
+
|
|
319
|
+
// Stop queue polling
|
|
320
|
+
abortController.abort();
|
|
321
|
+
|
|
322
|
+
// ── Summary ──
|
|
323
|
+
console.log(`\n${C.bold}═══════════════════════════════════════════════════════════${C.reset}`);
|
|
324
|
+
console.log(`${C.bold} TEST RESULTS${C.reset}`);
|
|
325
|
+
console.log(`${C.bold}═══════════════════════════════════════════════════════════${C.reset}\n`);
|
|
326
|
+
|
|
327
|
+
let totalCompleted = 0, totalFailed = 0, totalTimeout = 0;
|
|
328
|
+
const allElapsed = [];
|
|
329
|
+
|
|
330
|
+
for (const agentResult of allResults) {
|
|
331
|
+
const color = AGENT_COLORS[agentResult.agentIdx % AGENT_COLORS.length];
|
|
332
|
+
const completed = agentResult.results.filter(r => r.status === "completed");
|
|
333
|
+
const failed = agentResult.results.filter(r => r.status !== "completed");
|
|
334
|
+
totalCompleted += completed.length;
|
|
335
|
+
totalFailed += failed.filter(r => r.status !== "poll_timeout").length;
|
|
336
|
+
totalTimeout += failed.filter(r => r.status === "poll_timeout").length;
|
|
337
|
+
|
|
338
|
+
const times = completed.map(r => r.elapsed);
|
|
339
|
+
allElapsed.push(...times);
|
|
340
|
+
const avg = times.length ? Math.round(times.reduce((a, b) => a + b, 0) / times.length) : 0;
|
|
341
|
+
const max = times.length ? Math.max(...times) : 0;
|
|
342
|
+
const min = times.length ? Math.min(...times) : 0;
|
|
343
|
+
|
|
344
|
+
console.log(`${color} Agent-${agentResult.agentIdx}${C.reset} (${agentResult.agentId.slice(-8)}): ` +
|
|
345
|
+
`${C.green}${completed.length} ok${C.reset} / ` +
|
|
346
|
+
`${failed.length > 0 ? C.red : C.gray}${failed.length} fail${C.reset} | ` +
|
|
347
|
+
`avg: ${avg}ms min: ${min}ms max: ${max}ms`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
console.log(`\n${C.bold} Totals:${C.reset}`);
|
|
351
|
+
console.log(` Completed: ${C.green}${totalCompleted}${C.reset} / ${NUM_AGENTS * REQUESTS_PER_AGENT}`);
|
|
352
|
+
if (totalFailed > 0) console.log(` Failed: ${C.red}${totalFailed}${C.reset}`);
|
|
353
|
+
if (totalTimeout > 0) console.log(` Timed out: ${C.red}${totalTimeout}${C.reset}`);
|
|
354
|
+
console.log(` Wall time: ${C.bold}${totalElapsed}ms${C.reset}`);
|
|
355
|
+
|
|
356
|
+
if (allElapsed.length > 0) {
|
|
357
|
+
allElapsed.sort((a, b) => a - b);
|
|
358
|
+
const p50 = allElapsed[Math.floor(allElapsed.length * 0.5)];
|
|
359
|
+
const p95 = allElapsed[Math.floor(allElapsed.length * 0.95)];
|
|
360
|
+
const p99 = allElapsed[Math.floor(allElapsed.length * 0.99)];
|
|
361
|
+
const avg = Math.round(allElapsed.reduce((a, b) => a + b, 0) / allElapsed.length);
|
|
362
|
+
console.log(`\n${C.bold} Latency (all requests):${C.reset}`);
|
|
363
|
+
console.log(` avg: ${avg}ms | p50: ${p50}ms | p95: ${p95}ms | p99: ${p99}ms`);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Check fairness: did any agent get starved?
|
|
367
|
+
console.log(`\n${C.bold} Fairness Check:${C.reset}`);
|
|
368
|
+
const completionOrder = allResults.flatMap(r =>
|
|
369
|
+
r.results.filter(x => x.status === "completed").map(x => ({ agent: r.agentIdx, elapsed: x.elapsed }))
|
|
370
|
+
).sort((a, b) => a.elapsed - b.elapsed);
|
|
371
|
+
|
|
372
|
+
// Group completion times into quartiles
|
|
373
|
+
const quartileSize = Math.ceil(completionOrder.length / 4);
|
|
374
|
+
for (let q = 0; q < 4; q++) {
|
|
375
|
+
const slice = completionOrder.slice(q * quartileSize, (q + 1) * quartileSize);
|
|
376
|
+
const agentCounts = {};
|
|
377
|
+
for (const item of slice) {
|
|
378
|
+
agentCounts[item.agent] = (agentCounts[item.agent] || 0) + 1;
|
|
379
|
+
}
|
|
380
|
+
const dist = Object.entries(agentCounts).map(([a, c]) => `A${a}:${c}`).join(" ");
|
|
381
|
+
console.log(` Q${q + 1}: ${dist}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
console.log(`\n${C.bold}═══════════════════════════════════════════════════════════${C.reset}\n`);
|
|
385
|
+
|
|
386
|
+
// Get final queue info
|
|
387
|
+
try {
|
|
388
|
+
const finalInfo = await httpRequest("GET", "/api/queue/info");
|
|
389
|
+
log(C.gray, "[FINAL]", `Queue state: ${JSON.stringify(finalInfo.body)}`);
|
|
390
|
+
} catch { /* ok */ }
|
|
391
|
+
|
|
392
|
+
if (mockServer) mockServer.close();
|
|
393
|
+
process.exit(totalFailed + totalTimeout > 0 ? 1 : 0);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
main().catch((err) => {
|
|
397
|
+
console.error(`${C.red}Fatal: ${err.message}${C.reset}`);
|
|
398
|
+
if (mockServer) mockServer.close();
|
|
399
|
+
process.exit(1);
|
|
400
|
+
});
|