@el4cteo/rbx-studio-mcp 0.6.7 → 0.7.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/README.md +2 -2
- package/dist/bridge/harness.js +39 -12
- package/dist/bridge/harness.js.map +1 -1
- package/dist/bridge/rpc.js +26 -6
- package/dist/bridge/rpc.js.map +1 -1
- package/dist/bridge/server.js +12 -4
- package/dist/bridge/server.js.map +1 -1
- package/dist/index.js +10 -3
- package/dist/index.js.map +1 -1
- package/dist/lib/opencloud.js +55 -0
- package/dist/lib/opencloud.js.map +1 -1
- package/dist/tools/data.js +15 -4
- package/dist/tools/data.js.map +1 -1
- package/dist/tools/exec.js +16 -7
- package/dist/tools/exec.js.map +1 -1
- package/dist/tools/scripts.js +17 -3
- package/dist/tools/scripts.js.map +1 -1
- package/dist/tools/universe.js +6 -1
- package/dist/tools/universe.js.map +1 -1
- package/dist/tools/world.js +11 -3
- package/dist/tools/world.js.map +1 -1
- package/package.json +74 -74
- package/plugin/src/Config.luau +1 -1
- package/plugin/src/Serialize.luau +10 -0
- package/plugin/src/TextEdit.luau +6 -6
- package/plugin/src/handlers/Discover.luau +690 -685
- package/plugin/src/handlers/Exec.luau +5 -1
- package/plugin/src/handlers/Scripts.luau +677 -673
- package/plugin/src/handlers/Terrain.luau +371 -371
- package/scripts/test-bridge.mjs +328 -267
- package/scripts/test-console.mjs +592 -560
- package/scripts/test-server.mjs +60 -0
package/scripts/test-console.mjs
CHANGED
|
@@ -1,560 +1,592 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Checks the console panel's command line and the agents it starts.
|
|
3
|
-
*
|
|
4
|
-
* Everything here is a pure function or a call against a real Bridge with fake
|
|
5
|
-
* Studio sessions -- no sockets, no Studio, and above all no agent processes.
|
|
6
|
-
* That last one is the constraint that shapes the file: the interesting code is
|
|
7
|
-
* "start a coding agent and stream it back", and a test that actually did so
|
|
8
|
-
* would cost money, need an API key, and take a minute. So the seam is
|
|
9
|
-
* `harness.read`, which turns one line of a harness's output into console rows
|
|
10
|
-
* and is where every adapter's real work lives.
|
|
11
|
-
*
|
|
12
|
-
* The event shapes below are recorded from live runs of each CLI, not invented.
|
|
13
|
-
* A test built on a guessed envelope passes forever and proves nothing.
|
|
14
|
-
*/
|
|
15
|
-
import assert from "node:assert/strict";
|
|
16
|
-
import { readFileSync } from "node:fs";
|
|
17
|
-
import { Bridge } from "../dist/bridge/rpc.js";
|
|
18
|
-
import { LocalBridge } from "../dist/bridge/api.js";
|
|
19
|
-
import { frame, handleConsole } from "../dist/bridge/console.js";
|
|
20
|
-
import { find, installed, matchesClient } from "../dist/bridge/harness.js";
|
|
21
|
-
|
|
22
|
-
let checks = 0;
|
|
23
|
-
const ok = (condition, what) => {
|
|
24
|
-
assert.ok(condition, what);
|
|
25
|
-
checks += 1;
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
/** Every row a harness produces for one line of its output. */
|
|
29
|
-
const readAll = (id, lines) => {
|
|
30
|
-
const harness = find(id);
|
|
31
|
-
const rows = [];
|
|
32
|
-
let session = null;
|
|
33
|
-
for (const line of lines) {
|
|
34
|
-
const reading = harness.read(line);
|
|
35
|
-
if (reading.session !== undefined) session = reading.session;
|
|
36
|
-
rows.push(...reading.lines);
|
|
37
|
-
}
|
|
38
|
-
return { rows, session };
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
// --- Claude Code -----------------------------------------------------------
|
|
42
|
-
{
|
|
43
|
-
const { rows, session } = readAll("claude", [
|
|
44
|
-
JSON.stringify({ type: "system", subtype: "init", session_id: "abc-123" }),
|
|
45
|
-
JSON.stringify({
|
|
46
|
-
type: "assistant",
|
|
47
|
-
message: {
|
|
48
|
-
content: [
|
|
49
|
-
{ type: "text", text: "Looking at the place." },
|
|
50
|
-
{
|
|
51
|
-
type: "tool_use",
|
|
52
|
-
name: "mcp__rbx-studio__create",
|
|
53
|
-
input: { instances: [], parent: "Workspace" },
|
|
54
|
-
},
|
|
55
|
-
],
|
|
56
|
-
},
|
|
57
|
-
}),
|
|
58
|
-
"not json at all",
|
|
59
|
-
JSON.stringify({ type: "result", duration_ms: 5600, total_cost_usd: 0.1282 }),
|
|
60
|
-
]);
|
|
61
|
-
|
|
62
|
-
ok(session === "abc-123", "claude: session id is learned from the init event");
|
|
63
|
-
// Four rows, not three: the "not json at all" line is SHOWN. This assertion
|
|
64
|
-
// used to require it be dropped, which is the same instinct that made
|
|
65
|
-
// opencode print nothing -- a line we cannot parse is still evidence, and the
|
|
66
|
-
// panel is the only place the user can see it.
|
|
67
|
-
// Three rows, and which three is the point. The junk line IS shown -- that
|
|
68
|
-
// assertion used to require it be dropped, the same instinct that made
|
|
69
|
-
// opencode print nothing. The rbx-studio tool call is NOT, because Studio
|
|
70
|
-
// logs every call that reaches it with a friendlier name and a duration, and
|
|
71
|
-
// the agent's copy of it made every call two lines in the panel.
|
|
72
|
-
ok(rows.length === 3, "claude: junk is surfaced, our own tool call is not doubled");
|
|
73
|
-
ok(
|
|
74
|
-
rows.some((row) => row.level === "dim" && row.message === "not json at all"),
|
|
75
|
-
"claude: an unparseable line is shown dim rather than swallowed",
|
|
76
|
-
);
|
|
77
|
-
ok(
|
|
78
|
-
!rows.some((row) => row.level === "call"),
|
|
79
|
-
"claude: an rbx-studio call is left to Studio's own log",
|
|
80
|
-
);
|
|
81
|
-
ok(rows[0].level === "reply" && rows[0].message === "Looking at the place.", "claude: prose");
|
|
82
|
-
ok(rows[2].level === "ok" && rows[2].message === "agent done", "claude: result row");
|
|
83
|
-
ok(rows[2].detail === "5.6s $0.1282", "claude: duration and cost ride the detail column");
|
|
84
|
-
|
|
85
|
-
// A tool that is NOT ours is all the panel will ever hear about, so it stays.
|
|
86
|
-
const outside = readAll("claude", [
|
|
87
|
-
JSON.stringify({
|
|
88
|
-
type: "assistant",
|
|
89
|
-
message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls -la" } }] },
|
|
90
|
-
}),
|
|
91
|
-
]);
|
|
92
|
-
ok(
|
|
93
|
-
outside.rows[0].level === "call" && outside.rows[0].message === "Bash",
|
|
94
|
-
"claude: a tool Studio never sees is still logged",
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
// opencode names MCP tools `<server>_<tool>`, not `mcp__<server>__<tool>`.
|
|
98
|
-
// Unstripped it printed "rbx-studio_studio_status" AND doubled Studio's row.
|
|
99
|
-
const named = readAll("opencode", [
|
|
100
|
-
JSON.stringify({
|
|
101
|
-
type: "tool_use",
|
|
102
|
-
sessionID: "s",
|
|
103
|
-
part: { type: "tool", tool: "rbx-studio_studio_status", state: { input: {} } },
|
|
104
|
-
}),
|
|
105
|
-
]);
|
|
106
|
-
ok(named.rows.length === 0, "opencode: its own naming of our tools is recognised too");
|
|
107
|
-
|
|
108
|
-
// A failure must not be reported as a completion. This is the one row a user
|
|
109
|
-
// reads to decide whether to trust what just happened to their place.
|
|
110
|
-
const failed = readAll("claude", [
|
|
111
|
-
JSON.stringify({ type: "result", is_error: true, duration_ms: 100 }),
|
|
112
|
-
]);
|
|
113
|
-
ok(failed.rows[0].level === "error", "claude: is_error becomes an error row");
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// --- Tool arguments --------------------------------------------------------
|
|
117
|
-
{
|
|
118
|
-
// Regression: a Luau snippet passed to execute_luau is multi-line, and 32
|
|
119
|
-
// characters of it used to carry a newline into a log whose rows are lines.
|
|
120
|
-
// The visible symptom was a stray "m" sitting at column 0 under the entry.
|
|
121
|
-
const { rows } = readAll("claude", [
|
|
122
|
-
JSON.stringify({
|
|
123
|
-
type: "assistant",
|
|
124
|
-
message: {
|
|
125
|
-
content: [
|
|
126
|
-
{
|
|
127
|
-
type: "tool_use",
|
|
128
|
-
name: "Bash",
|
|
129
|
-
input: { source: "local m = workspace.SmallHouse\nm.Parent = nil\nprint(m)" },
|
|
130
|
-
},
|
|
131
|
-
],
|
|
132
|
-
},
|
|
133
|
-
}),
|
|
134
|
-
]);
|
|
135
|
-
ok(!rows[0].detail.includes("\n"), "tool detail never contains a newline");
|
|
136
|
-
ok(rows[0].message === "Bash", "an outside tool keeps its own name");
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// --- every adapter, against its real envelope -------------------------------
|
|
140
|
-
//
|
|
141
|
-
// This block used to assert INVENTED event names -- `session.created` for
|
|
142
|
-
// codex, `message.part.updated` for opencode -- while the file's own header
|
|
143
|
-
// claimed the shapes were recorded from live runs. They were not, and the tests
|
|
144
|
-
// passed anyway, because a test written against the same wrong envelope as the
|
|
145
|
-
// code agrees with it perfectly.
|
|
146
|
-
//
|
|
147
|
-
// What that cost: a prompt sent to opencode printed NOTHING. Every line fell
|
|
148
|
-
// through to no rows, the panel logged a blank run and went idle, and the
|
|
149
|
-
// agent's answer was thrown away. The lines below are copied from real runs
|
|
150
|
-
// (opencode 1.18.30, claude 2.1.266) or from the vendors' own documented
|
|
151
|
-
// schemas for the CLIs not installed here.
|
|
152
|
-
{
|
|
153
|
-
// opencode 1.18.30, verbatim from `opencode run --format json`.
|
|
154
|
-
const oc = readAll("opencode", [
|
|
155
|
-
JSON.stringify({ type: "step_start", sessionID: "ses_abc", part: { type: "step-start" } }),
|
|
156
|
-
JSON.stringify({
|
|
157
|
-
type: "tool_use",
|
|
158
|
-
sessionID: "ses_abc",
|
|
159
|
-
part: { type: "tool", tool: "glob", state: { input: { pattern: "*.ts" } } },
|
|
160
|
-
}),
|
|
161
|
-
JSON.stringify({
|
|
162
|
-
type: "text",
|
|
163
|
-
sessionID: "ses_abc",
|
|
164
|
-
part: { type: "text", text: "Hi there, how's it going?" },
|
|
165
|
-
}),
|
|
166
|
-
JSON.stringify({ type: "step_finish", sessionID: "ses_abc", part: { type: "step-finish" } }),
|
|
167
|
-
]);
|
|
168
|
-
ok(oc.session === "ses_abc", "opencode: session id rides on every event");
|
|
169
|
-
ok(
|
|
170
|
-
oc.rows.some((row) => row.level === "reply" && row.message === "Hi there, how's it going?"),
|
|
171
|
-
"opencode: the answer is printed -- the bug was that it never was",
|
|
172
|
-
);
|
|
173
|
-
ok(oc.rows.some((row) => row.message === "glob"), "opencode: tool calls are printed");
|
|
174
|
-
ok(
|
|
175
|
-
!oc.rows.some((row) => row.message === "agent done"),
|
|
176
|
-
"opencode: step_finish is per step, not the end of the run",
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
// Codex, from the documented exec --json protocol.
|
|
180
|
-
const codex = readAll("codex", [
|
|
181
|
-
JSON.stringify({ type: "thread.started", thread_id: "019cec77-af02" }),
|
|
182
|
-
JSON.stringify({ type: "turn.started" }),
|
|
183
|
-
JSON.stringify({
|
|
184
|
-
type: "item.started",
|
|
185
|
-
item: { id: "i1", type: "mcp_tool_call", server: "rbx", tool: "create", arguments: {} },
|
|
186
|
-
}),
|
|
187
|
-
JSON.stringify({
|
|
188
|
-
type: "item.completed",
|
|
189
|
-
item: { id: "i1", type: "mcp_tool_call", server: "rbx", tool: "create" },
|
|
190
|
-
}),
|
|
191
|
-
JSON.stringify({ type: "item.completed", item: { id: "i2", type: "agent_message", text: "Done." } }),
|
|
192
|
-
JSON.stringify({ type: "turn.completed", usage: {} }),
|
|
193
|
-
]);
|
|
194
|
-
ok(codex.session === "019cec77-af02", "codex: session comes from thread.started/thread_id");
|
|
195
|
-
ok(
|
|
196
|
-
codex.rows.some((row) => row.level === "reply" && row.message === "Done."),
|
|
197
|
-
"codex: prose",
|
|
198
|
-
);
|
|
199
|
-
ok(
|
|
200
|
-
codex.rows.filter((row) => row.message === "create").length === 1,
|
|
201
|
-
"codex: a tool reported started AND completed is logged once",
|
|
202
|
-
);
|
|
203
|
-
ok(codex.rows.some((row) => row.message === "agent done"), "codex: turn.completed ends the run");
|
|
204
|
-
|
|
205
|
-
// Codex global flags must precede the `resume` subcommand or it refuses them.
|
|
206
|
-
const resumed = find("codex").argv("hello", "thread-1");
|
|
207
|
-
ok(
|
|
208
|
-
resumed.indexOf("--skip-git-repo-check") < resumed.indexOf("resume"),
|
|
209
|
-
"codex: global flags come before the resume subcommand",
|
|
210
|
-
);
|
|
211
|
-
ok(resumed[resumed.length - 1] === "hello", "codex: the prompt stays last");
|
|
212
|
-
|
|
213
|
-
// Gemini, from the documented headless stream-json events.
|
|
214
|
-
const gem = readAll("gemini", [
|
|
215
|
-
JSON.stringify({ type: "init", session_id: "gem-1", model: "gemini" }),
|
|
216
|
-
JSON.stringify({ type: "message", role: "user", content: "what did I ask" }),
|
|
217
|
-
JSON.stringify({ type: "message", role: "assistant", content: "Built it." }),
|
|
218
|
-
JSON.stringify({ type: "result" }),
|
|
219
|
-
]);
|
|
220
|
-
ok(gem.session === "gem-1", "gemini: session id");
|
|
221
|
-
ok(gem.rows.some((row) => row.message === "Built it."), "gemini: the assistant half is printed");
|
|
222
|
-
ok(
|
|
223
|
-
!gem.rows.some((row) => row.message === "what did I ask"),
|
|
224
|
-
"gemini: the user half is not echoed back at them",
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
// Cursor, from the documented stream-json envelope.
|
|
228
|
-
const cur = readAll("cursor", [
|
|
229
|
-
JSON.stringify({ type: "system", subtype: "init", session_id: "cur-1" }),
|
|
230
|
-
JSON.stringify({
|
|
231
|
-
type: "tool_call",
|
|
232
|
-
subtype: "started",
|
|
233
|
-
session_id: "cur-1",
|
|
234
|
-
tool_call: { readToolCall: { args: { path: "a.ts" } } },
|
|
235
|
-
}),
|
|
236
|
-
JSON.stringify({
|
|
237
|
-
type: "assistant",
|
|
238
|
-
session_id: "cur-1",
|
|
239
|
-
message: { role: "assistant", content: [{ type: "text", text: "Read it." }] },
|
|
240
|
-
}),
|
|
241
|
-
JSON.stringify({ type: "result", subtype: "success", is_error: false, session_id: "cur-1" }),
|
|
242
|
-
]);
|
|
243
|
-
ok(cur.session === "cur-1", "cursor: session id rides on every event");
|
|
244
|
-
ok(cur.rows.some((row) => row.message === "Read it."), "cursor: text is nested in message.content");
|
|
245
|
-
ok(cur.rows.some((row) => row.message === "readToolCall"), "cursor: the tool key names the call");
|
|
246
|
-
ok(cur.rows.some((row) => row.message === "agent done"), "cursor: result ends the run");
|
|
247
|
-
|
|
248
|
-
// Crush has no event stream, but it does have its own verb -- the generic
|
|
249
|
-
// adapter guessed `-p`, which crush rejects outright as an unknown flag.
|
|
250
|
-
const crush = find("crush").argv("hello", null);
|
|
251
|
-
ok(crush[0] === "run", "crush: uses its `run` verb, not a guessed -p flag");
|
|
252
|
-
ok(
|
|
253
|
-
readAll("crush", ["Placed the model."]).rows[0].message === "Placed the model.",
|
|
254
|
-
"crush: plain text still reaches the log",
|
|
255
|
-
);
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
// --- DeepSeek Harness ------------------------------------------------------
|
|
259
|
-
{
|
|
260
|
-
const dsh = find("dsh");
|
|
261
|
-
const argv = dsh.argv("add a spawn point", null);
|
|
262
|
-
|
|
263
|
-
// `dsh [options] [command] [args...]`: --patch is a launcher option and the
|
|
264
|
-
// prompt is a positional argument, so the flag has to come first. Getting
|
|
265
|
-
// this backwards is silent -- dsh reads the prompt as the patch path.
|
|
266
|
-
const patchAt = argv.indexOf("--patch");
|
|
267
|
-
ok(patchAt !== -1, "dsh: the overlay is passed");
|
|
268
|
-
ok(
|
|
269
|
-
patchAt < argv.indexOf("add a spawn point"),
|
|
270
|
-
"dsh: --patch comes before the prompt, or dsh reads the prompt as a path",
|
|
271
|
-
);
|
|
272
|
-
ok(argv[argv.length - 1] === "add a spawn point", "dsh: the prompt is last");
|
|
273
|
-
ok(dsh.mcpFlag === undefined, "dsh: builds its own argv rather than appending flags");
|
|
274
|
-
|
|
275
|
-
const { rows } = readAll("dsh", ["The spawn point is placed.", " ", ""]);
|
|
276
|
-
ok(rows.length === 1, "dsh: blank lines are not rows");
|
|
277
|
-
ok(rows[0].level === "reply", "dsh: headless prints prose, not events");
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
// --- The registry ----------------------------------------------------------
|
|
281
|
-
{
|
|
282
|
-
ok(find("claude") !== undefined && find("nonesuch") === undefined, "registry: lookup by id");
|
|
283
|
-
ok(
|
|
284
|
-
installed().every((entry) => typeof entry.id === "string" && entry.id.length > 0),
|
|
285
|
-
"registry: every detected harness is named",
|
|
286
|
-
);
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
// --- Panel-started agents are not other people's clients -------------------
|
|
290
|
-
{
|
|
291
|
-
const bridge = new Bridge();
|
|
292
|
-
const announcements = [];
|
|
293
|
-
bridge.watchClients((count) => announcements.push(count));
|
|
294
|
-
|
|
295
|
-
const mine = new LocalBridge(bridge);
|
|
296
|
-
ok(bridge.clientCount() === 1, "an ordinary client counts");
|
|
297
|
-
|
|
298
|
-
// What a spawned agent's own server reports when it says hello. Counting it
|
|
299
|
-
// flashed the badge to 2 and logged an arrival and a departure around every
|
|
300
|
-
// single prompt -- around output the user was trying to read.
|
|
301
|
-
bridge.noteClient("spawned-agent", { name: "claude-code", pid: 42, spawned: true });
|
|
302
|
-
ok(bridge.clientCount() === 1, "a panel-started agent is not counted");
|
|
303
|
-
ok(
|
|
304
|
-
bridge.clientList().every((client) => client.name !== "claude-code"),
|
|
305
|
-
"a panel-started agent is not in the roster",
|
|
306
|
-
);
|
|
307
|
-
ok(
|
|
308
|
-
announcements.every((count) => count <= 1),
|
|
309
|
-
"a panel-started agent never announces an arrival",
|
|
310
|
-
);
|
|
311
|
-
|
|
312
|
-
mine.goodbye();
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// --- Command routing -------------------------------------------------------
|
|
316
|
-
{
|
|
317
|
-
const bridge = new Bridge();
|
|
318
|
-
const identity = (studioId, placeId, placeName) => ({
|
|
319
|
-
studioId,
|
|
320
|
-
placeName,
|
|
321
|
-
placeId,
|
|
322
|
-
pluginVersion: "test",
|
|
323
|
-
buildId: "test",
|
|
324
|
-
protocolVersion: 1,
|
|
325
|
-
transport: "poll",
|
|
326
|
-
context: "edit",
|
|
327
|
-
});
|
|
328
|
-
bridge.attach(identity("studio-a", 111, "Alpha"), null);
|
|
329
|
-
bridge.attach(identity("studio-b", 222, "Beta"), null);
|
|
330
|
-
|
|
331
|
-
const run = (command, args = []) =>
|
|
332
|
-
handleConsole(bridge, 44755, { studioId: "studio-a", command, args, line: command });
|
|
333
|
-
|
|
334
|
-
const studios = await run("studios");
|
|
335
|
-
ok(studios.length === 3, "studios: a heading and one row per Studio");
|
|
336
|
-
ok(
|
|
337
|
-
studios.some((row) => row.message.includes("(this panel)")),
|
|
338
|
-
"studios: the asking panel is marked, so two rows with one place name are told apart",
|
|
339
|
-
);
|
|
340
|
-
|
|
341
|
-
// `use` takes the number printed by `studios`, because requiring the id would
|
|
342
|
-
// mean reading a hex string off one line to type it into the next.
|
|
343
|
-
const used = await run("use", ["2"]);
|
|
344
|
-
ok(used[0].level === "ok" && used[0].message.includes("Beta"), "use: resolves a list number");
|
|
345
|
-
ok(bridge.activeId("nobody-in-particular") === "studio-b", "use: applies to clients too");
|
|
346
|
-
|
|
347
|
-
const bad = await run("use", ["nope"]);
|
|
348
|
-
ok(bad[0].level === "error", "use: an unknown target is an error, not a silent no-op");
|
|
349
|
-
ok(bridge.activeId("nobody") === "studio-b", "use: a failed switch changes nothing");
|
|
350
|
-
|
|
351
|
-
const noArg = await run("use");
|
|
352
|
-
ok(noArg[0].message.startsWith("usage:"), "use: says how to use it");
|
|
353
|
-
|
|
354
|
-
const stopped = await run("stop");
|
|
355
|
-
ok(stopped[0].message === "nothing is running", "stop: honest when idle");
|
|
356
|
-
|
|
357
|
-
const agents = await run("agent");
|
|
358
|
-
ok(agents.length > 0, "agent: always answers, installed or not");
|
|
359
|
-
|
|
360
|
-
const unknown = await run("wat");
|
|
361
|
-
ok(unknown[0].level === "error", "an unknown command is reported, not guessed at");
|
|
362
|
-
ok(
|
|
363
|
-
unknown[0].detail.includes("different builds"),
|
|
364
|
-
"an unknown command names the likely cause, since the plugin filters first",
|
|
365
|
-
);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// --- doctor ----------------------------------------------------------------
|
|
369
|
-
{
|
|
370
|
-
const bridge = new Bridge();
|
|
371
|
-
// A port nothing is listening on: doctor must still answer, because "why is
|
|
372
|
-
// nothing working" is exactly when it is run.
|
|
373
|
-
const rows = await handleConsole(bridge, 45999, {
|
|
374
|
-
studioId: "studio-a",
|
|
375
|
-
command: "doctor",
|
|
376
|
-
args: [],
|
|
377
|
-
line: "doctor",
|
|
378
|
-
});
|
|
379
|
-
ok(rows.length > 1, "doctor: reports against a dead port rather than failing");
|
|
380
|
-
ok(
|
|
381
|
-
rows.every((row) => typeof row.message === "string" && !row.message.includes("\n")),
|
|
382
|
-
"doctor: every row is one line",
|
|
383
|
-
);
|
|
384
|
-
const summary = rows[rows.length - 1];
|
|
385
|
-
ok(/passed.*warning.*failure/.test(summary.message), "doctor: ends with a tally");
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
// The framing preamble --------------------------------------------------------
|
|
389
|
-
//
|
|
390
|
-
// The shipped bug: "create a simple script, then edit it" sent the agent to the
|
|
391
|
-
// filesystem, because that is where a coding agent spawned in a repo assumes a
|
|
392
|
-
// script lives. It tried Bash, was refused, and reported the test impossible.
|
|
393
|
-
{
|
|
394
|
-
const framed = frame("make the door open");
|
|
395
|
-
ok(framed.endsWith("make the door open"), "frame: the user's words come last and unaltered");
|
|
396
|
-
ok(/Roblox Studio/.test(framed), "frame: says where the prompt came from");
|
|
397
|
-
ok(/script_create|script_edit/.test(framed), "frame: names the tools that reach the place");
|
|
398
|
-
ok(!framed.includes(String.fromCharCode(13)), "frame: no stray carriage returns");
|
|
399
|
-
// A framing that swallows an empty prompt would send the agent a wall of
|
|
400
|
-
// instructions and no request.
|
|
401
|
-
ok(frame("").trim().length > 0, "frame: survives an empty prompt");
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
// The spawned marker reaches the server the agent starts ----------------------
|
|
405
|
-
//
|
|
406
|
-
// The shipped bug: the agent inherited RBX_STUDIO_MCP_SPAWNED, but the agent is
|
|
407
|
-
// not what connects -- it launches its own copy of this server, and Claude did
|
|
408
|
-
// not pass its environment down. So the spawned server announced itself as a
|
|
409
|
-
// stranger: "2 MCP clients connected" on every prompt, and a stopped agent sat
|
|
410
|
-
// in `clients` until the stale timeout swept it.
|
|
411
|
-
{
|
|
412
|
-
const claude = find("claude");
|
|
413
|
-
ok(claude !== undefined, "registry: claude is registered");
|
|
414
|
-
const flag = claude.mcpFlag();
|
|
415
|
-
ok(flag[0] === "--mcp-config", "claude: passes an mcp config file");
|
|
416
|
-
const written = JSON.parse(readFileSync(flag[1], "utf8"));
|
|
417
|
-
const server = written.mcpServers["rbx-studio"];
|
|
418
|
-
ok(server !== undefined, "claude config: names this server");
|
|
419
|
-
ok(
|
|
420
|
-
server.env?.RBX_STUDIO_MCP_SPAWNED === "1",
|
|
421
|
-
"claude config: marks the server it starts as spawned by the panel",
|
|
422
|
-
);
|
|
423
|
-
|
|
424
|
-
const dsh = find("dsh");
|
|
425
|
-
const argv = dsh.argv("hello");
|
|
426
|
-
const patch = argv[argv.indexOf("--patch") + 1];
|
|
427
|
-
ok(
|
|
428
|
-
readFileSync(patch, "utf8").includes("RBX_STUDIO_MCP_SPAWNED: '1'"),
|
|
429
|
-
"dsh overlay: carries the same marker",
|
|
430
|
-
);
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// Choosing which agent answers a prompt --------------------------------------
|
|
434
|
-
//
|
|
435
|
-
// The shipped bug: someone with opencode open typed a prompt into the panel and
|
|
436
|
-
// Claude Code answered, because the choice was "first one installed" and
|
|
437
|
-
// `claude` sorts first in the registry. The answer was fine and came from the
|
|
438
|
-
// wrong program.
|
|
439
|
-
{
|
|
440
|
-
const claude = find("claude");
|
|
441
|
-
const opencode = find("opencode");
|
|
442
|
-
|
|
443
|
-
ok(matchesClient(claude, "claude-code"), "claude matches the name its client reports");
|
|
444
|
-
ok(matchesClient(opencode, "opencode"), "opencode matches its own client name");
|
|
445
|
-
ok(!matchesClient(claude, "opencode"), "and does not match a different agent");
|
|
446
|
-
ok(!matchesClient(opencode, "claude-code"), "in either direction");
|
|
447
|
-
ok(!matchesClient(claude, ""), "a nameless client matches nothing");
|
|
448
|
-
|
|
449
|
-
// The registry marks `agent` rows and picks the prompt's target from the same
|
|
450
|
-
// function, so a listing can never say "in use" about an agent the prompt
|
|
451
|
-
// would not use. Exercised through the real bridge: what makes an agent a
|
|
452
|
-
// candidate is that it is CONNECTED, which only the bridge knows.
|
|
453
|
-
const bridge = new Bridge();
|
|
454
|
-
bridge.noteClient("one", { name: "opencode", version: "1", pid: 1 });
|
|
455
|
-
bridge.noteClient("two", { name: "claude-code", version: "2", pid: 2 });
|
|
456
|
-
|
|
457
|
-
const rows = await handleConsole(bridge, 44755, {
|
|
458
|
-
studioId: "studio-agents",
|
|
459
|
-
command: "agent",
|
|
460
|
-
args: [],
|
|
461
|
-
line: "agent",
|
|
462
|
-
});
|
|
463
|
-
const text = rows.map((row) => `${row.message} ${row.detail ?? ""}`).join(" | ");
|
|
464
|
-
|
|
465
|
-
// Only meaningful when both are actually installed on the machine running the
|
|
466
|
-
// suite; otherwise there is nothing to be ambiguous between.
|
|
467
|
-
const both = installed().filter((entry) => entry.id === "claude" || entry.id === "opencode");
|
|
468
|
-
if (both.length === 2) {
|
|
469
|
-
ok(
|
|
470
|
-
rows.some((row) => row.level === "warn" && /agent use <id>/.test(row.message)),
|
|
471
|
-
"two connected agents are not silently resolved to whichever sorts first",
|
|
472
|
-
);
|
|
473
|
-
ok(!/in use/.test(text), "and none is marked as the one in use");
|
|
474
|
-
ok(/connected/.test(text), "the ones that are attached are named as attached");
|
|
475
|
-
} else {
|
|
476
|
-
ok(true, "skipped: both agents are not installed here");
|
|
477
|
-
}
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
// --- the harnesses added after the opencode failure -------------------------
|
|
481
|
-
//
|
|
482
|
-
// Same rule as the block above: every shape here comes from the vendor's own
|
|
483
|
-
// documentation, not from a guess. The point of the rule is that a guessed
|
|
484
|
-
// envelope produces silence, and silence is the failure mode this whole file
|
|
485
|
-
// exists to catch.
|
|
486
|
-
{
|
|
487
|
-
// Amp says outright that it speaks Claude Code's protocol, so it is read by
|
|
488
|
-
// the same function -- and this asserts that, rather than trusting it.
|
|
489
|
-
const amp = readAll("amp", [
|
|
490
|
-
JSON.stringify({ type: "system", subtype: "init", session_id: "T-1", cwd: "/x" }),
|
|
491
|
-
JSON.stringify({
|
|
492
|
-
type: "assistant",
|
|
493
|
-
session_id: "T-1",
|
|
494
|
-
message: {
|
|
495
|
-
content: [
|
|
496
|
-
{ type: "text", text: "Built the wall." },
|
|
497
|
-
{ type: "tool_use", name: "create", input: { className: "Part" } },
|
|
498
|
-
],
|
|
499
|
-
},
|
|
500
|
-
}),
|
|
501
|
-
JSON.stringify({ type: "result", session_id: "T-1", is_error: false }),
|
|
502
|
-
]);
|
|
503
|
-
ok(amp.session === "T-1", "amp: session id");
|
|
504
|
-
ok(amp.rows.some((row) => row.message === "Built the wall."), "amp: prose");
|
|
505
|
-
ok(amp.rows.some((row) => row.message === "create"), "amp: tool calls");
|
|
506
|
-
ok(amp.rows.some((row) => row.message === "agent done"), "amp: result ends the run");
|
|
507
|
-
|
|
508
|
-
// Continuing is a different command, not a flag: `amp threads continue <id>`.
|
|
509
|
-
const ampResume = find("amp").argv("go on", "T-1");
|
|
510
|
-
ok(ampResume[0] === "threads" && ampResume[1] === "continue", "amp: resumes with its own verb");
|
|
511
|
-
ok(ampResume[2] === "T-1", "amp: the thread id follows the verb");
|
|
512
|
-
ok(find("amp").argv("hi", null)[0] === "-x", "amp: a fresh run uses the execute flag");
|
|
513
|
-
|
|
514
|
-
// Qwen Code is a Gemini CLI fork and kept its headless envelope.
|
|
515
|
-
const qwen = readAll("qwen", [
|
|
516
|
-
JSON.stringify({ type: "init", session_id: "q-1" }),
|
|
517
|
-
JSON.stringify({ type: "message", role: "assistant", content: "Done." }),
|
|
518
|
-
]);
|
|
519
|
-
ok(qwen.session === "q-1", "qwen: session id, read as gemini");
|
|
520
|
-
ok(qwen.rows.some((row) => row.message === "Done."), "qwen: prose");
|
|
521
|
-
|
|
522
|
-
// Droid answers with one object at the end rather than a stream.
|
|
523
|
-
const droid = readAll("droid", [JSON.stringify({ session_id: "d-1", result: "Placed it." })]);
|
|
524
|
-
ok(droid.session === "d-1", "droid: session id");
|
|
525
|
-
ok(droid.rows.some((row) => row.message === "Placed it."), "droid: the final answer");
|
|
526
|
-
const droidArgv = find("droid").argv("hi", null);
|
|
527
|
-
ok(droidArgv[0] === "exec", "droid: uses its exec verb");
|
|
528
|
-
ok(droidArgv.includes("--auto"), "droid: sets an autonomy level, or it blocks on approval");
|
|
529
|
-
|
|
530
|
-
// goose names sessions instead of numbering them.
|
|
531
|
-
const goose = readAll("goose", [
|
|
532
|
-
JSON.stringify({ type: "message", text: "Ready.", session_id: "g-1" }),
|
|
533
|
-
]);
|
|
534
|
-
ok(goose.rows.some((row) => row.message === "Ready."), "goose: prose");
|
|
535
|
-
const gooseResume = find("goose").argv("go on", "g-1");
|
|
536
|
-
ok(
|
|
537
|
-
gooseResume.includes("--resume") && gooseResume[gooseResume.indexOf("-n") + 1] === "g-1",
|
|
538
|
-
"goose: resumes a session by name",
|
|
539
|
-
);
|
|
540
|
-
|
|
541
|
-
// Copilot has no structured output, but it does block without --no-ask-user.
|
|
542
|
-
const copilotArgv = find("copilot").argv("hi", null);
|
|
543
|
-
ok(copilotArgv.includes("--no-ask-user"), "copilot: never waits for a human that is not there");
|
|
544
|
-
ok(
|
|
545
|
-
readAll("copilot", ["Explained it."]).rows[0].message === "Explained it.",
|
|
546
|
-
"copilot: plain text reaches the log",
|
|
547
|
-
);
|
|
548
|
-
|
|
549
|
-
// Aider blocks on confirmations unless told not to.
|
|
550
|
-
ok(find("aider").argv("hi", null).includes("--yes"), "aider: answers its own confirmations");
|
|
551
|
-
|
|
552
|
-
// Every harness must produce SOMETHING from a plain line. A reader that
|
|
553
|
-
// silently drops unknown input is exactly how opencode printed nothing.
|
|
554
|
-
for (const harness of ["amp", "qwen", "droid", "goose", "copilot", "aider", "crush"]) {
|
|
555
|
-
const rows = readAll(harness, ["some unstructured output"]).rows;
|
|
556
|
-
ok(rows.length > 0, harness + ": unrecognised output is shown, not swallowed");
|
|
557
|
-
}
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Checks the console panel's command line and the agents it starts.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a pure function or a call against a real Bridge with fake
|
|
5
|
+
* Studio sessions -- no sockets, no Studio, and above all no agent processes.
|
|
6
|
+
* That last one is the constraint that shapes the file: the interesting code is
|
|
7
|
+
* "start a coding agent and stream it back", and a test that actually did so
|
|
8
|
+
* would cost money, need an API key, and take a minute. So the seam is
|
|
9
|
+
* `harness.read`, which turns one line of a harness's output into console rows
|
|
10
|
+
* and is where every adapter's real work lives.
|
|
11
|
+
*
|
|
12
|
+
* The event shapes below are recorded from live runs of each CLI, not invented.
|
|
13
|
+
* A test built on a guessed envelope passes forever and proves nothing.
|
|
14
|
+
*/
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { Bridge } from "../dist/bridge/rpc.js";
|
|
18
|
+
import { LocalBridge } from "../dist/bridge/api.js";
|
|
19
|
+
import { frame, handleConsole } from "../dist/bridge/console.js";
|
|
20
|
+
import { cmdQuote, find, installed, matchesClient } from "../dist/bridge/harness.js";
|
|
21
|
+
|
|
22
|
+
let checks = 0;
|
|
23
|
+
const ok = (condition, what) => {
|
|
24
|
+
assert.ok(condition, what);
|
|
25
|
+
checks += 1;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Every row a harness produces for one line of its output. */
|
|
29
|
+
const readAll = (id, lines) => {
|
|
30
|
+
const harness = find(id);
|
|
31
|
+
const rows = [];
|
|
32
|
+
let session = null;
|
|
33
|
+
for (const line of lines) {
|
|
34
|
+
const reading = harness.read(line);
|
|
35
|
+
if (reading.session !== undefined) session = reading.session;
|
|
36
|
+
rows.push(...reading.lines);
|
|
37
|
+
}
|
|
38
|
+
return { rows, session };
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// --- Claude Code -----------------------------------------------------------
|
|
42
|
+
{
|
|
43
|
+
const { rows, session } = readAll("claude", [
|
|
44
|
+
JSON.stringify({ type: "system", subtype: "init", session_id: "abc-123" }),
|
|
45
|
+
JSON.stringify({
|
|
46
|
+
type: "assistant",
|
|
47
|
+
message: {
|
|
48
|
+
content: [
|
|
49
|
+
{ type: "text", text: "Looking at the place." },
|
|
50
|
+
{
|
|
51
|
+
type: "tool_use",
|
|
52
|
+
name: "mcp__rbx-studio__create",
|
|
53
|
+
input: { instances: [], parent: "Workspace" },
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
}),
|
|
58
|
+
"not json at all",
|
|
59
|
+
JSON.stringify({ type: "result", duration_ms: 5600, total_cost_usd: 0.1282 }),
|
|
60
|
+
]);
|
|
61
|
+
|
|
62
|
+
ok(session === "abc-123", "claude: session id is learned from the init event");
|
|
63
|
+
// Four rows, not three: the "not json at all" line is SHOWN. This assertion
|
|
64
|
+
// used to require it be dropped, which is the same instinct that made
|
|
65
|
+
// opencode print nothing -- a line we cannot parse is still evidence, and the
|
|
66
|
+
// panel is the only place the user can see it.
|
|
67
|
+
// Three rows, and which three is the point. The junk line IS shown -- that
|
|
68
|
+
// assertion used to require it be dropped, the same instinct that made
|
|
69
|
+
// opencode print nothing. The rbx-studio tool call is NOT, because Studio
|
|
70
|
+
// logs every call that reaches it with a friendlier name and a duration, and
|
|
71
|
+
// the agent's copy of it made every call two lines in the panel.
|
|
72
|
+
ok(rows.length === 3, "claude: junk is surfaced, our own tool call is not doubled");
|
|
73
|
+
ok(
|
|
74
|
+
rows.some((row) => row.level === "dim" && row.message === "not json at all"),
|
|
75
|
+
"claude: an unparseable line is shown dim rather than swallowed",
|
|
76
|
+
);
|
|
77
|
+
ok(
|
|
78
|
+
!rows.some((row) => row.level === "call"),
|
|
79
|
+
"claude: an rbx-studio call is left to Studio's own log",
|
|
80
|
+
);
|
|
81
|
+
ok(rows[0].level === "reply" && rows[0].message === "Looking at the place.", "claude: prose");
|
|
82
|
+
ok(rows[2].level === "ok" && rows[2].message === "agent done", "claude: result row");
|
|
83
|
+
ok(rows[2].detail === "5.6s $0.1282", "claude: duration and cost ride the detail column");
|
|
84
|
+
|
|
85
|
+
// A tool that is NOT ours is all the panel will ever hear about, so it stays.
|
|
86
|
+
const outside = readAll("claude", [
|
|
87
|
+
JSON.stringify({
|
|
88
|
+
type: "assistant",
|
|
89
|
+
message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls -la" } }] },
|
|
90
|
+
}),
|
|
91
|
+
]);
|
|
92
|
+
ok(
|
|
93
|
+
outside.rows[0].level === "call" && outside.rows[0].message === "Bash",
|
|
94
|
+
"claude: a tool Studio never sees is still logged",
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
// opencode names MCP tools `<server>_<tool>`, not `mcp__<server>__<tool>`.
|
|
98
|
+
// Unstripped it printed "rbx-studio_studio_status" AND doubled Studio's row.
|
|
99
|
+
const named = readAll("opencode", [
|
|
100
|
+
JSON.stringify({
|
|
101
|
+
type: "tool_use",
|
|
102
|
+
sessionID: "s",
|
|
103
|
+
part: { type: "tool", tool: "rbx-studio_studio_status", state: { input: {} } },
|
|
104
|
+
}),
|
|
105
|
+
]);
|
|
106
|
+
ok(named.rows.length === 0, "opencode: its own naming of our tools is recognised too");
|
|
107
|
+
|
|
108
|
+
// A failure must not be reported as a completion. This is the one row a user
|
|
109
|
+
// reads to decide whether to trust what just happened to their place.
|
|
110
|
+
const failed = readAll("claude", [
|
|
111
|
+
JSON.stringify({ type: "result", is_error: true, duration_ms: 100 }),
|
|
112
|
+
]);
|
|
113
|
+
ok(failed.rows[0].level === "error", "claude: is_error becomes an error row");
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Tool arguments --------------------------------------------------------
|
|
117
|
+
{
|
|
118
|
+
// Regression: a Luau snippet passed to execute_luau is multi-line, and 32
|
|
119
|
+
// characters of it used to carry a newline into a log whose rows are lines.
|
|
120
|
+
// The visible symptom was a stray "m" sitting at column 0 under the entry.
|
|
121
|
+
const { rows } = readAll("claude", [
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
type: "assistant",
|
|
124
|
+
message: {
|
|
125
|
+
content: [
|
|
126
|
+
{
|
|
127
|
+
type: "tool_use",
|
|
128
|
+
name: "Bash",
|
|
129
|
+
input: { source: "local m = workspace.SmallHouse\nm.Parent = nil\nprint(m)" },
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
},
|
|
133
|
+
}),
|
|
134
|
+
]);
|
|
135
|
+
ok(!rows[0].detail.includes("\n"), "tool detail never contains a newline");
|
|
136
|
+
ok(rows[0].message === "Bash", "an outside tool keeps its own name");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- every adapter, against its real envelope -------------------------------
|
|
140
|
+
//
|
|
141
|
+
// This block used to assert INVENTED event names -- `session.created` for
|
|
142
|
+
// codex, `message.part.updated` for opencode -- while the file's own header
|
|
143
|
+
// claimed the shapes were recorded from live runs. They were not, and the tests
|
|
144
|
+
// passed anyway, because a test written against the same wrong envelope as the
|
|
145
|
+
// code agrees with it perfectly.
|
|
146
|
+
//
|
|
147
|
+
// What that cost: a prompt sent to opencode printed NOTHING. Every line fell
|
|
148
|
+
// through to no rows, the panel logged a blank run and went idle, and the
|
|
149
|
+
// agent's answer was thrown away. The lines below are copied from real runs
|
|
150
|
+
// (opencode 1.18.30, claude 2.1.266) or from the vendors' own documented
|
|
151
|
+
// schemas for the CLIs not installed here.
|
|
152
|
+
{
|
|
153
|
+
// opencode 1.18.30, verbatim from `opencode run --format json`.
|
|
154
|
+
const oc = readAll("opencode", [
|
|
155
|
+
JSON.stringify({ type: "step_start", sessionID: "ses_abc", part: { type: "step-start" } }),
|
|
156
|
+
JSON.stringify({
|
|
157
|
+
type: "tool_use",
|
|
158
|
+
sessionID: "ses_abc",
|
|
159
|
+
part: { type: "tool", tool: "glob", state: { input: { pattern: "*.ts" } } },
|
|
160
|
+
}),
|
|
161
|
+
JSON.stringify({
|
|
162
|
+
type: "text",
|
|
163
|
+
sessionID: "ses_abc",
|
|
164
|
+
part: { type: "text", text: "Hi there, how's it going?" },
|
|
165
|
+
}),
|
|
166
|
+
JSON.stringify({ type: "step_finish", sessionID: "ses_abc", part: { type: "step-finish" } }),
|
|
167
|
+
]);
|
|
168
|
+
ok(oc.session === "ses_abc", "opencode: session id rides on every event");
|
|
169
|
+
ok(
|
|
170
|
+
oc.rows.some((row) => row.level === "reply" && row.message === "Hi there, how's it going?"),
|
|
171
|
+
"opencode: the answer is printed -- the bug was that it never was",
|
|
172
|
+
);
|
|
173
|
+
ok(oc.rows.some((row) => row.message === "glob"), "opencode: tool calls are printed");
|
|
174
|
+
ok(
|
|
175
|
+
!oc.rows.some((row) => row.message === "agent done"),
|
|
176
|
+
"opencode: step_finish is per step, not the end of the run",
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
// Codex, from the documented exec --json protocol.
|
|
180
|
+
const codex = readAll("codex", [
|
|
181
|
+
JSON.stringify({ type: "thread.started", thread_id: "019cec77-af02" }),
|
|
182
|
+
JSON.stringify({ type: "turn.started" }),
|
|
183
|
+
JSON.stringify({
|
|
184
|
+
type: "item.started",
|
|
185
|
+
item: { id: "i1", type: "mcp_tool_call", server: "rbx", tool: "create", arguments: {} },
|
|
186
|
+
}),
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
type: "item.completed",
|
|
189
|
+
item: { id: "i1", type: "mcp_tool_call", server: "rbx", tool: "create" },
|
|
190
|
+
}),
|
|
191
|
+
JSON.stringify({ type: "item.completed", item: { id: "i2", type: "agent_message", text: "Done." } }),
|
|
192
|
+
JSON.stringify({ type: "turn.completed", usage: {} }),
|
|
193
|
+
]);
|
|
194
|
+
ok(codex.session === "019cec77-af02", "codex: session comes from thread.started/thread_id");
|
|
195
|
+
ok(
|
|
196
|
+
codex.rows.some((row) => row.level === "reply" && row.message === "Done."),
|
|
197
|
+
"codex: prose",
|
|
198
|
+
);
|
|
199
|
+
ok(
|
|
200
|
+
codex.rows.filter((row) => row.message === "create").length === 1,
|
|
201
|
+
"codex: a tool reported started AND completed is logged once",
|
|
202
|
+
);
|
|
203
|
+
ok(codex.rows.some((row) => row.message === "agent done"), "codex: turn.completed ends the run");
|
|
204
|
+
|
|
205
|
+
// Codex global flags must precede the `resume` subcommand or it refuses them.
|
|
206
|
+
const resumed = find("codex").argv("hello", "thread-1");
|
|
207
|
+
ok(
|
|
208
|
+
resumed.indexOf("--skip-git-repo-check") < resumed.indexOf("resume"),
|
|
209
|
+
"codex: global flags come before the resume subcommand",
|
|
210
|
+
);
|
|
211
|
+
ok(resumed[resumed.length - 1] === "hello", "codex: the prompt stays last");
|
|
212
|
+
|
|
213
|
+
// Gemini, from the documented headless stream-json events.
|
|
214
|
+
const gem = readAll("gemini", [
|
|
215
|
+
JSON.stringify({ type: "init", session_id: "gem-1", model: "gemini" }),
|
|
216
|
+
JSON.stringify({ type: "message", role: "user", content: "what did I ask" }),
|
|
217
|
+
JSON.stringify({ type: "message", role: "assistant", content: "Built it." }),
|
|
218
|
+
JSON.stringify({ type: "result" }),
|
|
219
|
+
]);
|
|
220
|
+
ok(gem.session === "gem-1", "gemini: session id");
|
|
221
|
+
ok(gem.rows.some((row) => row.message === "Built it."), "gemini: the assistant half is printed");
|
|
222
|
+
ok(
|
|
223
|
+
!gem.rows.some((row) => row.message === "what did I ask"),
|
|
224
|
+
"gemini: the user half is not echoed back at them",
|
|
225
|
+
);
|
|
226
|
+
|
|
227
|
+
// Cursor, from the documented stream-json envelope.
|
|
228
|
+
const cur = readAll("cursor", [
|
|
229
|
+
JSON.stringify({ type: "system", subtype: "init", session_id: "cur-1" }),
|
|
230
|
+
JSON.stringify({
|
|
231
|
+
type: "tool_call",
|
|
232
|
+
subtype: "started",
|
|
233
|
+
session_id: "cur-1",
|
|
234
|
+
tool_call: { readToolCall: { args: { path: "a.ts" } } },
|
|
235
|
+
}),
|
|
236
|
+
JSON.stringify({
|
|
237
|
+
type: "assistant",
|
|
238
|
+
session_id: "cur-1",
|
|
239
|
+
message: { role: "assistant", content: [{ type: "text", text: "Read it." }] },
|
|
240
|
+
}),
|
|
241
|
+
JSON.stringify({ type: "result", subtype: "success", is_error: false, session_id: "cur-1" }),
|
|
242
|
+
]);
|
|
243
|
+
ok(cur.session === "cur-1", "cursor: session id rides on every event");
|
|
244
|
+
ok(cur.rows.some((row) => row.message === "Read it."), "cursor: text is nested in message.content");
|
|
245
|
+
ok(cur.rows.some((row) => row.message === "readToolCall"), "cursor: the tool key names the call");
|
|
246
|
+
ok(cur.rows.some((row) => row.message === "agent done"), "cursor: result ends the run");
|
|
247
|
+
|
|
248
|
+
// Crush has no event stream, but it does have its own verb -- the generic
|
|
249
|
+
// adapter guessed `-p`, which crush rejects outright as an unknown flag.
|
|
250
|
+
const crush = find("crush").argv("hello", null);
|
|
251
|
+
ok(crush[0] === "run", "crush: uses its `run` verb, not a guessed -p flag");
|
|
252
|
+
ok(
|
|
253
|
+
readAll("crush", ["Placed the model."]).rows[0].message === "Placed the model.",
|
|
254
|
+
"crush: plain text still reaches the log",
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// --- DeepSeek Harness ------------------------------------------------------
|
|
259
|
+
{
|
|
260
|
+
const dsh = find("dsh");
|
|
261
|
+
const argv = dsh.argv("add a spawn point", null);
|
|
262
|
+
|
|
263
|
+
// `dsh [options] [command] [args...]`: --patch is a launcher option and the
|
|
264
|
+
// prompt is a positional argument, so the flag has to come first. Getting
|
|
265
|
+
// this backwards is silent -- dsh reads the prompt as the patch path.
|
|
266
|
+
const patchAt = argv.indexOf("--patch");
|
|
267
|
+
ok(patchAt !== -1, "dsh: the overlay is passed");
|
|
268
|
+
ok(
|
|
269
|
+
patchAt < argv.indexOf("add a spawn point"),
|
|
270
|
+
"dsh: --patch comes before the prompt, or dsh reads the prompt as a path",
|
|
271
|
+
);
|
|
272
|
+
ok(argv[argv.length - 1] === "add a spawn point", "dsh: the prompt is last");
|
|
273
|
+
ok(dsh.mcpFlag === undefined, "dsh: builds its own argv rather than appending flags");
|
|
274
|
+
|
|
275
|
+
const { rows } = readAll("dsh", ["The spawn point is placed.", " ", ""]);
|
|
276
|
+
ok(rows.length === 1, "dsh: blank lines are not rows");
|
|
277
|
+
ok(rows[0].level === "reply", "dsh: headless prints prose, not events");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// --- The registry ----------------------------------------------------------
|
|
281
|
+
{
|
|
282
|
+
ok(find("claude") !== undefined && find("nonesuch") === undefined, "registry: lookup by id");
|
|
283
|
+
ok(
|
|
284
|
+
installed().every((entry) => typeof entry.id === "string" && entry.id.length > 0),
|
|
285
|
+
"registry: every detected harness is named",
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// --- Panel-started agents are not other people's clients -------------------
|
|
290
|
+
{
|
|
291
|
+
const bridge = new Bridge();
|
|
292
|
+
const announcements = [];
|
|
293
|
+
bridge.watchClients((count) => announcements.push(count));
|
|
294
|
+
|
|
295
|
+
const mine = new LocalBridge(bridge);
|
|
296
|
+
ok(bridge.clientCount() === 1, "an ordinary client counts");
|
|
297
|
+
|
|
298
|
+
// What a spawned agent's own server reports when it says hello. Counting it
|
|
299
|
+
// flashed the badge to 2 and logged an arrival and a departure around every
|
|
300
|
+
// single prompt -- around output the user was trying to read.
|
|
301
|
+
bridge.noteClient("spawned-agent", { name: "claude-code", pid: 42, spawned: true });
|
|
302
|
+
ok(bridge.clientCount() === 1, "a panel-started agent is not counted");
|
|
303
|
+
ok(
|
|
304
|
+
bridge.clientList().every((client) => client.name !== "claude-code"),
|
|
305
|
+
"a panel-started agent is not in the roster",
|
|
306
|
+
);
|
|
307
|
+
ok(
|
|
308
|
+
announcements.every((count) => count <= 1),
|
|
309
|
+
"a panel-started agent never announces an arrival",
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
mine.goodbye();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// --- Command routing -------------------------------------------------------
|
|
316
|
+
{
|
|
317
|
+
const bridge = new Bridge();
|
|
318
|
+
const identity = (studioId, placeId, placeName) => ({
|
|
319
|
+
studioId,
|
|
320
|
+
placeName,
|
|
321
|
+
placeId,
|
|
322
|
+
pluginVersion: "test",
|
|
323
|
+
buildId: "test",
|
|
324
|
+
protocolVersion: 1,
|
|
325
|
+
transport: "poll",
|
|
326
|
+
context: "edit",
|
|
327
|
+
});
|
|
328
|
+
bridge.attach(identity("studio-a", 111, "Alpha"), null);
|
|
329
|
+
bridge.attach(identity("studio-b", 222, "Beta"), null);
|
|
330
|
+
|
|
331
|
+
const run = (command, args = []) =>
|
|
332
|
+
handleConsole(bridge, 44755, { studioId: "studio-a", command, args, line: command });
|
|
333
|
+
|
|
334
|
+
const studios = await run("studios");
|
|
335
|
+
ok(studios.length === 3, "studios: a heading and one row per Studio");
|
|
336
|
+
ok(
|
|
337
|
+
studios.some((row) => row.message.includes("(this panel)")),
|
|
338
|
+
"studios: the asking panel is marked, so two rows with one place name are told apart",
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
// `use` takes the number printed by `studios`, because requiring the id would
|
|
342
|
+
// mean reading a hex string off one line to type it into the next.
|
|
343
|
+
const used = await run("use", ["2"]);
|
|
344
|
+
ok(used[0].level === "ok" && used[0].message.includes("Beta"), "use: resolves a list number");
|
|
345
|
+
ok(bridge.activeId("nobody-in-particular") === "studio-b", "use: applies to clients too");
|
|
346
|
+
|
|
347
|
+
const bad = await run("use", ["nope"]);
|
|
348
|
+
ok(bad[0].level === "error", "use: an unknown target is an error, not a silent no-op");
|
|
349
|
+
ok(bridge.activeId("nobody") === "studio-b", "use: a failed switch changes nothing");
|
|
350
|
+
|
|
351
|
+
const noArg = await run("use");
|
|
352
|
+
ok(noArg[0].message.startsWith("usage:"), "use: says how to use it");
|
|
353
|
+
|
|
354
|
+
const stopped = await run("stop");
|
|
355
|
+
ok(stopped[0].message === "nothing is running", "stop: honest when idle");
|
|
356
|
+
|
|
357
|
+
const agents = await run("agent");
|
|
358
|
+
ok(agents.length > 0, "agent: always answers, installed or not");
|
|
359
|
+
|
|
360
|
+
const unknown = await run("wat");
|
|
361
|
+
ok(unknown[0].level === "error", "an unknown command is reported, not guessed at");
|
|
362
|
+
ok(
|
|
363
|
+
unknown[0].detail.includes("different builds"),
|
|
364
|
+
"an unknown command names the likely cause, since the plugin filters first",
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// --- doctor ----------------------------------------------------------------
|
|
369
|
+
{
|
|
370
|
+
const bridge = new Bridge();
|
|
371
|
+
// A port nothing is listening on: doctor must still answer, because "why is
|
|
372
|
+
// nothing working" is exactly when it is run.
|
|
373
|
+
const rows = await handleConsole(bridge, 45999, {
|
|
374
|
+
studioId: "studio-a",
|
|
375
|
+
command: "doctor",
|
|
376
|
+
args: [],
|
|
377
|
+
line: "doctor",
|
|
378
|
+
});
|
|
379
|
+
ok(rows.length > 1, "doctor: reports against a dead port rather than failing");
|
|
380
|
+
ok(
|
|
381
|
+
rows.every((row) => typeof row.message === "string" && !row.message.includes("\n")),
|
|
382
|
+
"doctor: every row is one line",
|
|
383
|
+
);
|
|
384
|
+
const summary = rows[rows.length - 1];
|
|
385
|
+
ok(/passed.*warning.*failure/.test(summary.message), "doctor: ends with a tally");
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// The framing preamble --------------------------------------------------------
|
|
389
|
+
//
|
|
390
|
+
// The shipped bug: "create a simple script, then edit it" sent the agent to the
|
|
391
|
+
// filesystem, because that is where a coding agent spawned in a repo assumes a
|
|
392
|
+
// script lives. It tried Bash, was refused, and reported the test impossible.
|
|
393
|
+
{
|
|
394
|
+
const framed = frame("make the door open");
|
|
395
|
+
ok(framed.endsWith("make the door open"), "frame: the user's words come last and unaltered");
|
|
396
|
+
ok(/Roblox Studio/.test(framed), "frame: says where the prompt came from");
|
|
397
|
+
ok(/script_create|script_edit/.test(framed), "frame: names the tools that reach the place");
|
|
398
|
+
ok(!framed.includes(String.fromCharCode(13)), "frame: no stray carriage returns");
|
|
399
|
+
// A framing that swallows an empty prompt would send the agent a wall of
|
|
400
|
+
// instructions and no request.
|
|
401
|
+
ok(frame("").trim().length > 0, "frame: survives an empty prompt");
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// The spawned marker reaches the server the agent starts ----------------------
|
|
405
|
+
//
|
|
406
|
+
// The shipped bug: the agent inherited RBX_STUDIO_MCP_SPAWNED, but the agent is
|
|
407
|
+
// not what connects -- it launches its own copy of this server, and Claude did
|
|
408
|
+
// not pass its environment down. So the spawned server announced itself as a
|
|
409
|
+
// stranger: "2 MCP clients connected" on every prompt, and a stopped agent sat
|
|
410
|
+
// in `clients` until the stale timeout swept it.
|
|
411
|
+
{
|
|
412
|
+
const claude = find("claude");
|
|
413
|
+
ok(claude !== undefined, "registry: claude is registered");
|
|
414
|
+
const flag = claude.mcpFlag();
|
|
415
|
+
ok(flag[0] === "--mcp-config", "claude: passes an mcp config file");
|
|
416
|
+
const written = JSON.parse(readFileSync(flag[1], "utf8"));
|
|
417
|
+
const server = written.mcpServers["rbx-studio"];
|
|
418
|
+
ok(server !== undefined, "claude config: names this server");
|
|
419
|
+
ok(
|
|
420
|
+
server.env?.RBX_STUDIO_MCP_SPAWNED === "1",
|
|
421
|
+
"claude config: marks the server it starts as spawned by the panel",
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const dsh = find("dsh");
|
|
425
|
+
const argv = dsh.argv("hello");
|
|
426
|
+
const patch = argv[argv.indexOf("--patch") + 1];
|
|
427
|
+
ok(
|
|
428
|
+
readFileSync(patch, "utf8").includes("RBX_STUDIO_MCP_SPAWNED: '1'"),
|
|
429
|
+
"dsh overlay: carries the same marker",
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Choosing which agent answers a prompt --------------------------------------
|
|
434
|
+
//
|
|
435
|
+
// The shipped bug: someone with opencode open typed a prompt into the panel and
|
|
436
|
+
// Claude Code answered, because the choice was "first one installed" and
|
|
437
|
+
// `claude` sorts first in the registry. The answer was fine and came from the
|
|
438
|
+
// wrong program.
|
|
439
|
+
{
|
|
440
|
+
const claude = find("claude");
|
|
441
|
+
const opencode = find("opencode");
|
|
442
|
+
|
|
443
|
+
ok(matchesClient(claude, "claude-code"), "claude matches the name its client reports");
|
|
444
|
+
ok(matchesClient(opencode, "opencode"), "opencode matches its own client name");
|
|
445
|
+
ok(!matchesClient(claude, "opencode"), "and does not match a different agent");
|
|
446
|
+
ok(!matchesClient(opencode, "claude-code"), "in either direction");
|
|
447
|
+
ok(!matchesClient(claude, ""), "a nameless client matches nothing");
|
|
448
|
+
|
|
449
|
+
// The registry marks `agent` rows and picks the prompt's target from the same
|
|
450
|
+
// function, so a listing can never say "in use" about an agent the prompt
|
|
451
|
+
// would not use. Exercised through the real bridge: what makes an agent a
|
|
452
|
+
// candidate is that it is CONNECTED, which only the bridge knows.
|
|
453
|
+
const bridge = new Bridge();
|
|
454
|
+
bridge.noteClient("one", { name: "opencode", version: "1", pid: 1 });
|
|
455
|
+
bridge.noteClient("two", { name: "claude-code", version: "2", pid: 2 });
|
|
456
|
+
|
|
457
|
+
const rows = await handleConsole(bridge, 44755, {
|
|
458
|
+
studioId: "studio-agents",
|
|
459
|
+
command: "agent",
|
|
460
|
+
args: [],
|
|
461
|
+
line: "agent",
|
|
462
|
+
});
|
|
463
|
+
const text = rows.map((row) => `${row.message} ${row.detail ?? ""}`).join(" | ");
|
|
464
|
+
|
|
465
|
+
// Only meaningful when both are actually installed on the machine running the
|
|
466
|
+
// suite; otherwise there is nothing to be ambiguous between.
|
|
467
|
+
const both = installed().filter((entry) => entry.id === "claude" || entry.id === "opencode");
|
|
468
|
+
if (both.length === 2) {
|
|
469
|
+
ok(
|
|
470
|
+
rows.some((row) => row.level === "warn" && /agent use <id>/.test(row.message)),
|
|
471
|
+
"two connected agents are not silently resolved to whichever sorts first",
|
|
472
|
+
);
|
|
473
|
+
ok(!/in use/.test(text), "and none is marked as the one in use");
|
|
474
|
+
ok(/connected/.test(text), "the ones that are attached are named as attached");
|
|
475
|
+
} else {
|
|
476
|
+
ok(true, "skipped: both agents are not installed here");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --- the harnesses added after the opencode failure -------------------------
|
|
481
|
+
//
|
|
482
|
+
// Same rule as the block above: every shape here comes from the vendor's own
|
|
483
|
+
// documentation, not from a guess. The point of the rule is that a guessed
|
|
484
|
+
// envelope produces silence, and silence is the failure mode this whole file
|
|
485
|
+
// exists to catch.
|
|
486
|
+
{
|
|
487
|
+
// Amp says outright that it speaks Claude Code's protocol, so it is read by
|
|
488
|
+
// the same function -- and this asserts that, rather than trusting it.
|
|
489
|
+
const amp = readAll("amp", [
|
|
490
|
+
JSON.stringify({ type: "system", subtype: "init", session_id: "T-1", cwd: "/x" }),
|
|
491
|
+
JSON.stringify({
|
|
492
|
+
type: "assistant",
|
|
493
|
+
session_id: "T-1",
|
|
494
|
+
message: {
|
|
495
|
+
content: [
|
|
496
|
+
{ type: "text", text: "Built the wall." },
|
|
497
|
+
{ type: "tool_use", name: "create", input: { className: "Part" } },
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
JSON.stringify({ type: "result", session_id: "T-1", is_error: false }),
|
|
502
|
+
]);
|
|
503
|
+
ok(amp.session === "T-1", "amp: session id");
|
|
504
|
+
ok(amp.rows.some((row) => row.message === "Built the wall."), "amp: prose");
|
|
505
|
+
ok(amp.rows.some((row) => row.message === "create"), "amp: tool calls");
|
|
506
|
+
ok(amp.rows.some((row) => row.message === "agent done"), "amp: result ends the run");
|
|
507
|
+
|
|
508
|
+
// Continuing is a different command, not a flag: `amp threads continue <id>`.
|
|
509
|
+
const ampResume = find("amp").argv("go on", "T-1");
|
|
510
|
+
ok(ampResume[0] === "threads" && ampResume[1] === "continue", "amp: resumes with its own verb");
|
|
511
|
+
ok(ampResume[2] === "T-1", "amp: the thread id follows the verb");
|
|
512
|
+
ok(find("amp").argv("hi", null)[0] === "-x", "amp: a fresh run uses the execute flag");
|
|
513
|
+
|
|
514
|
+
// Qwen Code is a Gemini CLI fork and kept its headless envelope.
|
|
515
|
+
const qwen = readAll("qwen", [
|
|
516
|
+
JSON.stringify({ type: "init", session_id: "q-1" }),
|
|
517
|
+
JSON.stringify({ type: "message", role: "assistant", content: "Done." }),
|
|
518
|
+
]);
|
|
519
|
+
ok(qwen.session === "q-1", "qwen: session id, read as gemini");
|
|
520
|
+
ok(qwen.rows.some((row) => row.message === "Done."), "qwen: prose");
|
|
521
|
+
|
|
522
|
+
// Droid answers with one object at the end rather than a stream.
|
|
523
|
+
const droid = readAll("droid", [JSON.stringify({ session_id: "d-1", result: "Placed it." })]);
|
|
524
|
+
ok(droid.session === "d-1", "droid: session id");
|
|
525
|
+
ok(droid.rows.some((row) => row.message === "Placed it."), "droid: the final answer");
|
|
526
|
+
const droidArgv = find("droid").argv("hi", null);
|
|
527
|
+
ok(droidArgv[0] === "exec", "droid: uses its exec verb");
|
|
528
|
+
ok(droidArgv.includes("--auto"), "droid: sets an autonomy level, or it blocks on approval");
|
|
529
|
+
|
|
530
|
+
// goose names sessions instead of numbering them.
|
|
531
|
+
const goose = readAll("goose", [
|
|
532
|
+
JSON.stringify({ type: "message", text: "Ready.", session_id: "g-1" }),
|
|
533
|
+
]);
|
|
534
|
+
ok(goose.rows.some((row) => row.message === "Ready."), "goose: prose");
|
|
535
|
+
const gooseResume = find("goose").argv("go on", "g-1");
|
|
536
|
+
ok(
|
|
537
|
+
gooseResume.includes("--resume") && gooseResume[gooseResume.indexOf("-n") + 1] === "g-1",
|
|
538
|
+
"goose: resumes a session by name",
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
// Copilot has no structured output, but it does block without --no-ask-user.
|
|
542
|
+
const copilotArgv = find("copilot").argv("hi", null);
|
|
543
|
+
ok(copilotArgv.includes("--no-ask-user"), "copilot: never waits for a human that is not there");
|
|
544
|
+
ok(
|
|
545
|
+
readAll("copilot", ["Explained it."]).rows[0].message === "Explained it.",
|
|
546
|
+
"copilot: plain text reaches the log",
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
// Aider blocks on confirmations unless told not to.
|
|
550
|
+
ok(find("aider").argv("hi", null).includes("--yes"), "aider: answers its own confirmations");
|
|
551
|
+
|
|
552
|
+
// Every harness must produce SOMETHING from a plain line. A reader that
|
|
553
|
+
// silently drops unknown input is exactly how opencode printed nothing.
|
|
554
|
+
for (const harness of ["amp", "qwen", "droid", "goose", "copilot", "aider", "crush"]) {
|
|
555
|
+
const rows = readAll(harness, ["some unstructured output"]).rows;
|
|
556
|
+
ok(rows.length > 0, harness + ": unrecognised output is shown, not swallowed");
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// A prompt reaches a Windows .cmd shim as ONE argument, byte for byte. Before
|
|
561
|
+
// cmdQuote, `&` in a prompt ran the rest of it as a second shell command.
|
|
562
|
+
if (process.platform === "win32") {
|
|
563
|
+
const { mkdtempSync, writeFileSync } = await import("node:fs");
|
|
564
|
+
const { spawnSync } = await import("node:child_process");
|
|
565
|
+
const { tmpdir } = await import("node:os");
|
|
566
|
+
const { join } = await import("node:path");
|
|
567
|
+
const dir = mkdtempSync(join(tmpdir(), "cmd quote "));
|
|
568
|
+
writeFileSync(join(dir, "cli.js"), "console.log(JSON.stringify(process.argv.slice(2)))\n");
|
|
569
|
+
const shim = join(dir, "agent.cmd");
|
|
570
|
+
writeFileSync(shim, '@ECHO off\r\n"' + process.execPath + '" "%~dp0\\cli.js" %*\r\n');
|
|
571
|
+
const slash = "\\";
|
|
572
|
+
for (const prompt of [
|
|
573
|
+
"add a door & a window",
|
|
574
|
+
'say "hi" | more',
|
|
575
|
+
"100% %PATH% !x! ^caret",
|
|
576
|
+
"C:" + slash + "path" + slash,
|
|
577
|
+
"(a) <b> ;c, *d? `e`",
|
|
578
|
+
"tail" + slash + slash + '"q',
|
|
579
|
+
]) {
|
|
580
|
+
const run = spawnSync(
|
|
581
|
+
process.env.ComSpec ?? "cmd.exe",
|
|
582
|
+
["/d", "/s", "/c", `"${[shim, prompt].map(cmdQuote).join(" ")}"`],
|
|
583
|
+
{ windowsVerbatimArguments: true, encoding: "utf8" },
|
|
584
|
+
);
|
|
585
|
+
ok(
|
|
586
|
+
JSON.stringify(JSON.parse(run.stdout.trim())) === JSON.stringify([prompt]),
|
|
587
|
+
"cmd.exe passes the prompt through unchanged: " + prompt,
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
process.stdout.write(`console: ${checks} checks pass\n`);
|