unity-open-mcp 1.4.0 → 1.6.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/dist/capabilities/cost-hints.js +73 -0
- package/dist/capabilities/cost-hints.js.map +1 -1
- package/dist/capabilities/intent-groups.js +76 -4
- package/dist/capabilities/intent-groups.js.map +1 -1
- package/dist/capabilities/lifecycle.js +9 -0
- package/dist/capabilities/lifecycle.js.map +1 -1
- package/dist/capabilities/tool-groups.js +75 -17
- package/dist/capabilities/tool-groups.js.map +1 -1
- package/dist/compressible-router.d.ts +4 -1
- package/dist/compressible-router.js +27 -11
- package/dist/compressible-router.js.map +1 -1
- package/dist/constants.d.ts +2 -2
- package/dist/constants.js +2 -2
- package/dist/event-stream.d.ts +10 -0
- package/dist/event-stream.js +29 -0
- package/dist/event-stream.js.map +1 -1
- package/dist/game-view-settle.d.ts +18 -0
- package/dist/game-view-settle.js +81 -0
- package/dist/game-view-settle.js.map +1 -0
- package/dist/jobs/adapters.d.ts +59 -0
- package/dist/jobs/adapters.js +226 -0
- package/dist/jobs/adapters.js.map +1 -1
- package/dist/live-client.d.ts +17 -60
- package/dist/live-client.js +52 -87
- package/dist/live-client.js.map +1 -1
- package/dist/local-tools.js +3 -0
- package/dist/local-tools.js.map +1 -1
- package/dist/offline/api.d.ts +2 -0
- package/dist/offline/api.js +4 -2
- package/dist/offline/api.js.map +1 -1
- package/dist/offline/model.js +1 -1
- package/dist/offline/model.js.map +1 -1
- package/dist/offline/parse.d.ts +1 -1
- package/dist/offline/parse.js +20 -8
- package/dist/offline/parse.js.map +1 -1
- package/dist/playmode-routes.d.ts +17 -0
- package/dist/playmode-routes.js +50 -0
- package/dist/playmode-routes.js.map +1 -0
- package/dist/playtest/png.d.ts +27 -0
- package/dist/playtest/png.js +248 -0
- package/dist/playtest/png.js.map +1 -0
- package/dist/playtest/recorder.d.ts +203 -0
- package/dist/playtest/recorder.js +945 -0
- package/dist/playtest/recorder.js.map +1 -0
- package/dist/playtest/redact.d.ts +16 -0
- package/dist/playtest/redact.js +124 -0
- package/dist/playtest/redact.js.map +1 -0
- package/dist/playtest/report.d.ts +73 -0
- package/dist/playtest/report.js +477 -0
- package/dist/playtest/report.js.map +1 -0
- package/dist/playtest/session-store.d.ts +78 -0
- package/dist/playtest/session-store.js +224 -0
- package/dist/playtest/session-store.js.map +1 -0
- package/dist/skill/SKILL.md +22 -3
- package/dist/skill/references/senses-and-tests.md +3 -0
- package/dist/skill/references/yaml-and-offline-work.md +1 -1
- package/dist/tool-router.d.ts +44 -0
- package/dist/tool-router.js +249 -7
- package/dist/tool-router.js.map +1 -1
- package/dist/tools/batch-execute.js +5 -1
- package/dist/tools/batch-execute.js.map +1 -1
- package/dist/tools/capture-inline.js +30 -12
- package/dist/tools/capture-inline.js.map +1 -1
- package/dist/tools/capture-series.d.ts +46 -0
- package/dist/tools/capture-series.js +116 -0
- package/dist/tools/capture-series.js.map +1 -0
- package/dist/tools/game-view.d.ts +44 -0
- package/dist/tools/game-view.js +103 -0
- package/dist/tools/game-view.js.map +1 -0
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +34 -7
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/inputsim-gesture.d.ts +46 -0
- package/dist/tools/inputsim-gesture.js +123 -0
- package/dist/tools/inputsim-gesture.js.map +1 -0
- package/dist/tools/inputsim-pointer.js +35 -2
- package/dist/tools/inputsim-pointer.js.map +1 -1
- package/dist/tools/inputsim-touch.js +42 -22
- package/dist/tools/inputsim-touch.js.map +1 -1
- package/dist/tools/jobs.js +1 -1
- package/dist/tools/jobs.js.map +1 -1
- package/dist/tools/player-state.d.ts +42 -0
- package/dist/tools/player-state.js +142 -0
- package/dist/tools/player-state.js.map +1 -0
- package/dist/tools/playtest-conditions.d.ts +334 -0
- package/dist/tools/playtest-conditions.js +99 -0
- package/dist/tools/playtest-conditions.js.map +1 -0
- package/dist/tools/playtest-session.d.ts +45 -0
- package/dist/tools/playtest-session.js +105 -0
- package/dist/tools/playtest-session.js.map +1 -0
- package/dist/tools/playtest-wait.d.ts +46 -0
- package/dist/tools/playtest-wait.js +86 -0
- package/dist/tools/playtest-wait.js.map +1 -0
- package/dist/tools/screenshot.js +31 -8
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/text-target.d.ts +22 -0
- package/dist/tools/text-target.js +39 -0
- package/dist/tools/text-target.js.map +1 -0
- package/dist/tools/ui-snapshot.d.ts +41 -0
- package/dist/tools/ui-snapshot.js +87 -0
- package/dist/tools/ui-snapshot.js.map +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,945 @@
|
|
|
1
|
+
// Playtest session recorder (unity_open_mcp_playtest_session).
|
|
2
|
+
//
|
|
3
|
+
// The MCP server sees every tool call, so the session log lives here; the
|
|
4
|
+
// bridge only writes frames to its capture folder. While an agent owner has an
|
|
5
|
+
// open session, each of its input / wait / capture / project-command calls
|
|
6
|
+
// (and play-mode / Game view changes) is appended to the session log when the
|
|
7
|
+
// call ends: tool, redacted arguments, status, duration, the frames it wrote,
|
|
8
|
+
// and the console errors / exceptions logged during it. Errors come from the
|
|
9
|
+
// bridge event stream (timestamped, with stacks) correlated by time window;
|
|
10
|
+
// the call's own `logs[]` and a play-mode operation's `errors` fill in what
|
|
11
|
+
// the stream missed (it reconnects after a domain reload). An error logged
|
|
12
|
+
// between calls belongs to the call before it (`after`) — a tap's consequence
|
|
13
|
+
// shows up a few frames later.
|
|
14
|
+
//
|
|
15
|
+
// Ownership: the owner is the request identity jobs use (project + agent id +
|
|
16
|
+
// per-request port override). One open session per owner; other owners' calls
|
|
17
|
+
// are never recorded into it. The log is appended per line as events happen,
|
|
18
|
+
// so it survives a domain reload and an MCP server restart; a restarted server
|
|
19
|
+
// adopts a session whose owning process is gone (`resume` / `end` with
|
|
20
|
+
// `session`), and the report lists the call that was in flight when the old
|
|
21
|
+
// process stopped.
|
|
22
|
+
import { randomBytes } from "node:crypto";
|
|
23
|
+
import { copyFileSync, linkSync, statSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { basename, isAbsolute, join } from "node:path";
|
|
25
|
+
import { toolOutput, toolResultBody } from "../jobs/adapters.js";
|
|
26
|
+
import { ALL_TOOLS } from "../tools/index.js";
|
|
27
|
+
import { isPidAlive } from "../instance-discovery.js";
|
|
28
|
+
import { Redactor } from "./redact.js";
|
|
29
|
+
import { FRAMES_DIR, REPORT_DIR, SESSION_FORMAT, SESSION_ID_PATTERN, SessionStore, readProjectIdentity, sessionId, } from "./session-store.js";
|
|
30
|
+
import { writeReport } from "./report.js";
|
|
31
|
+
export const PLAYTEST_SESSION_TOOL = "unity_open_mcp_playtest_session";
|
|
32
|
+
export const RECORDING_LIMITS = {
|
|
33
|
+
/** Recorded calls per session; later calls are counted, not logged. */
|
|
34
|
+
maxCalls: 500,
|
|
35
|
+
maxMarks: 200,
|
|
36
|
+
/** Frames linked / copied into the report folder per session. */
|
|
37
|
+
maxFrames: 300,
|
|
38
|
+
maxFrameBytes: 512 * 1024 * 1024,
|
|
39
|
+
/** Thumbnails written by `end` (downscaled; the full frames stay files). */
|
|
40
|
+
maxThumbnails: 60,
|
|
41
|
+
maxErrorsPerCall: 20,
|
|
42
|
+
/** Occurrences of one console message logged per session; later ones are counted, not logged. */
|
|
43
|
+
maxErrorRepeats: 20,
|
|
44
|
+
/** Console errors logged per session; later ones are counted, not logged. */
|
|
45
|
+
maxErrors: 2_000,
|
|
46
|
+
maxEditorEvents: 200,
|
|
47
|
+
maxArgsChars: 2_000,
|
|
48
|
+
maxPendingErrors: 500,
|
|
49
|
+
};
|
|
50
|
+
const INPUT_TOOLS = new Set([
|
|
51
|
+
"unity_open_mcp_inputsim_touch", "unity_open_mcp_inputsim_pointer", "unity_open_mcp_inputsim_pointer3d",
|
|
52
|
+
"unity_open_mcp_inputsim_key", "unity_open_mcp_inputsim_gesture", "unity_open_mcp_inputsim_step",
|
|
53
|
+
]);
|
|
54
|
+
const CAPTURE_TOOLS = new Set([
|
|
55
|
+
"unity_senses_screenshot", "unity_senses_screenshot_window", "unity_senses_screenshot_camera",
|
|
56
|
+
"unity_senses_capture_inline", "unity_senses_capture_series", "unity_senses_visual_compare",
|
|
57
|
+
]);
|
|
58
|
+
const WAIT_TOOL = "unity_open_mcp_playtest_wait";
|
|
59
|
+
const JOBS_TOOL = "unity_open_mcp_jobs";
|
|
60
|
+
const COMMANDS_TOOL = "unity_open_mcp_project_commands";
|
|
61
|
+
function kindOfTool(tool) {
|
|
62
|
+
if (INPUT_TOOLS.has(tool))
|
|
63
|
+
return "input";
|
|
64
|
+
if (CAPTURE_TOOLS.has(tool))
|
|
65
|
+
return "capture";
|
|
66
|
+
if (tool === WAIT_TOOL)
|
|
67
|
+
return "wait";
|
|
68
|
+
if (tool.startsWith("project."))
|
|
69
|
+
return "command";
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
/** Which recorded category a call falls in (null = not recorded). */
|
|
73
|
+
export function classifyCall(tool, args) {
|
|
74
|
+
const direct = kindOfTool(tool);
|
|
75
|
+
if (direct)
|
|
76
|
+
return { kind: direct, label: tool, args };
|
|
77
|
+
if (tool === COMMANDS_TOOL && args.action === "invoke")
|
|
78
|
+
return { kind: "command", label: `${COMMANDS_TOOL} ${String(args.command_id ?? "")}`.trim(), args };
|
|
79
|
+
if (tool === JOBS_TOOL && args.action === "start" && typeof args.tool_or_command === "string") {
|
|
80
|
+
const inner = kindOfTool(args.tool_or_command);
|
|
81
|
+
if (inner)
|
|
82
|
+
return { kind: inner, label: `${JOBS_TOOL} start ${args.tool_or_command}`, args };
|
|
83
|
+
}
|
|
84
|
+
if (tool === "unity_open_mcp_editor_set_state")
|
|
85
|
+
return { kind: "control", label: tool, args };
|
|
86
|
+
if (tool === "unity_open_mcp_game_view" && (args.action === "set_resolution" || args.action === "set_device"))
|
|
87
|
+
return { kind: "control", label: tool, args };
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
export function ownerKey(owner) {
|
|
91
|
+
return `${owner.project}\u0000${owner.port ?? ""}\u0000${owner.agent}`;
|
|
92
|
+
}
|
|
93
|
+
export class PlaytestError extends Error {
|
|
94
|
+
code;
|
|
95
|
+
detail;
|
|
96
|
+
constructor(code, message, detail = {}) {
|
|
97
|
+
super(message);
|
|
98
|
+
this.code = code;
|
|
99
|
+
this.detail = detail;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
103
|
+
const ERROR_TYPES = new Set(["error", "exception", "assert"]);
|
|
104
|
+
const TERMINAL_JOB_STATES = new Set(["succeeded", "failed", "cancelled", "orphaned"]);
|
|
105
|
+
/** Errors are told apart by their first message line; the stream says Exception where logs[] says error. */
|
|
106
|
+
const messageKey = (message) => message.split("\n")[0].trim().slice(0, 200);
|
|
107
|
+
const clip = (value, max) => (value.length > max ? `${value.slice(0, max)}…` : value);
|
|
108
|
+
function boundedJson(value, max) {
|
|
109
|
+
const text = JSON.stringify(value);
|
|
110
|
+
if (text === undefined || text.length <= max)
|
|
111
|
+
return value;
|
|
112
|
+
return { truncated: true, json: clip(text, max) };
|
|
113
|
+
}
|
|
114
|
+
const SUMMARY_KEYS = [
|
|
115
|
+
"action", "status", "outcome", "fired", "elapsed_ms", "realtime_ms", "backend", "target", "resolved", "targetHit", "topHit",
|
|
116
|
+
"blockedBy", "framesAdvanced", "released", "isPlaying", "isPaused", "mode", "size", "device", "settled", "capturedAt",
|
|
117
|
+
"time_scale", "frames", "exception",
|
|
118
|
+
];
|
|
119
|
+
export class PlaytestRecorder {
|
|
120
|
+
store;
|
|
121
|
+
redactor;
|
|
122
|
+
projectPath;
|
|
123
|
+
now;
|
|
124
|
+
pid;
|
|
125
|
+
isAlive;
|
|
126
|
+
streamConnected;
|
|
127
|
+
open = new Map();
|
|
128
|
+
/** Owners whose `start` is between its check and its session (awaiting the bridge). */
|
|
129
|
+
starting = new Set();
|
|
130
|
+
identity = null;
|
|
131
|
+
constructor(options) {
|
|
132
|
+
this.store = new SessionStore(options.root);
|
|
133
|
+
this.projectPath = options.projectPath;
|
|
134
|
+
this.redactor = new Redactor(options.knownSecrets ?? []);
|
|
135
|
+
this.now = options.now ?? Date.now;
|
|
136
|
+
this.pid = options.pid ?? process.pid;
|
|
137
|
+
this.isAlive = options.isAlive ?? isPidAlive;
|
|
138
|
+
this.streamConnected = options.streamConnected ?? (() => true);
|
|
139
|
+
}
|
|
140
|
+
/** Cheap hot-path check: is any session open in this process? */
|
|
141
|
+
get openCount() { return this.open.size; }
|
|
142
|
+
isRecording(owner) {
|
|
143
|
+
return this.open.size > 0 && this.open.has(ownerKey(owner));
|
|
144
|
+
}
|
|
145
|
+
// ---------------------------------------------------------------------
|
|
146
|
+
// Hook: one recorded call
|
|
147
|
+
// ---------------------------------------------------------------------
|
|
148
|
+
begin(owner, tool, args) {
|
|
149
|
+
const session = this.open.get(ownerKey(owner));
|
|
150
|
+
if (!session)
|
|
151
|
+
return null;
|
|
152
|
+
const classified = classifyCall(tool, args);
|
|
153
|
+
if (!classified)
|
|
154
|
+
return null;
|
|
155
|
+
this.settle(session);
|
|
156
|
+
if (session.calls >= RECORDING_LIMITS.maxCalls) {
|
|
157
|
+
this.dropCall(session);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
const seq = session.nextSeq++;
|
|
161
|
+
session.calls++;
|
|
162
|
+
const start = this.now();
|
|
163
|
+
session.inflight.set(seq, start);
|
|
164
|
+
// The arguments go with the start line, so a call lost in flight still shows what it was.
|
|
165
|
+
this.append(session, {
|
|
166
|
+
k: "call_started", seq, tool: classified.label, kind: classified.kind, at: iso(start),
|
|
167
|
+
args: boundedJson(this.redactor.value(asSent(classified.label, args)), RECORDING_LIMITS.maxArgsChars),
|
|
168
|
+
});
|
|
169
|
+
return { session, seq, tool: classified.label, kind: classified.kind, start };
|
|
170
|
+
}
|
|
171
|
+
finish(handle, _args, result, thrown) {
|
|
172
|
+
const { session, seq } = handle;
|
|
173
|
+
session.inflight.delete(seq);
|
|
174
|
+
if (!this.isOpen(session))
|
|
175
|
+
return;
|
|
176
|
+
const end = this.now();
|
|
177
|
+
const body = result ? toolResultBody(result) : {};
|
|
178
|
+
const output = result ? toolOutput(result) : {};
|
|
179
|
+
const error = (output.error ?? body.error);
|
|
180
|
+
const jobId = typeof body.job_id === "string" ? body.job_id : undefined;
|
|
181
|
+
const status = thrown ? "error" : result?.isError || error ? "error" : jobId && !TERMINAL_JOB_STATES.has(String(body.state)) ? "job_started" : "ok";
|
|
182
|
+
const { errors, total, responseOnly } = this.collectErrors(session, handle.start, end, output, body);
|
|
183
|
+
const captures = result ? this.collectCaptures(session, seq, output, result) : [];
|
|
184
|
+
this.pushWindow(session, { seq, start: handle.start, end, messages: responseOnly });
|
|
185
|
+
if (jobId && status === "job_started")
|
|
186
|
+
session.jobs.set(jobId, { seq, kind: handle.kind, tool: handle.tool });
|
|
187
|
+
session.sinceMark.calls++;
|
|
188
|
+
session.sinceMark.errors += total;
|
|
189
|
+
session.sinceMark.captures += captures.length;
|
|
190
|
+
if (status === "error")
|
|
191
|
+
session.sinceMark.failed++;
|
|
192
|
+
const entry = {
|
|
193
|
+
k: "call", seq, tool: handle.tool, kind: handle.kind,
|
|
194
|
+
at: iso(handle.start), ended_at: iso(end), duration_ms: end - handle.start, status,
|
|
195
|
+
};
|
|
196
|
+
if (thrown)
|
|
197
|
+
entry.error = { code: "internal_error", message: this.redactor.text(clip(String(thrown instanceof Error ? thrown.message : thrown), 500)) };
|
|
198
|
+
else if (error)
|
|
199
|
+
entry.error = this.redactor.value({ code: error.code ?? "error", message: clip(String(error.message ?? ""), 500) });
|
|
200
|
+
if (jobId)
|
|
201
|
+
entry.job_id = jobId;
|
|
202
|
+
if (!this.streamConnected())
|
|
203
|
+
entry.stream = false;
|
|
204
|
+
const summary = this.summarize(handle.kind, output);
|
|
205
|
+
if (summary)
|
|
206
|
+
entry.summary = summary;
|
|
207
|
+
if (captures.length)
|
|
208
|
+
entry.captures = captures;
|
|
209
|
+
if (errors.length)
|
|
210
|
+
entry.errors = errors;
|
|
211
|
+
this.append(session, entry);
|
|
212
|
+
this.settle(session);
|
|
213
|
+
}
|
|
214
|
+
/** `jobs wait` / `jobs status` reached a terminal state for a job a recorded call started. */
|
|
215
|
+
observeJob(owner, args, result) {
|
|
216
|
+
const session = this.open.get(ownerKey(owner));
|
|
217
|
+
if (!session || (args.action !== "wait" && args.action !== "status"))
|
|
218
|
+
return;
|
|
219
|
+
const body = toolResultBody(result);
|
|
220
|
+
const jobId = typeof body.job_id === "string" ? body.job_id : undefined;
|
|
221
|
+
const started = jobId ? session.jobs.get(jobId) : undefined;
|
|
222
|
+
if (!jobId || !started || !TERMINAL_JOB_STATES.has(String(body.state)))
|
|
223
|
+
return;
|
|
224
|
+
session.jobs.delete(jobId);
|
|
225
|
+
if (session.calls >= RECORDING_LIMITS.maxCalls) {
|
|
226
|
+
this.dropCall(session);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const seq = session.nextSeq++;
|
|
230
|
+
session.calls++;
|
|
231
|
+
const start = typeof body.started_at === "number" ? body.started_at : this.now();
|
|
232
|
+
const end = typeof body.finished_at === "number" ? body.finished_at : this.now();
|
|
233
|
+
const jobResult = (body.result && typeof body.result === "object" ? body.result : {});
|
|
234
|
+
// The stream's copies of the job's errors were already written (a job is not
|
|
235
|
+
// in flight for attribution); collectErrors skips the result's own copies of them.
|
|
236
|
+
const { errors, total, responseOnly } = this.collectErrors(session, start, end, jobResult, {});
|
|
237
|
+
const captures = this.collectCaptures(session, seq, jobResult, null);
|
|
238
|
+
this.pushWindow(session, { seq, start, end, messages: responseOnly });
|
|
239
|
+
session.sinceMark.calls++;
|
|
240
|
+
session.sinceMark.errors += total;
|
|
241
|
+
session.sinceMark.captures += captures.length;
|
|
242
|
+
const status = body.state === "succeeded" ? "ok" : "error";
|
|
243
|
+
if (status === "error")
|
|
244
|
+
session.sinceMark.failed++;
|
|
245
|
+
const entry = {
|
|
246
|
+
k: "call", seq, tool: `${started.tool} (job result)`, kind: started.kind, of: started.seq, job_id: jobId,
|
|
247
|
+
at: iso(start), ended_at: iso(end), duration_ms: Math.max(0, end - start), status, job_state: body.state,
|
|
248
|
+
};
|
|
249
|
+
if (body.error)
|
|
250
|
+
entry.error = this.redactor.value(body.error);
|
|
251
|
+
const summary = this.summarize(started.kind, jobResult);
|
|
252
|
+
if (summary)
|
|
253
|
+
entry.summary = summary;
|
|
254
|
+
if (captures.length)
|
|
255
|
+
entry.captures = captures;
|
|
256
|
+
if (errors.length)
|
|
257
|
+
entry.errors = errors;
|
|
258
|
+
this.append(session, entry);
|
|
259
|
+
this.settle(session);
|
|
260
|
+
}
|
|
261
|
+
/** Bridge event stream listener (every event, every open session). */
|
|
262
|
+
onEvent(event) {
|
|
263
|
+
if (this.open.size === 0)
|
|
264
|
+
return;
|
|
265
|
+
for (const session of this.open.values()) {
|
|
266
|
+
if (event.type === "log" && ERROR_TYPES.has(String(event.logType ?? "").toLowerCase())) {
|
|
267
|
+
if (session.pending.length >= RECORDING_LIMITS.maxPendingErrors) {
|
|
268
|
+
session.pendingDropped++;
|
|
269
|
+
this.noteLimit(session, "pending_errors", `More than ${RECORDING_LIMITS.maxPendingErrors} console errors arrived during one call; later ones were dropped.`);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
const ts = Date.parse(event.ts);
|
|
273
|
+
session.pending.push({
|
|
274
|
+
ts: Number.isFinite(ts) ? ts : this.now(), type: String(event.logType),
|
|
275
|
+
message: event.message ?? "", ...(event.stack ? { stack: event.stack } : {}), source: "stream",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
else if (event.type === "editor_state" && event.state && event.state !== session.lastEditorState) {
|
|
279
|
+
session.lastEditorState = event.state;
|
|
280
|
+
if (session.editorEvents++ < RECORDING_LIMITS.maxEditorEvents)
|
|
281
|
+
this.append(session, { k: "editor", at: event.ts, state: event.state, playing: event.isPlaying === true });
|
|
282
|
+
}
|
|
283
|
+
else if (event.type === "ready") {
|
|
284
|
+
// The first `ready` is the subscription the session started; a later one
|
|
285
|
+
// is a reconnect — the bridge was away (a domain reload, an Editor restart).
|
|
286
|
+
if (session.streamReady++ > 0 && session.editorEvents++ < RECORDING_LIMITS.maxEditorEvents)
|
|
287
|
+
this.append(session, { k: "editor", at: event.ts, state: "stream_reconnected", playing: null });
|
|
288
|
+
}
|
|
289
|
+
else if (event.type === "missed") {
|
|
290
|
+
if (session.streamGaps++ < RECORDING_LIMITS.maxEditorEvents)
|
|
291
|
+
this.append(session, { k: "stream_gap", at: event.ts, detail: event.message ?? "" });
|
|
292
|
+
}
|
|
293
|
+
this.settle(session);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// ---------------------------------------------------------------------
|
|
297
|
+
// Actions
|
|
298
|
+
// ---------------------------------------------------------------------
|
|
299
|
+
async start(owner, args, call) {
|
|
300
|
+
const key = ownerKey(owner);
|
|
301
|
+
if (this.starting.has(key))
|
|
302
|
+
throw new PlaytestError("session_already_open", "This agent's previous start is still running; wait for its answer instead of starting again.");
|
|
303
|
+
const existing = this.open.get(key) ?? this.diskSessionOf(owner);
|
|
304
|
+
if (existing) {
|
|
305
|
+
const meta = "meta" in existing ? existing.meta : existing;
|
|
306
|
+
throw new PlaytestError("session_already_open", `This agent already has an open playtest session (${meta.id}); end it before starting another.`, { session: meta.id, name: meta.name, started_at: meta.started_at });
|
|
307
|
+
}
|
|
308
|
+
if (args.snapshot === true && typeof args.use_snapshot === "string")
|
|
309
|
+
throw new PlaytestError("invalid_arguments", "Pass either snapshot:true (take one now) or use_snapshot (an existing one), not both.");
|
|
310
|
+
// Claimed before the first await: a retried start must not open a second session.
|
|
311
|
+
this.starting.add(key);
|
|
312
|
+
try {
|
|
313
|
+
return await this.startClaimed(owner, args, call);
|
|
314
|
+
}
|
|
315
|
+
finally {
|
|
316
|
+
this.starting.delete(key);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async startClaimed(owner, args, call) {
|
|
320
|
+
const identity = await this.resolveIdentity(call);
|
|
321
|
+
const name = typeof args.name === "string" && args.name.trim() ? args.name.trim() : "playtest";
|
|
322
|
+
const startedAt = this.now();
|
|
323
|
+
const id = sessionId(name, new Date(startedAt), randomBytes(2).toString("hex"));
|
|
324
|
+
let snapshot = null;
|
|
325
|
+
if (args.snapshot === true) {
|
|
326
|
+
const snapshotName = `playtest-${id}`.slice(0, 64);
|
|
327
|
+
const result = await call("unity_open_mcp_player_state", { action: "snapshot", name: snapshotName });
|
|
328
|
+
const out = toolOutput(result);
|
|
329
|
+
if (result.isError || out.error) {
|
|
330
|
+
const failure = (out.error ?? toolResultBody(result).error ?? {});
|
|
331
|
+
throw new PlaytestError("snapshot_failed", `The player-state snapshot failed (${failure.code ?? "error"}): ${failure.message ?? "no detail"}. No session was started.`, { snapshot_error: failure });
|
|
332
|
+
}
|
|
333
|
+
snapshot = { name: snapshotName, taken: true };
|
|
334
|
+
}
|
|
335
|
+
else if (typeof args.use_snapshot === "string") {
|
|
336
|
+
snapshot = { name: args.use_snapshot, taken: false };
|
|
337
|
+
}
|
|
338
|
+
const device = await this.describeDevice(args, call);
|
|
339
|
+
const meta = {
|
|
340
|
+
format: SESSION_FORMAT, id, name: this.redactor.text(name), project: this.projectPath,
|
|
341
|
+
company: identity.company, product: identity.product, owner: { ...owner }, server_pid: this.pid,
|
|
342
|
+
started_at: iso(startedAt), state: "open", device, snapshot,
|
|
343
|
+
limits: { ...RECORDING_LIMITS },
|
|
344
|
+
};
|
|
345
|
+
const dir = this.store.create(meta);
|
|
346
|
+
const session = this.openSession(meta, dir);
|
|
347
|
+
this.append(session, { k: "session_started", at: meta.started_at, name: meta.name, device, snapshot });
|
|
348
|
+
return {
|
|
349
|
+
status: "ok", action: "start", session: id, name: meta.name, folder: dir, device, snapshot,
|
|
350
|
+
recording: ["input (inputsim_*)", "wait (playtest_wait)", "capture (screenshots, capture_inline, capture_series, visual_compare)",
|
|
351
|
+
"command (project_commands invoke, project-command and play-mode jobs)", "control (editor_set_state, game_view set_*)"],
|
|
352
|
+
limits: RECORDING_LIMITS,
|
|
353
|
+
agentNextSteps: [
|
|
354
|
+
"Your input / wait / capture / project-command calls are now recorded with their console errors and frames.",
|
|
355
|
+
"After each player-visible step call playtest_session {\"action\":\"mark\",\"label\":\"…\",\"verdict\":\"ok|bug|suspicious\",\"note\":\"…\"}.",
|
|
356
|
+
"Finish with playtest_session {\"action\":\"end\"} and hand the report path to the human.",
|
|
357
|
+
],
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
mark(owner, args, sessionArg) {
|
|
361
|
+
const session = this.requireOpen(owner, sessionArg);
|
|
362
|
+
this.settle(session);
|
|
363
|
+
if (session.marks >= RECORDING_LIMITS.maxMarks)
|
|
364
|
+
throw new PlaytestError("limit_reached", `A session holds at most ${RECORDING_LIMITS.maxMarks} marks; end it and start another.`);
|
|
365
|
+
const verdict = typeof args.verdict === "string" ? args.verdict : "ok";
|
|
366
|
+
const step = session.nextStep++;
|
|
367
|
+
session.marks++;
|
|
368
|
+
const since = { ...session.sinceMark };
|
|
369
|
+
session.sinceMark = { calls: 0, errors: 0, captures: 0, failed: 0 };
|
|
370
|
+
this.append(session, {
|
|
371
|
+
k: "mark", step, at: iso(this.now()), label: this.redactor.text(String(args.label ?? "")).slice(0, 120), verdict,
|
|
372
|
+
...(typeof args.note === "string" && args.note ? { note: this.redactor.text(clip(args.note, 2_000)) } : {}),
|
|
373
|
+
});
|
|
374
|
+
return {
|
|
375
|
+
status: "ok", action: "mark", session: session.meta.id, step, label: args.label, verdict,
|
|
376
|
+
since_last_mark: since,
|
|
377
|
+
...(since.errors > 0 ? { note: `${since.errors} console error(s) / exception(s) were logged during this step — treat them as findings (they are in the report with the call that caused them).` } : {}),
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
status(owner) {
|
|
381
|
+
const session = this.open.get(ownerKey(owner));
|
|
382
|
+
if (session)
|
|
383
|
+
this.settle(session);
|
|
384
|
+
const orphaned = this.orphanedSessions(owner);
|
|
385
|
+
return {
|
|
386
|
+
status: "ok", action: "status",
|
|
387
|
+
session: session ? this.describe(session) : null,
|
|
388
|
+
...(orphaned.length ? {
|
|
389
|
+
orphaned,
|
|
390
|
+
agentNextSteps: ["An open session whose MCP server process is gone can be continued with {\"action\":\"resume\",\"session\":\"<id>\"} or closed with {\"action\":\"end\",\"session\":\"<id>\"}."],
|
|
391
|
+
} : {}),
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
resume(owner, sessionArg) {
|
|
395
|
+
if (!sessionArg)
|
|
396
|
+
throw new PlaytestError("invalid_arguments", "resume requires session (the id start returned).");
|
|
397
|
+
const own = this.open.get(ownerKey(owner));
|
|
398
|
+
if (own && own.meta.id === sessionArg)
|
|
399
|
+
return { status: "ok", action: "resume", already_open: true, session: this.describe(own) };
|
|
400
|
+
if (own)
|
|
401
|
+
throw new PlaytestError("session_already_open", `This agent already has an open playtest session (${own.meta.id}).`, { session: own.meta.id });
|
|
402
|
+
const session = this.adopt(owner, sessionArg);
|
|
403
|
+
const lost = this.lostInFlight(session);
|
|
404
|
+
return {
|
|
405
|
+
status: "ok", action: "resume", session: this.describe(session),
|
|
406
|
+
...(lost.length ? { lost_in_flight: lost } : {}),
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
async end(owner, args, call) {
|
|
410
|
+
const sessionArg = typeof args.session === "string" ? args.session : undefined;
|
|
411
|
+
let session = this.open.get(ownerKey(owner));
|
|
412
|
+
if (session && sessionArg && session.meta.id !== sessionArg)
|
|
413
|
+
throw new PlaytestError("session_already_open", `This agent's open session is ${session.meta.id}, not ${sessionArg}; end that one first.`, { session: session.meta.id });
|
|
414
|
+
if (!session) {
|
|
415
|
+
if (!sessionArg) {
|
|
416
|
+
const orphaned = this.orphanedSessions(owner);
|
|
417
|
+
throw new PlaytestError("no_open_session", "This agent has no open playtest session.", orphaned.length ? { orphaned, hint: "After an MCP server restart pass session: <id> to end (or resume) the session the old process left open." } : {});
|
|
418
|
+
}
|
|
419
|
+
session = this.adopt(owner, sessionArg);
|
|
420
|
+
}
|
|
421
|
+
// Let in-flight stream events (SSE latency) arrive before the last attribution.
|
|
422
|
+
await new Promise(resolve => setTimeout(resolve, 300));
|
|
423
|
+
this.settle(session, true);
|
|
424
|
+
let stateDiff = null;
|
|
425
|
+
if (session.meta.snapshot && args.diff !== false) {
|
|
426
|
+
try {
|
|
427
|
+
const result = await call("unity_open_mcp_player_state", { action: "diff", name: session.meta.snapshot.name, limit: 200 });
|
|
428
|
+
const out = toolOutput(result);
|
|
429
|
+
stateDiff = result.isError || out.error
|
|
430
|
+
? { ok: false, error: out.error ?? toolResultBody(result).error ?? { code: "diff_failed" } }
|
|
431
|
+
: { ok: true, output: out };
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
stateDiff = { ok: false, error: { code: "diff_failed", message: error instanceof Error ? error.message : String(error) } };
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
const endedAt = this.now();
|
|
438
|
+
if (typeof args.note === "string" && args.note)
|
|
439
|
+
this.append(session, { k: "end_note", at: iso(endedAt), note: this.redactor.text(clip(args.note, 2_000)) });
|
|
440
|
+
this.append(session, {
|
|
441
|
+
k: "session_ended", at: iso(endedAt), dropped_calls: session.droppedCalls, frames_skipped: session.framesSkipped,
|
|
442
|
+
// Calls of this process that had not returned yet (not lost: the process is alive).
|
|
443
|
+
running: [...session.inflight.keys()],
|
|
444
|
+
error_repeats: [...session.errorRepeats.values()], errors_over_limit: session.errorsOverLimit, errors_dropped: session.pendingDropped,
|
|
445
|
+
});
|
|
446
|
+
const meta = { ...session.meta, state: "ended", ended_at: iso(endedAt) };
|
|
447
|
+
const summary = writeReport({
|
|
448
|
+
store: this.store, dir: session.dir, meta, entries: this.store.readLog(session.dir),
|
|
449
|
+
stateDiff: stateDiff ? this.redactor.value(stateDiff) : null, redactor: this.redactor,
|
|
450
|
+
maxThumbnails: RECORDING_LIMITS.maxThumbnails, now: this.now,
|
|
451
|
+
});
|
|
452
|
+
meta.report = summary.report;
|
|
453
|
+
this.store.writeMeta(meta);
|
|
454
|
+
this.open.delete(ownerKey(session.meta.owner));
|
|
455
|
+
const nextSteps = [`Hand the report to the human: ${summary.report}`];
|
|
456
|
+
if (summary.verdicts.bug > 0 || summary.errors.unique > 0)
|
|
457
|
+
nextSteps.push("Summarize the bug / suspicious steps and the first occurrence of each error from summary.findings and summary.top_errors.");
|
|
458
|
+
if (summary.hooks.begin && !summary.hooks.end)
|
|
459
|
+
nextSteps.push(`The project's isolation hook ${summary.hooks.begin} ran without its end hook — invoke the matching playtest end command.`);
|
|
460
|
+
if (session.meta.snapshot)
|
|
461
|
+
nextSteps.push(`Restore the player state after stopping play mode: player_state {"action":"restore","name":"${session.meta.snapshot.name}"} (dry run), then with "confirm":true and the dry run's "plan".`);
|
|
462
|
+
return { status: "ok", action: "end", session: meta.id, report: summary.report, folder: session.dir, summary, agentNextSteps: nextSteps };
|
|
463
|
+
}
|
|
464
|
+
list(owner, limit) {
|
|
465
|
+
const identity = this.identity ?? readProjectIdentity(this.projectPath);
|
|
466
|
+
if (!identity)
|
|
467
|
+
return { status: "ok", action: "list", sessions: [] };
|
|
468
|
+
const all = this.store.list(identity.company, identity.product).filter(s => s.meta.project === this.projectPath);
|
|
469
|
+
return {
|
|
470
|
+
status: "ok", action: "list", company: identity.company, product: identity.product,
|
|
471
|
+
root: this.store.productDir(identity.company, identity.product), total: all.length,
|
|
472
|
+
sessions: all.slice(0, limit).map(({ dir, meta }) => ({
|
|
473
|
+
session: meta.id, name: meta.name, state: this.stateOf(meta, owner), started_at: meta.started_at,
|
|
474
|
+
...(meta.ended_at ? { ended_at: meta.ended_at } : {}), ...(meta.report ? { report: meta.report } : {}), folder: dir,
|
|
475
|
+
})),
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
// ---------------------------------------------------------------------
|
|
479
|
+
// Internals
|
|
480
|
+
// ---------------------------------------------------------------------
|
|
481
|
+
openSession(meta, dir) {
|
|
482
|
+
const session = {
|
|
483
|
+
meta, dir, nextSeq: 1, nextStep: 1, calls: 0, droppedCalls: 0, marks: 0, frames: 0, frameBytes: 0, framesSkipped: 0,
|
|
484
|
+
// An already-connected stream will not send another `ready` until it reconnects.
|
|
485
|
+
editorEvents: 0, lastEditorState: null, streamReady: this.streamConnected() ? 1 : 0, windows: [], inflight: new Map(), pending: [], jobs: new Map(),
|
|
486
|
+
pendingDropped: 0, streamGaps: 0, errorSeen: new Map(), errorsLogged: 0, errorRepeats: new Map(), errorsOverLimit: 0, recentErrors: [],
|
|
487
|
+
limitNoted: new Set(), sinceMark: { calls: 0, errors: 0, captures: 0, failed: 0 },
|
|
488
|
+
};
|
|
489
|
+
this.open.set(ownerKey(meta.owner), session);
|
|
490
|
+
return session;
|
|
491
|
+
}
|
|
492
|
+
isOpen(session) {
|
|
493
|
+
return this.open.get(ownerKey(session.meta.owner)) === session;
|
|
494
|
+
}
|
|
495
|
+
append(session, entry) {
|
|
496
|
+
try {
|
|
497
|
+
this.store.append(session.meta, entry);
|
|
498
|
+
}
|
|
499
|
+
catch (error) {
|
|
500
|
+
console.error("[unity-open-mcp] playtest session log write failed:", error);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
noteLimit(session, what, message) {
|
|
504
|
+
if (session.limitNoted.has(what))
|
|
505
|
+
return;
|
|
506
|
+
session.limitNoted.add(what);
|
|
507
|
+
this.append(session, { k: "limit", at: iso(this.now()), what, message });
|
|
508
|
+
}
|
|
509
|
+
/** A call over maxCalls: counted (the count is logged, so an adopting process keeps it), not recorded. */
|
|
510
|
+
dropCall(session) {
|
|
511
|
+
session.droppedCalls++;
|
|
512
|
+
this.noteLimit(session, "calls", `More than ${RECORDING_LIMITS.maxCalls} calls; later calls are counted but not logged.`);
|
|
513
|
+
this.append(session, { k: "dropped", calls: session.droppedCalls });
|
|
514
|
+
}
|
|
515
|
+
/**
|
|
516
|
+
* Whether this console error is written to the log. Each message is logged
|
|
517
|
+
* maxErrorRepeats times and the session maxErrors times; the rest are counted
|
|
518
|
+
* (per message, written at end), so an error thrown every frame cannot grow
|
|
519
|
+
* the log without bound. `occurrence: false` is a second copy of an error
|
|
520
|
+
* already counted (the stream's copy of a response error): written while the
|
|
521
|
+
* message is under its cap, never counted.
|
|
522
|
+
*/
|
|
523
|
+
keepError(session, type, message, occurrence = true) {
|
|
524
|
+
const key = messageKey(message);
|
|
525
|
+
const seen = session.errorSeen.get(key);
|
|
526
|
+
if (!occurrence)
|
|
527
|
+
return seen === undefined || seen <= RECORDING_LIMITS.maxErrorRepeats;
|
|
528
|
+
if (seen !== undefined && seen >= RECORDING_LIMITS.maxErrorRepeats) {
|
|
529
|
+
const repeat = session.errorRepeats.get(key) ?? { type, message: this.redactor.text(key), count: 0 };
|
|
530
|
+
repeat.count++;
|
|
531
|
+
session.errorRepeats.set(key, repeat);
|
|
532
|
+
this.noteLimit(session, "error_repeats", `A console message logged ${RECORDING_LIMITS.maxErrorRepeats} times is counted, not logged again.`);
|
|
533
|
+
return false;
|
|
534
|
+
}
|
|
535
|
+
if (session.errorsLogged >= RECORDING_LIMITS.maxErrors) {
|
|
536
|
+
session.errorsOverLimit++;
|
|
537
|
+
this.noteLimit(session, "errors", `More than ${RECORDING_LIMITS.maxErrors} console errors; later ones are counted, not logged.`);
|
|
538
|
+
return false;
|
|
539
|
+
}
|
|
540
|
+
session.errorSeen.set(key, (seen ?? 0) + 1);
|
|
541
|
+
session.errorsLogged++;
|
|
542
|
+
return true;
|
|
543
|
+
}
|
|
544
|
+
rememberStreamError(session, ts, message) {
|
|
545
|
+
session.recentErrors.push({ ts, key: messageKey(message) });
|
|
546
|
+
if (session.recentErrors.length > 1_000)
|
|
547
|
+
session.recentErrors.splice(0, session.recentErrors.length - 1_000);
|
|
548
|
+
}
|
|
549
|
+
requireOpen(owner, sessionArg) {
|
|
550
|
+
const session = this.open.get(ownerKey(owner));
|
|
551
|
+
if (session && (!sessionArg || session.meta.id === sessionArg))
|
|
552
|
+
return session;
|
|
553
|
+
if (!session && sessionArg)
|
|
554
|
+
return this.adopt(owner, sessionArg);
|
|
555
|
+
if (session)
|
|
556
|
+
throw new PlaytestError("session_already_open", `This agent's open session is ${session.meta.id}, not ${sessionArg}.`, { session: session.meta.id });
|
|
557
|
+
const orphaned = this.orphanedSessions(owner);
|
|
558
|
+
throw new PlaytestError("no_open_session", "This agent has no open playtest session; start one first.", orphaned.length ? { orphaned } : {});
|
|
559
|
+
}
|
|
560
|
+
/** Take over an open session whose owner is this caller or whose server process is gone. */
|
|
561
|
+
adopt(owner, id) {
|
|
562
|
+
if (!SESSION_ID_PATTERN.test(id))
|
|
563
|
+
throw new PlaytestError("invalid_arguments", `'${id}' is not a session id.`);
|
|
564
|
+
const found = this.findSession(id);
|
|
565
|
+
if (!found)
|
|
566
|
+
throw new PlaytestError("session_not_found", `No playtest session ${id} for this project.`);
|
|
567
|
+
const { dir, meta } = found;
|
|
568
|
+
if (meta.state === "ended")
|
|
569
|
+
throw new PlaytestError("session_ended", `Session ${id} has already ended.`, { report: meta.report ?? null });
|
|
570
|
+
const same = ownerKey(meta.owner) === ownerKey(owner);
|
|
571
|
+
for (const other of this.open.values())
|
|
572
|
+
if (other.meta.id === id && !same)
|
|
573
|
+
throw new PlaytestError("session_owned_by_other_agent", `Session ${id} belongs to another agent of this MCP server.`);
|
|
574
|
+
// A live process keeps its session even for the same agent id (two processes
|
|
575
|
+
// sharing an explicit _meta.agentId); not open in this process (checked
|
|
576
|
+
// above), so a session naming this pid was left by an earlier process.
|
|
577
|
+
if (meta.server_pid !== this.pid && this.isAlive(meta.server_pid))
|
|
578
|
+
throw new PlaytestError("session_owned_by_other_agent", `Session ${id} is open in another running MCP server process (pid ${meta.server_pid}); only its owner can mark or end it.`);
|
|
579
|
+
this.store.terminateLog(dir);
|
|
580
|
+
const adopted = {
|
|
581
|
+
...meta, owner: { ...owner }, server_pid: this.pid,
|
|
582
|
+
adoptions: [...(meta.adoptions ?? []), { at: iso(this.now()), from: meta.owner, from_pid: meta.server_pid }],
|
|
583
|
+
};
|
|
584
|
+
this.store.writeMeta(adopted);
|
|
585
|
+
const session = this.openSession(adopted, dir);
|
|
586
|
+
// Continue numbering and the limits after what the previous process logged.
|
|
587
|
+
const logged = (items) => {
|
|
588
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
589
|
+
const key = messageKey(String(item?.message ?? ""));
|
|
590
|
+
session.errorSeen.set(key, (session.errorSeen.get(key) ?? 0) + 1);
|
|
591
|
+
session.errorsLogged++;
|
|
592
|
+
}
|
|
593
|
+
};
|
|
594
|
+
for (const entry of this.store.readLog(dir)) {
|
|
595
|
+
if (typeof entry.seq === "number")
|
|
596
|
+
session.nextSeq = Math.max(session.nextSeq, entry.seq + 1);
|
|
597
|
+
if (entry.k === "call") {
|
|
598
|
+
session.calls++;
|
|
599
|
+
const start = Date.parse(entry.at), end = Date.parse(entry.ended_at);
|
|
600
|
+
if (Number.isFinite(start) && Number.isFinite(end))
|
|
601
|
+
session.windows.push({ seq: entry.seq, start, end, messages: new Set() });
|
|
602
|
+
for (const capture of Array.isArray(entry.captures) ? entry.captures : []) {
|
|
603
|
+
if (capture?.file) {
|
|
604
|
+
session.frames++;
|
|
605
|
+
session.frameBytes += typeof capture.bytes === "number" ? capture.bytes : 0;
|
|
606
|
+
}
|
|
607
|
+
else if (capture?.skipped)
|
|
608
|
+
session.framesSkipped++;
|
|
609
|
+
}
|
|
610
|
+
logged(entry.errors);
|
|
611
|
+
}
|
|
612
|
+
if (entry.k === "errors")
|
|
613
|
+
logged(entry.items);
|
|
614
|
+
if (entry.k === "mark") {
|
|
615
|
+
session.marks++;
|
|
616
|
+
session.nextStep = Math.max(session.nextStep, entry.step + 1);
|
|
617
|
+
}
|
|
618
|
+
if (entry.k === "dropped" && typeof entry.calls === "number")
|
|
619
|
+
session.droppedCalls = Math.max(session.droppedCalls, entry.calls);
|
|
620
|
+
if (entry.k === "limit" && typeof entry.what === "string")
|
|
621
|
+
session.limitNoted.add(entry.what);
|
|
622
|
+
if (entry.k === "editor")
|
|
623
|
+
session.editorEvents++;
|
|
624
|
+
if (entry.k === "stream_gap")
|
|
625
|
+
session.streamGaps++;
|
|
626
|
+
}
|
|
627
|
+
session.windows = session.windows.slice(-256);
|
|
628
|
+
this.append(session, { k: "adopted", at: iso(this.now()), from: meta.owner, from_pid: meta.server_pid, lost_in_flight: this.lostInFlight(session) });
|
|
629
|
+
return session;
|
|
630
|
+
}
|
|
631
|
+
lostInFlight(session) {
|
|
632
|
+
const started = new Map();
|
|
633
|
+
for (const entry of this.store.readLog(session.dir)) {
|
|
634
|
+
if (entry.k === "call_started")
|
|
635
|
+
started.set(entry.seq, { seq: entry.seq, tool: entry.tool, at: entry.at });
|
|
636
|
+
if (entry.k === "call")
|
|
637
|
+
started.delete(entry.seq);
|
|
638
|
+
}
|
|
639
|
+
for (const seq of session.inflight.keys())
|
|
640
|
+
started.delete(seq);
|
|
641
|
+
return [...started.values()];
|
|
642
|
+
}
|
|
643
|
+
findSession(id) {
|
|
644
|
+
const identity = this.identity ?? readProjectIdentity(this.projectPath);
|
|
645
|
+
if (!identity)
|
|
646
|
+
return null;
|
|
647
|
+
const dir = this.store.dir({ company: identity.company, product: identity.product, id });
|
|
648
|
+
const meta = this.store.readMeta(dir);
|
|
649
|
+
return meta && meta.project === this.projectPath ? { dir, meta } : null;
|
|
650
|
+
}
|
|
651
|
+
/** An open on-disk session owned by exactly this owner (a stable explicit agent id after a restart). */
|
|
652
|
+
diskSessionOf(owner) {
|
|
653
|
+
const identity = this.identity ?? readProjectIdentity(this.projectPath);
|
|
654
|
+
if (!identity)
|
|
655
|
+
return null;
|
|
656
|
+
const key = ownerKey(owner);
|
|
657
|
+
return this.store.list(identity.company, identity.product)
|
|
658
|
+
.find(s => s.meta.state === "open" && s.meta.project === this.projectPath && ownerKey(s.meta.owner) === key)?.meta ?? null;
|
|
659
|
+
}
|
|
660
|
+
stateOf(meta, owner) {
|
|
661
|
+
if (meta.state === "ended")
|
|
662
|
+
return "ended";
|
|
663
|
+
if ([...this.open.values()].some(s => s.meta.id === meta.id))
|
|
664
|
+
return ownerKey(meta.owner) === ownerKey(owner) ? "open" : "open_other_agent";
|
|
665
|
+
if (meta.server_pid !== this.pid && this.isAlive(meta.server_pid))
|
|
666
|
+
return "open_other_process";
|
|
667
|
+
return "orphaned";
|
|
668
|
+
}
|
|
669
|
+
orphanedSessions(owner) {
|
|
670
|
+
const identity = this.identity ?? readProjectIdentity(this.projectPath);
|
|
671
|
+
if (!identity)
|
|
672
|
+
return [];
|
|
673
|
+
return this.store.list(identity.company, identity.product)
|
|
674
|
+
.filter(s => s.meta.project === this.projectPath && this.stateOf(s.meta, owner) === "orphaned")
|
|
675
|
+
.slice(0, 10)
|
|
676
|
+
.map(s => ({ session: s.meta.id, name: s.meta.name, started_at: s.meta.started_at, owner_pid: s.meta.server_pid }));
|
|
677
|
+
}
|
|
678
|
+
describe(session) {
|
|
679
|
+
return {
|
|
680
|
+
session: session.meta.id, name: session.meta.name, started_at: session.meta.started_at, folder: session.dir,
|
|
681
|
+
calls: session.calls, marks: session.marks, frames: session.frames, pending_errors: session.pending.length,
|
|
682
|
+
since_last_mark: session.sinceMark, ...(session.droppedCalls ? { dropped_calls: session.droppedCalls } : {}),
|
|
683
|
+
snapshot: session.meta.snapshot, device: session.meta.device,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
/** Resolve company / product once per process (live Player Settings, else ProjectSettings.asset). */
|
|
687
|
+
async prepare(call) {
|
|
688
|
+
if (this.identity)
|
|
689
|
+
return;
|
|
690
|
+
try {
|
|
691
|
+
await this.resolveIdentity(call);
|
|
692
|
+
}
|
|
693
|
+
catch { /* actions report identity_unavailable themselves */ }
|
|
694
|
+
}
|
|
695
|
+
async resolveIdentity(call) {
|
|
696
|
+
if (this.identity)
|
|
697
|
+
return this.identity;
|
|
698
|
+
try {
|
|
699
|
+
const result = await call("unity_open_mcp_player_state", { action: "list" });
|
|
700
|
+
const out = toolOutput(result);
|
|
701
|
+
if (!result.isError && typeof out.company === "string" && typeof out.product === "string") {
|
|
702
|
+
this.identity = { company: out.company, product: out.product };
|
|
703
|
+
return this.identity;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
catch { /* fall back to ProjectSettings */ }
|
|
707
|
+
const fromDisk = readProjectIdentity(this.projectPath);
|
|
708
|
+
if (!fromDisk)
|
|
709
|
+
throw new PlaytestError("identity_unavailable", "Could not read the project's company / product (no live Editor and no readable ProjectSettings/ProjectSettings.asset).");
|
|
710
|
+
this.identity = fromDisk;
|
|
711
|
+
return fromDisk;
|
|
712
|
+
}
|
|
713
|
+
async describeDevice(args, call) {
|
|
714
|
+
const device = {};
|
|
715
|
+
if (typeof args.device === "string" && args.device.trim())
|
|
716
|
+
device.summary = this.redactor.text(args.device.trim()).slice(0, 200);
|
|
717
|
+
try {
|
|
718
|
+
const result = await call("unity_open_mcp_game_view", { action: "get" });
|
|
719
|
+
const out = toolOutput(result);
|
|
720
|
+
if (!result.isError && !out.error) {
|
|
721
|
+
const screen = (out.screen ?? {});
|
|
722
|
+
device.view = {
|
|
723
|
+
mode: out.mode, playing: out.playing,
|
|
724
|
+
...(out.size ? { size: out.size.label } : {}),
|
|
725
|
+
...(out.device ? { device: out.device.id } : {}),
|
|
726
|
+
...(screen.width ? { screen: [screen.width, screen.height], orientation: screen.orientation, safe_area: screen.safe_area } : {}),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
else
|
|
730
|
+
device.view_error = out.error?.code ?? "unavailable";
|
|
731
|
+
}
|
|
732
|
+
catch {
|
|
733
|
+
device.view_error = "unavailable";
|
|
734
|
+
}
|
|
735
|
+
return device;
|
|
736
|
+
}
|
|
737
|
+
pushWindow(session, window) {
|
|
738
|
+
session.windows.push(window);
|
|
739
|
+
if (session.windows.length > 256)
|
|
740
|
+
session.windows.shift();
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* Which recorded call an error at `ts` belongs to: the call whose window
|
|
744
|
+
* contains it; else one within 50 ms of it (clock rounding, the stream's
|
|
745
|
+
* latency); else the last call that ended before it ("after" — a tap's
|
|
746
|
+
* consequence logged frames later). A call whose response already reported
|
|
747
|
+
* this message wins over another call sharing the same millisecond.
|
|
748
|
+
*/
|
|
749
|
+
attribute(session, ts, key) {
|
|
750
|
+
const reported = session.windows.find(w => w.messages.has(key) && ts >= w.start - 50 && ts <= w.end + 50);
|
|
751
|
+
if (reported)
|
|
752
|
+
return { window: reported, relation: "during" };
|
|
753
|
+
let near, nearGap = Infinity, before;
|
|
754
|
+
for (const w of session.windows) {
|
|
755
|
+
if (ts >= w.start && ts <= w.end)
|
|
756
|
+
return { window: w, relation: "during" };
|
|
757
|
+
const gap = ts < w.start ? w.start - ts : ts - w.end;
|
|
758
|
+
if (gap <= 50 && gap < nearGap) {
|
|
759
|
+
near = w;
|
|
760
|
+
nearGap = gap;
|
|
761
|
+
}
|
|
762
|
+
if (w.end < ts && (!before || w.end > before.end))
|
|
763
|
+
before = w;
|
|
764
|
+
}
|
|
765
|
+
if (near)
|
|
766
|
+
return { window: near, relation: "during" };
|
|
767
|
+
return before ? { window: before, relation: "after" } : null;
|
|
768
|
+
}
|
|
769
|
+
/**
|
|
770
|
+
* Attribute pending stream errors that can no longer belong to an in-flight
|
|
771
|
+
* call (see attribute). An error the call's own response already reported
|
|
772
|
+
* is still logged (it carries the stack) but not counted twice.
|
|
773
|
+
*/
|
|
774
|
+
settle(session, all = false) {
|
|
775
|
+
if (session.pending.length === 0)
|
|
776
|
+
return;
|
|
777
|
+
const earliestInflight = session.inflight.size && !all ? Math.min(...session.inflight.values()) : Infinity;
|
|
778
|
+
const ready = session.pending.filter(e => e.ts < earliestInflight);
|
|
779
|
+
if (ready.length === 0)
|
|
780
|
+
return;
|
|
781
|
+
session.pending = session.pending.filter(e => !ready.includes(e));
|
|
782
|
+
const items = [];
|
|
783
|
+
for (const e of ready) {
|
|
784
|
+
const key = messageKey(e.message);
|
|
785
|
+
const target = this.attribute(session, e.ts, key);
|
|
786
|
+
// The stream's copy of an error the call's response already reported: one error, now with its stack.
|
|
787
|
+
const copy = target?.relation === "during" && target.window.messages.delete(key);
|
|
788
|
+
if (target && !copy)
|
|
789
|
+
session.sinceMark.errors++;
|
|
790
|
+
this.rememberStreamError(session, e.ts, e.message);
|
|
791
|
+
if (!this.keepError(session, e.type, e.message, !copy))
|
|
792
|
+
continue;
|
|
793
|
+
items.push(this.redactor.value({
|
|
794
|
+
at: iso(e.ts), type: e.type, message: clip(e.message, 2_000), ...(e.stack ? { stack: clip(e.stack, 4_000) } : {}),
|
|
795
|
+
seq: target?.window.seq ?? null, relation: target?.relation ?? "before_first_call", source: e.source,
|
|
796
|
+
}));
|
|
797
|
+
}
|
|
798
|
+
if (items.length)
|
|
799
|
+
this.append(session, { k: "errors", items });
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Errors logged inside the call's window: stream events first, then the
|
|
803
|
+
* response's own evidence. `total` counts every occurrence; `errors` holds
|
|
804
|
+
* the ones written (per-call, per-message and per-session caps).
|
|
805
|
+
*/
|
|
806
|
+
collectErrors(session, start, end, output, body) {
|
|
807
|
+
const out = [];
|
|
808
|
+
const seen = new Set();
|
|
809
|
+
// From the call's start (an earlier error belongs to the call before it) to
|
|
810
|
+
// shortly after its end; later stream arrivals are attributed by settle().
|
|
811
|
+
const inWindow = session.pending.filter(e => e.ts >= start && e.ts <= end + 50);
|
|
812
|
+
session.pending = session.pending.filter(e => !inWindow.includes(e));
|
|
813
|
+
for (const e of inWindow) {
|
|
814
|
+
seen.add(messageKey(e.message));
|
|
815
|
+
this.rememberStreamError(session, e.ts, e.message);
|
|
816
|
+
out.push({ at: iso(e.ts), type: e.type, message: clip(e.message, 2_000), ...(e.stack ? { stack: clip(e.stack, 4_000) } : {}), source: "stream" });
|
|
817
|
+
}
|
|
818
|
+
const add = (type, message, stack) => {
|
|
819
|
+
const key = messageKey(message);
|
|
820
|
+
if (seen.has(key))
|
|
821
|
+
return;
|
|
822
|
+
seen.add(key);
|
|
823
|
+
// Already written from the stream inside this window (a job's errors, an overlapping call's).
|
|
824
|
+
if (session.recentErrors.some(r => r.key === key && r.ts >= start - 50 && r.ts <= end + 50))
|
|
825
|
+
return;
|
|
826
|
+
out.push({ type, message: clip(message, 2_000), ...(stack ? { stack: clip(stack, 4_000) } : {}), source: "response" });
|
|
827
|
+
};
|
|
828
|
+
const logs = Array.isArray(body.logs) ? body.logs : [];
|
|
829
|
+
for (const log of logs)
|
|
830
|
+
if (log && typeof log === "object" && log.severity === "error" && typeof log.message === "string")
|
|
831
|
+
add("error", log.message);
|
|
832
|
+
// A play-mode operation's own console capture (wait / series / gesture); an
|
|
833
|
+
// argument-validation failure's `errors` are schema messages, not console.
|
|
834
|
+
const operationErrors = output.operation !== undefined || output.series !== undefined || "errors_dropped" in output;
|
|
835
|
+
if (operationErrors && Array.isArray(output.errors))
|
|
836
|
+
for (const e of output.errors)
|
|
837
|
+
if (typeof e === "string")
|
|
838
|
+
add("error", e);
|
|
839
|
+
const exception = output.exception;
|
|
840
|
+
if (exception && typeof exception.message === "string")
|
|
841
|
+
add(String(exception.type ?? "exception").toLowerCase(), exception.message, typeof exception.stack === "string" ? exception.stack : undefined);
|
|
842
|
+
session.errorsOverLimit += Math.max(0, out.length - RECORDING_LIMITS.maxErrorsPerCall);
|
|
843
|
+
const kept = out.slice(0, RECORDING_LIMITS.maxErrorsPerCall).filter(e => this.keepError(session, String(e.type), String(e.message)));
|
|
844
|
+
const responseOnly = new Set(out.filter(e => e.source === "response").map(e => messageKey(String(e.message))));
|
|
845
|
+
return { errors: this.redactor.value(kept), total: out.length, responseOnly };
|
|
846
|
+
}
|
|
847
|
+
/** Frames a call wrote (file paths anywhere in its output, inline images) → linked into the report folder. */
|
|
848
|
+
collectCaptures(session, seq, output, result) {
|
|
849
|
+
const found = [];
|
|
850
|
+
const walk = (value, path, depth) => {
|
|
851
|
+
if (depth > 8 || !value || typeof value !== "object")
|
|
852
|
+
return;
|
|
853
|
+
if (Array.isArray(value)) {
|
|
854
|
+
value.forEach((item, i) => walk(item, `${path}[${i}]`, depth + 1));
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
const obj = value;
|
|
858
|
+
for (const key of ["filePath", "file"]) {
|
|
859
|
+
const file = obj[key];
|
|
860
|
+
if (typeof file === "string" && /\.png$/i.test(file) && isAbsolute(file)) {
|
|
861
|
+
found.push({
|
|
862
|
+
source: file, label: /contact_sheet/.test(path) ? "contact sheet" : undefined,
|
|
863
|
+
frame: typeof obj.frame === "number" ? obj.frame : undefined,
|
|
864
|
+
width: typeof obj.width === "number" ? obj.width : undefined, height: typeof obj.height === "number" ? obj.height : undefined,
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
for (const [key, child] of Object.entries(obj))
|
|
869
|
+
if (typeof child === "object")
|
|
870
|
+
walk(child, `${path}.${key}`, depth + 1);
|
|
871
|
+
};
|
|
872
|
+
walk(output, "", 0);
|
|
873
|
+
for (const block of result?.content ?? [])
|
|
874
|
+
if (block.type === "image" && typeof block.data === "string")
|
|
875
|
+
found.push({ data: block.data, label: "inline" });
|
|
876
|
+
const out = [];
|
|
877
|
+
found.forEach((capture, index) => {
|
|
878
|
+
const entry = {
|
|
879
|
+
...(capture.label ? { label: capture.label } : {}), ...(capture.frame !== undefined ? { frame: capture.frame } : {}),
|
|
880
|
+
...(capture.width ? { width: capture.width, height: capture.height } : {}),
|
|
881
|
+
...(capture.source ? { source: capture.source } : {}),
|
|
882
|
+
};
|
|
883
|
+
if (session.frames >= RECORDING_LIMITS.maxFrames || session.frameBytes >= RECORDING_LIMITS.maxFrameBytes) {
|
|
884
|
+
session.framesSkipped++;
|
|
885
|
+
this.noteLimit(session, "frames", `More than ${RECORDING_LIMITS.maxFrames} frames (or ${RECORDING_LIMITS.maxFrameBytes / 1024 / 1024} MB); later frames are referenced by their source path only.`);
|
|
886
|
+
out.push({ ...entry, skipped: "limit" });
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
const name = `s${String(seq).padStart(3, "0")}-${String(index).padStart(2, "0")}-${capture.source ? basename(capture.source) : "inline.png"}`;
|
|
890
|
+
const target = join(session.dir, REPORT_DIR, FRAMES_DIR, name);
|
|
891
|
+
try {
|
|
892
|
+
if (capture.data)
|
|
893
|
+
writeFileSync(target, Buffer.from(capture.data, "base64"));
|
|
894
|
+
else {
|
|
895
|
+
try {
|
|
896
|
+
linkSync(capture.source, target);
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
copyFileSync(capture.source, target);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
const bytes = statSync(target).size;
|
|
903
|
+
session.frames++;
|
|
904
|
+
session.frameBytes += bytes;
|
|
905
|
+
out.push({ ...entry, file: `${FRAMES_DIR}/${name}`, bytes });
|
|
906
|
+
}
|
|
907
|
+
catch (error) {
|
|
908
|
+
out.push({ ...entry, missing: error instanceof Error ? error.message.slice(0, 200) : "copy failed" });
|
|
909
|
+
}
|
|
910
|
+
});
|
|
911
|
+
return out;
|
|
912
|
+
}
|
|
913
|
+
summarize(kind, output) {
|
|
914
|
+
const summary = {};
|
|
915
|
+
for (const key of SUMMARY_KEYS) {
|
|
916
|
+
if (output[key] === undefined || output[key] === null)
|
|
917
|
+
continue;
|
|
918
|
+
if (key === "frames" && kind !== "wait")
|
|
919
|
+
continue;
|
|
920
|
+
summary[key] = boundedJson(output[key], 400);
|
|
921
|
+
}
|
|
922
|
+
if (kind === "command" && output.result !== undefined)
|
|
923
|
+
summary.result = boundedJson(output.result, 1_000);
|
|
924
|
+
if (output.series && typeof output.series === "object") {
|
|
925
|
+
const series = output.series;
|
|
926
|
+
summary.series = { captured: series.captured, requested: series.requested, schedule: series.schedule };
|
|
927
|
+
}
|
|
928
|
+
return Object.keys(summary).length ? this.redactor.value(summary) : null;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
const TOOL_BY_NAME = new Map(ALL_TOOLS.map(t => [t.name, t]));
|
|
932
|
+
/**
|
|
933
|
+
* The arguments as the agent sent them: the server fills schema defaults in
|
|
934
|
+
* before routing, so a top-level value equal to its schema default is dropped
|
|
935
|
+
* (as are routing keys) — the report shows intent, not boilerplate.
|
|
936
|
+
*/
|
|
937
|
+
function asSent(label, args) {
|
|
938
|
+
const { _meta: _ignored, operation_id: _op, ...rest } = args;
|
|
939
|
+
const properties = (TOOL_BY_NAME.get(label.split(" ")[0])?.inputSchema.properties ?? {});
|
|
940
|
+
for (const [key, value] of Object.entries(rest))
|
|
941
|
+
if (properties[key]?.default !== undefined && JSON.stringify(properties[key].default) === JSON.stringify(value))
|
|
942
|
+
delete rest[key];
|
|
943
|
+
return rest;
|
|
944
|
+
}
|
|
945
|
+
//# sourceMappingURL=recorder.js.map
|