1// @effect-diagnostics nodeBuiltinImport:off
2import * as NodeFS from "node:fs";
3import * as NodeZlib from "node:zlib";
4import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as Layer from "effect/Layer";
7import { assert, it } from "@effect/vitest";
8import * as Effect from "effect/Effect";
9import * as Schema from "effect/Schema";
10
11import {
12 AGENT_DEVICE_SHELL,
13 agentDeviceSessionError,
14 agentDeviceSessions,
15 describeSessions,
16 deviceFailure,
17 type LauncherTarget,
18 launcherHelp,
19 makeDeviceTools,
20 type ShellCall,
21} from "./devices.ts";
22import {
23 buildBase,
24 buildMessages,
25 cannedOutput,
26 makeSample,
27 McpToolUseCase,
28 shellRequest,
29 t3SubagentContext,
30 t3SystemAppend,
31 withDevices,
32} from "./run.ts";
33import { DeviceOperationError, LOCAL_DEVICE_HOST_ID } from "@t3tools/contracts";
34
35const MAC = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
36const IPHONE = "BBBBBBBB-BBBB-4BBB-8BBB-BBBBBBBBBBBB";
37const world = {
38 devices: [
39 {
40 hostId: MAC,
41 id: IPHONE,
42 name: "Acme iPhone",
43 platform: "ios",
44 version: "iOS 26.0",
45 booted: true,
46 physical: false,
47 },
48 ],
49 deviceHosts: { [MAC]: "build-mac" },
50};
51const screenshotFailure = {
52 _tag: "DeviceOperationError",
53 operation: "screenshot",
54 reason: "request_failed",
55 cause: null,
56};
57const decodeCase = Schema.decodeUnknownSync(McpToolUseCase);
58const deviceCase = (setup: object) =>
59 decodeCase({
60 id: "device-case",
61 area: "device-manager",
62 kind: "guard",
63 pattern: "device",
64 experience: "A device case.",
65 setup: { role: "top", world, ...setup },
66 prompt: "Show a fresh screenshot of the iPhone on build-mac.",
67 expect: {},
68 });
69const capture = JSON.parse(
70 NodeZlib.gunzipSync(
71 NodeFS.readFileSync(new URL("./templates/claude.json.gz", import.meta.url)),
72 ).toString(),
73).request.provider_native;
74const model = "claude-opus-5-5";
75const base = buildBase(capture, { model, keepUserInstructions: false });
76const json = (text: string) => JSON.parse(text);
77
78it.effect("answers device calls with T3's handlers over the case's world", () =>
79 Effect.gen(function* () {
80 const { devices, deviceHosts } = deviceCase({}).setup.world ?? {};
81 const tools = yield* makeDeviceTools({ devices: devices ?? [], deviceHosts }, base.environment);
82 const listed = json((yield* tools.call("device_list", {})).text);
83 assert.deepEqual(listed.devices, world.devices);
84 assert.deepEqual(
85 listed.hosts.map((host: { label: string }) => host.label),
86 ["This machine", "build-mac"],
87 );
88 const opened = json((yield* tools.call("device_open", { hostId: MAC, deviceId: IPHONE })).text);
89 const { command } = opened.agentDevice;
90 assert.match(command, /^\/home\/user\/\.t3\/userdata\/device\/.+\/agent-device$/);
91 assert.include(opened.quickStart, command);
92 assert.deepEqual(tools.launchers, [command]);
93 // The image never reaches the model; T3's text result has the rest.
94 assert.deepEqual(json((yield* tools.call("device_screenshot", {})).text).screenshot, {
95 mimeType: "image/png",
96 width: 1206,
97 height: 2622,
98 });
99 const failed = yield* tools.call("device_screenshot", {}, deviceFailure(screenshotFailure));
100 assert.isTrue(failed.isError);
101 assert.include(failed.text, "screenshot failed");
102 const elsewhere = yield* tools.call("device_open", { hostId: "local", deviceId: IPHONE });
103 assert.isTrue(elsewhere.isError);
104 assert.include(elsewhere.text, IPHONE);
105 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
106);
107
108it.effect("lists the boot state T3 left a device in", () =>
109 Effect.gen(function* () {
110 const { devices, deviceHosts } = deviceCase({}).setup.world ?? {};
111 const stopped = (devices ?? []).map((device) => ({ ...device, booted: false }));
112 const tools = yield* makeDeviceTools({ devices: stopped, deviceHosts }, base.environment);
113 const before = json((yield* tools.call("device_list", {})).text);
114 assert.isFalse(before.devices.find((d: { id: string }) => d.id === IPHONE).booted);
115 yield* tools.call("device_open", { hostId: MAC, deviceId: IPHONE });
116 const after = json((yield* tools.call("device_list", {})).text);
117 assert.isTrue(after.devices.find((d: { id: string }) => d.id === IPHONE).booted);
118 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
119);
120
121it("decodes only T3 device errors as injected failures", () => {
122 assert.isDefined(deviceFailure(screenshotFailure));
123 assert.isUndefined(deviceFailure({ error: "Synthetic hub is unavailable", isError: true }));
124 assert.isUndefined(deviceFailure("default"));
125});
126
127it.effect("rebuilds prior device results with T3, whose sessions reach the measured turn", () =>
128 Effect.gen(function* () {
129 const testCase = deviceCase({
130 priorTurns: [
131 { role: "user", text: "Show me Settings on the iPhone on build-mac." },
132 {
133 role: "assistant",
134 toolCalls: [
135 { tool: "device_list", input: {}, result: "default" },
136 { tool: "device_open", input: { hostId: MAC, deviceId: IPHONE }, result: "default" },
137 ],
138 },
139 ],
140 mocks: { device_screenshot: screenshotFailure },
141 });
142 const sample = makeSample(testCase, base);
143 assert.throws(() => buildMessages(sample), /run withDevices/);
144 yield* withDevices(sample);
145 const shown = JSON.stringify(buildMessages(sample));
146 const [launcher = ""] = sample.devices?.launchers ?? [];
147 assert.include(shown, launcher);
148 assert.notInclude(shown, `"content":"default"`);
149 const tools = sample.devices!;
150 assert.isFalse((yield* tools.call("device_screenshot", {})).isError);
151 const mock = deviceFailure(testCase.setup.mocks?.device_screenshot);
152 assert.isTrue((yield* tools.call("device_screenshot", {}, mock)).isError);
153 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
154);
155
156it.effect("gives a native subagent T3's SubagentStart context instead of T3's append", () =>
157 Effect.gen(function* () {
158 const subagentContext = yield* Effect.promise(() => t3SubagentContext(model));
159 const native = {
160 ...buildBase(capture, { model, keepUserInstructions: false, nativeSubagent: true }),
161 subagentContext,
162 };
163 const system = (blocks: typeof base.system) =>
164 blocks.map((block) => String(block.text)).join("");
165 assert.include(system(base.system), t3SystemAppend(model));
166 assert.notInclude(system(native.system), t3SystemAppend(model));
167 const testCase = deviceCase({ role: "native-subagent" });
168 const shown = (sample: typeof native) =>
169 buildMessages(makeSample(testCase, sample))[0]?.content.map((block) => String(block.text));
170 const reminders = shown(native)?.join("\n") ?? "";
171 // A ref without T3's hook gives the subagent nothing.
172 assert.equal(
173 reminders.includes("SubagentStart hook additional context"),
174 subagentContext !== "",
175 );
176 assert.include(reminders, subagentContext);
177 // Claude Code 2.1.310 records the hook's context after the task, and sends it that way.
178 const blocks = shown({ ...native, subagentContext: "## T3 Code devices" }) ?? [];
179 assert.equal(blocks.at(-2), testCase.prompt);
180 assert.equal(
181 blocks.at(-1),
182 "<system-reminder>\nSubagentStart hook additional context: ## T3 Code devices\n</system-reminder>",
183 );
184 }),
185);
186
187it.effect("tells the shell simulator the launchers T3 returned and agent-device's real CLI", () =>
188 Effect.gen(function* () {
189 const sample = makeSample(deviceCase({}), base);
190 yield* withDevices(sample);
191 const call = { tool: "Bash", input: { command: "ls /tmp" } };
192 const told = (request: ReturnType<typeof shellRequest>) => JSON.stringify(request.messages);
193 assert.notInclude(told(shellRequest(sample, call, "claude-haiku-5-5")), "launchers T3's");
194 const opened = yield* sample.devices!.call("device_open", { hostId: MAC, deviceId: IPHONE });
195 const { command } = json(opened.text).agentDevice;
196 const request = shellRequest(sample, call, "claude-haiku-5-5");
197 assert.include(told(request), `agent-device launchers T3's device_open returned: ${command}`);
198 // agent-device's own syntax: a positional recording path, and `record stop` with no flag.
199 assert.include(request.system, AGENT_DEVICE_SHELL);
200 assert.include(
201 request.system,
202 "agent-device record start [path] [--scope <app|device|system>]",
203 );
204 assert.include(request.system, "| record stop |");
205 assert.include(request.system, "Unknown flag: --stop. Did you mean --scope?");
206 // A case without devices keeps the shell prompt every other case has.
207 const plain = makeSample(deviceCase({ world: {} }), base);
208 assert.notInclude(shellRequest(plain, call, "claude-haiku-5-5").system, "agent-device");
209 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
210);
211
212it("prints agent-device's own help for a lone help command on a returned launcher", () => {
213 const help = (topic: string) =>
214 AGENT_DEVICE_SHELL.split(`$ agent-device help${topic ? ` ${topic}` : ""}\n`)[1]
215 ?.split("\n$ agent-device help")[0]
216 ?.trimEnd();
217 // Commands from the paired runs. The simulator once answered the first with a placeholder.
218 const launcher = "/home/user/.t3/userdata/device/bin/agent-device";
219 const target = `--platform ios --udid ${IPHONE} --config /home/user/.t3/userdata/device/hosts/303617b9730210ef3c86c52d.json --session t3-4c43aa31e607633b85e9ff2f`;
220 const asked = (command: string) => launcherHelp([launcher], command);
221 assert.match(help("") ?? "", /^agent-device <command> \[args\] \[--json\]\n/);
222 assert.equal(asked(`${launcher} help ${target}`), help(""));
223 assert.equal(asked(`${launcher} record --help ${target}`), help("record"));
224 assert.equal(asked(`${launcher} help open ${target}`), help("open"));
225 assert.equal(asked(`${launcher} --help 2>&1`), help(""));
226 assert.equal(asked(`${launcher} help settings --session=t3-1`), help("settings"));
227 // A password reaches fill from stdin, so request_secret's file never enters argv.
228 assert.equal(asked(`${launcher} fill --help ${target}`), help("fill"));
229 assert.include(help("fill"), "fill <x y|@ref|selector> --text-stdin");
230 // Anything else stays with the simulator: other topics, pipes, aliases, other commands.
231 assert.isUndefined(asked(`${launcher} metro --help ${target}`));
232 assert.isUndefined(asked(`${launcher} help record 2>&1 | head -40`));
233 assert.isUndefined(asked(`AD=${launcher}; $AD help record`));
234 assert.isUndefined(asked(`${launcher} snapshot -i ${target}`));
235 assert.isUndefined(asked(`/usr/local/bin/agent-device help`));
236});
237
238it("pins agent-device's help to the version T3 installs", () => {
239 const versions = JSON.parse(
240 NodeFS.readFileSync(
241 new URL("../../apps/server/src/device/deviceToolVersions.json", import.meta.url),
242 "utf8",
243 ),
244 );
245 assert.include(AGENT_DEVICE_SHELL, `agent-device ${versions["agent-device"]} prints this help`);
246});
247
248// agent-device 0.21.31's errors as a real Mac printed them (stderr, exit status 1).
249const NO_SESSION =
250 "Error (SESSION_NOT_FOUND): No active session. Run open first.\nHint: Run open first or pass an explicit device selector.";
251const APP_RECORDING_BEFORE_OPEN =
252 "Error (INVALID_ARGS): record start with app scope and an explicit session requires an active app session; run open <app> first, or use --scope device to record the full screen\nHint: Check command arguments and run --help for usage examples.";
253/** agent-device 0.21.31's snapshot-runtime-binding refusal, with the CLI's default hint. */
254const iosSnapshotRefused = (session: string) =>
255 `Error (SESSION_NOT_FOUND): iOS snapshot requires an active app session on the target device. Run open first (for example: open --session ${session} --platform ios --device "<name>" <app>).\nHint: Run open first or pass an explicit device selector.`;
256const APP_RECORDING_WITHOUT_APP =
257 "Error (UNKNOWN): App-scoped recording requires an active app session; open the app under test before recording.\nHint: Unexpected internal error. Retry with --debug and report the diagnostics log if it persists.";
258
259it.effect("answers the returned launcher as agent-device does before and after open", () =>
260 Effect.gen(function* () {
261 const { devices = [], deviceHosts } = deviceCase({}).setup.world ?? {};
262 const tools = yield* makeDeviceTools({ devices, deviceHosts }, base.environment);
263 const opened = json((yield* tools.call("device_open", { hostId: MAC, deviceId: IPHONE })).text);
264 const { command, targetArgs, session: bound } = opened.agentDevice;
265 // This ref's launcher: a bound one names its session; the shared one takes device_open's flags.
266 const session: string = bound ?? targetArgs[targetArgs.indexOf("--session") + 1];
267 const line = (args: string) =>
268 bound === undefined ? `${command} ${args} ${targetArgs.join(" ")}` : `${command} ${args}`;
269 const history: Array<ShellCall> = [];
270 const answer = (args: string) => tools.sessionError(history, line(args));
271 const ran = (args: string, output: string) =>
272 history.push({ tool: "Bash", input: { command: line(args) }, output });
273
274 const shot = answer("screenshot /tmp/shot.png") ?? "";
275 assert.isTrue(shot.startsWith(`Exit code 1\n${NO_SESSION}\nDiagnostic ID: `));
276 assert.match(
277 shot,
278 new RegExp(
279 `\\nDiagnostics Log: /home/user/\\.agent-device/remote-diagnostics/${session}/requests/[\\da-f]{16}\\.ndjson$`,
280 ),
281 );
282 for (const args of ["press @e3 --settle", 'fill @e5 "hello world"', "scroll down", "home"]) {
283 assert.include(answer(args), NO_SESSION, args);
284 }
285 assert.include(answer("record start /tmp/run.mp4"), APP_RECORDING_BEFORE_OPEN);
286 // An iOS snapshot needs an app open in the session.
287 assert.include(answer("snapshot -i"), iosSnapshotRefused("sim"));
288 // With the device selector both refs pass, these run without a session.
289 for (const args of [
290 "open",
291 "devices",
292 "apps",
293 "settings appearance dark",
294 "alert accept",
295 "install com.acme.app /tmp/Acme.app",
296 "record start --scope device /tmp/run.mp4",
297 ]) {
298 assert.isUndefined(answer(args), args);
299 }
300 // Unknown flags, JSON output and compound commands stay with the simulator.
301 assert.isUndefined(answer("screenshot --bogus /tmp/shot.png"));
302 assert.isUndefined(answer("screenshot --json /tmp/shot.png"));
303 assert.isUndefined(tools.sessionError(history, `${line("screenshot /tmp/a.png")} && echo ok`));
304 assert.deepEqual([...tools.sessions(history)], [[session, undefined]]);
305
306 ran("open com.acme.missing", "Exit code 1\nError (APP_NOT_INSTALLED): App not installed");
307 assert.include(answer("screenshot /tmp/shot.png"), NO_SESSION);
308 ran("open", `Opened: ${session}`);
309 assert.isUndefined(answer("screenshot /tmp/shot.png"));
310 assert.isUndefined(answer("press @e3"));
311 assert.include(answer("record start /tmp/run.mp4"), APP_RECORDING_WITHOUT_APP);
312 assert.isUndefined(answer("record start --scope device /tmp/run.mp4"));
313 assert.include(answer("snapshot -i"), iosSnapshotRefused(session));
314 ran("open com.acme.app", "Opened: com.acme.app");
315 assert.isUndefined(answer("record start /tmp/run.mp4"));
316 assert.isUndefined(answer("snapshot -i"));
317 assert.deepEqual([...tools.sessions(history)], [[session, { app: true }]]);
318 ran("close", "Closed");
319 assert.include(
320 tools.sessionError(history, `${line("screenshot /tmp/a.png")} 2>&1`),
321 NO_SESSION,
322 );
323 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
324);
325
326const shared = (session: string): LauncherTarget => ({
327 command: "/home/user/.t3/userdata/device/bin/agent-device",
328 session,
329 bound: false,
330 deviceId: IPHONE,
331 ios: true,
332 iosSimulator: true,
333});
334const boundTarget: LauncherTarget = {
335 command: "/home/user/.t3/userdata/device/targets/t3-bound/agent-device",
336 session: "t3-bound",
337 bound: true,
338 deviceId: IPHONE,
339 ios: true,
340 iosSimulator: true,
341};
342const bash = (command: string, output = "OK"): ShellCall => ({
343 tool: "Bash",
344 input: { command },
345 output,
346});
347const errorCode = (
348 targets: ReadonlyArray<LauncherTarget>,
349 history: ReadonlyArray<ShellCall>,
350 command: string,
351) => agentDeviceSessionError(targets, agentDeviceSessions(targets, {}, history), command)?.code;
352
353it("names the session on either ref: the bound launcher's own, or the shared one's --session", () => {
354 const flags = `--platform ios --udid ${IPHONE} --config /home/user/.t3/userdata/device/hosts/x.json`;
355 const sharedTargets = [shared("t3-a"), shared("t3-b")];
356 // The shared launcher answers each session its flags name; without one, its own check (not
357 // modelled) refuses before agent-device runs.
358 const opened = [bash(`${sharedTargets[0]!.command} open com.acme.app ${flags} --session t3-a`)];
359 assert.isUndefined(
360 errorCode(
361 sharedTargets,
362 opened,
363 `${sharedTargets[0]!.command} screenshot ${flags} --session t3-a`,
364 ),
365 );
366 assert.equal(
367 errorCode(
368 sharedTargets,
369 opened,
370 `${sharedTargets[0]!.command} screenshot ${flags} --session=t3-b`,
371 ),
372 "SESSION_NOT_FOUND",
373 );
374 assert.isUndefined(errorCode(sharedTargets, [], `${sharedTargets[0]!.command} screenshot`));
375 // The bound launcher needs no flags, and an explicit --session still wins.
376 assert.equal(
377 errorCode([boundTarget], [], `${boundTarget.command} screenshot`),
378 "SESSION_NOT_FOUND",
379 );
380 assert.equal(errorCode([boundTarget], [], `${boundTarget.command} record start`), "INVALID_ARGS");
381 const boundOpen = [bash(`${boundTarget.command} open`)];
382 assert.isUndefined(errorCode([boundTarget], boundOpen, `${boundTarget.command} screenshot`));
383 assert.equal(
384 errorCode([boundTarget], boundOpen, `${boundTarget.command} record start /tmp/r.mp4`),
385 "UNKNOWN",
386 );
387 assert.equal(
388 errorCode([boundTarget], boundOpen, `${boundTarget.command} screenshot --session other`),
389 "SESSION_NOT_FOUND",
390 );
391 // Without a selector, agent-device's record and close refuse a missing named session with
392 // other text; that stays with the simulator.
393 assert.isUndefined(
394 errorCode(sharedTargets, [], `${sharedTargets[0]!.command} record start --session t3-a`),
395 );
396});
397
398it("follows the session through aliases, compound calls, snapshots and whole-screen recordings", () => {
399 const launcher = boundTarget.command;
400 const after = (...history: ReadonlyArray<ShellCall>) =>
401 agentDeviceSessions([boundTarget], {}, history).get("t3-bound");
402 assert.deepEqual(after(bash(`AD=${launcher}; "$AD" open com.acme.app`)), { app: true });
403 // A failed lone open starts nothing; an open in a failed compound call still counts.
404 assert.isUndefined(after(bash(`${launcher} open com.acme.app`, "Exit code 1\nError (X): no")));
405 assert.deepEqual(
406 after(bash(`${launcher} open com.acme.app && ${launcher} press @e9`, "Exit code 1\nboom")),
407 { app: true },
408 );
409 // Dead tails, remote agent-device and help start nothing.
410 assert.isUndefined(after(bash(`false && ${launcher} open`, "Exit code 1")));
411 assert.isUndefined(after(bash("ssh build-mac agent-device open com.acme.app")));
412 assert.isUndefined(after(bash(`${launcher} help open`)));
413 // agent-device publishes a session for a sessionless Android snapshot, without an app. An iOS
414 // snapshot without an app fails and starts nothing.
415 assert.isUndefined(after(bash(`${launcher} snapshot -i`)));
416 const android = { ...boundTarget, ios: false, iosSimulator: false };
417 assert.deepEqual(
418 agentDeviceSessions([android], {}, [bash(`${launcher} snapshot -i`)]).get("t3-bound"),
419 { app: false },
420 );
421 // A whole-screen recording holds a session only until it stops.
422 const recording = bash(`${launcher} record start --scope device /tmp/r.mp4`);
423 assert.deepEqual(after(recording), { app: false, recordingOnly: true });
424 assert.isUndefined(errorCode([boundTarget], [recording], `${launcher} screenshot`));
425 assert.isUndefined(after(recording, bash(`${launcher} record stop`)));
426 assert.equal(
427 errorCode(
428 [boundTarget],
429 [recording, bash(`${launcher} record stop`)],
430 `${launcher} screenshot`,
431 ),
432 "SESSION_NOT_FOUND",
433 );
434 assert.deepEqual(after(bash(`${launcher} open`), bash(`${launcher} close`)), undefined);
435});
436
437it("follows the shared launcher's session through flags held in a variable or an array", () => {
438 // Forms Sonnet used with the shared launcher in a live sample.
439 const target = shared("t3-a");
440 const flags = `--platform ios --udid ${IPHONE} --config /home/user/.t3/userdata/device/hosts/x.json --session t3-a`;
441 const after = (history: ShellCall, targets = [target]) =>
442 agentDeviceSessions(targets, {}, [history]).get("t3-a");
443 const opened = bash(
444 `AD=${target.command}; A="${flags}"; $AD open com.acme.app $A 2>&1 | tail -5; sleep 5; $AD snapshot -i $A 2>&1 | head -60`,
445 "Opened: com.acme.app",
446 );
447 assert.deepEqual(after(opened), { app: true });
448 const array = bash(`A=(${flags}); ${target.command} open "\${A[@]}" 2>&1 | head -60`);
449 assert.deepEqual(after(array), { app: false });
450 // With two sessions behind the shared launcher, unread flags name neither.
451 assert.isUndefined(after(array, [target, shared("t3-b")]));
452 // agent-device still sees no session for a plain command after a failed open in a variable.
453 const failed = bash(
454 `AD=${target.command}; A="${flags}"; $AD open $A`,
455 "Exit code 1\nError (X): no",
456 );
457 assert.equal(
458 errorCode([target], [failed], `${target.command} screenshot ${flags}`),
459 "SESSION_NOT_FOUND",
460 );
461});
462
463it.effect("starts sessions the world says prior turns opened, and rejects unknown devices", () =>
464 Effect.gen(function* () {
465 const testCase = deviceCase({
466 world: { ...world, agentDeviceSessions: { [IPHONE]: { app: "com.acme.app" } } },
467 priorTurns: [
468 { role: "user", text: "Show me Settings on the iPhone on build-mac." },
469 {
470 role: "assistant",
471 toolCalls: [
472 { tool: "device_open", input: { hostId: MAC, deviceId: IPHONE }, result: "default" },
473 ],
474 },
475 ],
476 });
477 const sample = makeSample(testCase, base);
478 yield* withDevices(sample);
479 const tools = sample.devices!;
480 const [launcher = ""] = tools.launchers;
481 assert.deepEqual([...tools.sessions(sample.shell).values()], [{ app: true }]);
482 const call = { tool: "Bash", input: { command: "ls" } };
483 assert.include(
484 JSON.stringify(shellRequest(sample, call, "claude-haiku-5-5").messages),
485 "is open with an app",
486 );
487 assert.isUndefined(tools.sessionError(sample.shell, `${launcher} record start /tmp/r.mp4`));
488 const { devices = [], deviceHosts } = testCase.setup.world ?? {};
489 const unknown = { devices, deviceHosts, agentDeviceSessions: { "not-a-device": {} } };
490 const exit = yield* Effect.exit(makeDeviceTools(unknown, base.environment));
491 assert.isTrue(exit._tag === "Failure");
492 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
493);
494
495it.effect(
496 "answers a shell mock first, then help, then the session error, before the simulator",
497 () =>
498 Effect.gen(function* () {
499 const sample = makeSample(deviceCase({}), base);
500 yield* withDevices(sample);
501 const opened = yield* sample.devices!.call("device_open", { hostId: MAC, deviceId: IPHONE });
502 const { command, targetArgs, session } = json(opened.text).agentDevice;
503 const flags = session === undefined ? ` ${targetArgs.join(" ")}` : "";
504 const told = () =>
505 JSON.stringify(
506 shellRequest(sample, { tool: "Bash", input: {} }, "claude-haiku-5-5").messages,
507 );
508 assert.include(told(), "has not started");
509 assert.include(cannedOutput(sample, `${command} screenshot /tmp/a.png${flags}`), NO_SESSION);
510 assert.match(cannedOutput(sample, `${command} help record`) ?? "", /^Usage:/);
511 assert.isUndefined(cannedOutput(sample, `${command} open com.acme.app${flags}`));
512 const outage = makeSample(
513 deviceCase({
514 shellMocks: [
515 { match: "\\bagent-device\\b", output: "Exit code 1\nError (COMMAND_FAILED): down" },
516 ],
517 }),
518 base,
519 );
520 yield* withDevices(outage);
521 yield* outage.devices!.call("device_open", { hostId: MAC, deviceId: IPHONE });
522 assert.include(cannedOutput(outage, `${command} screenshot /tmp/a.png${flags}`), "down");
523 sample.shell.push({
524 tool: "Bash",
525 input: { command: `${command} open com.acme.app${flags}` },
526 output: "Opened: com.acme.app",
527 });
528 assert.isUndefined(cannedOutput(sample, `${command} screenshot /tmp/a.png${flags}`));
529 assert.include(told(), "is open with an app");
530 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
531);
532
533it("keeps a redirection's descriptor out of the app an open names, on either launcher", () => {
534 // Sol R10 B1: `open 2>&1` once read as `open 2`, an app the real shell never passes.
535 const flags = `--platform ios --udid ${IPHONE} --config /home/user/.t3/userdata/device/hosts/x.json --session t3-a`;
536 for (const [target, args] of [
537 [shared("t3-a"), ` ${flags}`],
538 [boundTarget, ""],
539 ] as const) {
540 for (const redirect of ["2>&1", "2>&1 | tail -5", "2>/dev/null"]) {
541 const opened = [
542 bash(`${target.command} open${args} ${redirect}`, `Opened: ${target.session}`),
543 ];
544 assert.deepEqual(
545 agentDeviceSessions([target], {}, opened).get(target.session),
546 { app: false },
547 redirect,
548 );
549 assert.equal(
550 errorCode([target], opened, `${target.command} record start /tmp/run.mp4${args}`),
551 "UNKNOWN",
552 redirect,
553 );
554 }
555 }
556});
557
558it("follows opens the parser cannot prove ran, in calls that did not fail", () => {
559 // Opus R10 B1(b): shapes from run 5, where `&&` before `;` leaves an open uncertain.
560 const launcher = boundTarget.command;
561 const after = (command: string, output = "Opened: com.acme.app") =>
562 agentDeviceSessions([boundTarget], {}, [bash(command, output)]).get("t3-bound");
563 assert.deepEqual(
564 after(`mkdir -p /tmp/p && ${launcher} open com.acme.app 2>&1 | tail -3; echo done`),
565 { app: true },
566 );
567 assert.deepEqual(after(`cd /tmp && ${launcher} open com.acme.app; ${launcher} snapshot -i`), {
568 app: true,
569 });
570 // The launcher alias such a call sets, too, here and in later calls.
571 assert.deepEqual(after(`mkdir -p /tmp/p && AD=${launcher}; $AD open com.acme.app`), {
572 app: true,
573 });
574 assert.deepEqual(after(`mkdir -p /tmp/p && AD="${launcher}"; "\${AD}" open com.acme.app`), {
575 app: true,
576 });
577 assert.deepEqual(
578 agentDeviceSessions([boundTarget], {}, [
579 bash(`cd /tmp && AD=${launcher}; echo ready`),
580 bash("$AD open com.acme.app"),
581 ]).get("t3-bound"),
582 { app: true },
583 );
584 // A failed call credits only what certainly ran.
585 assert.isUndefined(
586 after(`mkdir -p /tmp/p && ${launcher} open com.acme.app; echo done`, "Exit code 1\nno"),
587 );
588 assert.isUndefined(
589 after(`mkdir -p /tmp/p && AD=${launcher}; $AD open com.acme.app`, "Exit code 1\nno"),
590 );
591});
592
593const SPARE_MAC = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
594const SPARE_PHONE = "DDDDDDDD-DDDD-4DDD-8DDD-DDDDDDDDDDDD";
595const twoMacs = {
596 devices: [
597 ...world.devices,
598 { ...world.devices[0]!, hostId: SPARE_MAC, id: SPARE_PHONE, name: "Acme Spare iPhone" },
599 ],
600 deviceHosts: { ...world.deviceHosts, [SPARE_MAC]: "spare-mac" },
601};
602const deviceTools = (extra: object) =>
603 makeDeviceTools(
604 { ...twoMacs, ...extra } as Parameters<typeof makeDeviceTools>[0],
605 base.environment,
606 );
607
608it.effect("lists a host whose discovery fails as T3 does: failed, with none of its devices", () =>
609 Effect.gen(function* () {
610 // F1's saved device_list: the hub answered, but its device list timed out.
611 const failure = new DeviceOperationError({
612 operation: "list",
613 reason: "request_failed",
614 cause: null,
615 });
616 const tools = yield* deviceTools({ deviceDiscoveryFailures: { [MAC]: failure } });
617 const listed = json((yield* tools.call("device_list", {})).text);
618 assert.deepEqual(listed.hostStatuses[MAC], {
619 status: "failed",
620 detail:
621 "Device list failed: Could not communicate with device support. Try refreshing devices.",
622 });
623 assert.equal(listed.hostStatuses[SPARE_MAC].status, "ready");
624 assert.deepEqual(
625 listed.devices.map((device: { id: string }) => device.id),
626 [SPARE_PHONE],
627 );
628 const opened = yield* tools.call("device_open", { hostId: MAC, deviceId: IPHONE });
629 assert.isTrue(opened.isError);
630 assert.include(opened.text, IPHONE);
631 assert.isFalse(
632 (yield* tools.call("device_open", { hostId: SPARE_MAC, deviceId: SPARE_PHONE })).isError,
633 );
634 // A failed host with no devices of its own still needs its label.
635 const unlabeled = yield* Effect.exit(
636 deviceTools({ deviceDiscoveryFailures: { "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee": failure } }),
637 );
638 assert.isTrue(unlabeled._tag === "Failure");
639 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
640);
641
642it.effect("denies every device tool with T3's own error when agent device access is off", () =>
643 Effect.gen(function* () {
644 const tools = yield* deviceTools({ deviceAgentAccess: false });
645 for (const [name, input] of [
646 ["device_list", {}],
647 ["device_open", { hostId: MAC, deviceId: IPHONE }],
648 ["device_screenshot", {}],
649 ["device_close", {}],
650 ] as const) {
651 const result = yield* tools.call(name, input);
652 assert.isTrue(result.isError, name);
653 // Refs before typed screenshot errors print their generic screenshot failure instead, and
654 // later refs may add how to turn access on.
655 const denied = result.text.startsWith(
656 "Agent device access is turned off for this environment.",
657 );
658 assert.isTrue(
659 denied || (name === "device_screenshot" && result.text === "Device screenshot failed."),
660 `${name}: ${result.text}`,
661 );
662 }
663 // T3 drops its device text for a thread without the capability, on refs that have it.
664 assert.notInclude(t3SystemAppend(model, false), "## T3 Code devices");
665 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
666);
667
668it.effect("reports an unreadable capture as T3 does: a successful 0×0 screenshot", () =>
669 Effect.gen(function* () {
670 const tools = yield* deviceTools({ deviceInvalidCapture: true });
671 yield* tools.call("device_open", { hostId: MAC, deviceId: IPHONE });
672 const shot = yield* tools.call("device_screenshot", {});
673 assert.isFalse(shot.isError);
674 assert.deepEqual(json(shot.text).screenshot, { mimeType: "image/png", width: 0, height: 0 });
675 const sample = makeSample(
676 deviceCase({ world: { ...world, deviceInvalidCapture: true } }),
677 base,
678 );
679 yield* withDevices(sample);
680 const told = JSON.stringify(shellRequest(sample, { tool: "Bash", input: {} }, model).messages);
681 assert.include(told, "agent-device's own screenshot finds the PNG and is unaffected");
682 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
683);
684
685it.effect("refuses an open on a device another thread's agent-device session holds", () =>
686 Effect.gen(function* () {
687 const tools = yield* deviceTools({ deviceOwners: { [IPHONE]: "acme-design-review" } });
688 // T3 names the owner's session as it names every thread's.
689 const owner = "t3-a062c547ef0b6bf8ee839b67";
690 assert.deepEqual(tools.owners, [
691 { deviceId: IPHONE, threadId: "acme-design-review", session: owner },
692 ]);
693 // T3 has no cross-thread gate: device_open succeeds, and device_list shows only this thread.
694 const opened = json((yield* tools.call("device_open", { hostId: MAC, deviceId: IPHONE })).text);
695 assert.deepEqual(json((yield* tools.call("device_list", {})).text).open, [
696 { hostId: MAC, deviceId: IPHONE },
697 ]);
698 const { command, targetArgs, session: bound } = opened.agentDevice;
699 const own: string = bound ?? targetArgs[targetArgs.indexOf("--session") + 1];
700 const target = (session = own) =>
701 bound === undefined
702 ? targetArgs.map((arg: string) => (arg === own ? session : arg)).join(" ")
703 : session === own
704 ? ""
705 : `--session ${session}`;
706 const line = (args: string, session?: string) => `${command} ${args} ${target(session)}`.trim();
707 const history: Array<ShellCall> = [];
708 const answer = (args: string, session?: string) =>
709 tools.sessionError(history, line(args, session)) ?? "";
710 const inUse = `Error (DEVICE_IN_USE): Device is already in use by session "${owner}".\nHint: Run agent-device session list to inspect active sessions. To reuse this device, rerun the command with --session ${owner}. To open a new session on this device, first run agent-device close --session ${owner}.`;
711 assert.include(
712 answer("open com.acme.app"),
713 `${inUse} To wait for the device to free up, run the same open with --wait <ms>.\nDiagnostic ID: `,
714 );
715 assert.include(
716 answer("open --wait 5000"),
717 `Error (DEVICE_IN_USE): Device is already in use by session "${owner}".\nHint: Waited 5000ms for this device and it stayed busy. Run agent-device session list`,
718 );
719 assert.notInclude(answer("open --wait 5000"), "--wait <ms>");
720 // The hint's reuse works, in the other thread's session.
721 assert.equal(answer("screenshot /tmp/a.png", owner), "");
722 assert.equal(answer("open com.acme.app", owner), "");
723 // A failed open starts nothing, in a compound call too.
724 history.push({
725 tool: "Bash",
726 input: { command: `${line("open com.acme.app")} && ${line("screenshot /tmp/a.png")}` },
727 output: "Exit code 1\nError (DEVICE_IN_USE): Device is already in use",
728 });
729 assert.deepEqual(
730 [...tools.sessions(history)],
731 [
732 [own, undefined],
733 [owner, { app: true }],
734 ],
735 );
736 const sample = makeSample(
737 deviceCase({ world: { ...twoMacs, deviceOwners: { [IPHONE]: "acme-design-review" } } }),
738 base,
739 );
740 yield* withDevices(sample);
741 const told = JSON.stringify(shellRequest(sample, { tool: "Bash", input: {} }, model).messages);
742 assert.include(
743 told,
744 `${owner} (Acme iPhone, ${IPHONE}, on build-mac) belongs to another T3 thread (acme-design-review)`,
745 );
746 // Closing the owner's session frees the device.
747 history.push({ tool: "Bash", input: { command: line("close", owner) }, output: "Closed" });
748 assert.equal(answer("open com.acme.app"), "");
749 assert.include(describeSessions(tools.sessions(history), tools.owners), "was closed");
750 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
751);
752
753it.effect("leaves a free device free when another thread holds a different one", () =>
754 Effect.gen(function* () {
755 const tools = yield* deviceTools({ deviceOwners: { [IPHONE]: "acme-design-review" } });
756 const opened = json(
757 (yield* tools.call("device_open", { hostId: SPARE_MAC, deviceId: SPARE_PHONE })).text,
758 );
759 const { command, targetArgs, session } = opened.agentDevice;
760 const flags = session === undefined ? ` ${targetArgs.join(" ")}` : "";
761 assert.isUndefined(tools.sessionError([], `${command} open com.acme.app${flags}`));
762 assert.include(
763 tools.sessionError([], `${command} screenshot /tmp/a.png${flags}`) ?? "",
764 "SESSION_NOT_FOUND",
765 );
766 // The local host always exists and holds none of the world's devices.
767 assert.isFalse(
768 (yield* tools.call("device_list", {})).text.includes(`"hostId":"${LOCAL_DEVICE_HOST_ID}"`),
769 );
770 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
771);
772
773type Opened = { command: string; targetArgs: Array<string>; session?: string };
774/** This ref's command line for a launcher: a bound one needs no flags; the shared one takes them. */
775const commandLine = (opened: Opened, args: string, session?: string) => {
776 const own = opened.session ?? opened.targetArgs[opened.targetArgs.indexOf("--session") + 1];
777 if (opened.session !== undefined)
778 return `${opened.command} ${args}${session === undefined ? "" : ` --session ${session}`}`;
779 const flags = opened.targetArgs.map((arg) => (arg === own && session ? session : arg));
780 return `${opened.command} ${args} ${flags.join(" ")}`;
781};
782
783it.effect(
784 "tells the simulator each launcher's device, and routes DEVICE_IN_USE to the held one",
785 () =>
786 Effect.gen(function* () {
787 // Opus R11-B3(a): in run 6 the simulator gave the free spare's plain open the owner's
788 // DEVICE_IN_USE twice. It could not tell which device a flagless bound launcher drives.
789 const owner = "t3-a062c547ef0b6bf8ee839b67";
790 const sample = makeSample(
791 deviceCase({ world: { ...twoMacs, deviceOwners: { [IPHONE]: "acme-design-review" } } }),
792 base,
793 );
794 yield* withDevices(sample);
795 const tools = sample.devices!;
796 const open = (hostId: string, deviceId: string) =>
797 Effect.map(
798 tools.call("device_open", { hostId, deviceId }),
799 (result): Opened => json(result.text).agentDevice,
800 );
801 const held = yield* open(MAC, IPHONE);
802 const spare = yield* open(SPARE_MAC, SPARE_PHONE);
803 const session = (opened: Opened) =>
804 opened.session ?? opened.targetArgs[opened.targetArgs.indexOf("--session") + 1];
805 const told = (command: string) =>
806 String(
807 shellRequest(sample, { tool: "Bash", input: { command } }, model).messages[0]?.content[0]
808 ?.text,
809 );
810 // Run 6's history: the held device's plain open, refused without the simulator.
811 const heldOpen = commandLine(held, "open com.acme.app --metro-port 8095");
812 const refused = cannedOutput(sample, heldOpen) ?? "";
813 assert.include(
814 refused,
815 `Error (DEVICE_IN_USE): Device is already in use by session "${owner}".`,
816 );
817 sample.shell.push({ tool: "Bash", input: { command: heldOpen }, output: refused });
818
819 // The spare's plain open goes to the simulator, which now knows it drives the free spare.
820 const spareOpen = commandLine(spare, "open com.acme.app --metro-port 8095");
821 assert.isUndefined(cannedOutput(sample, spareOpen));
822 const free = told(spareOpen);
823 assert.include(
824 free,
825 `agent-device launchers T3's device_open returned: ${
826 held.session === undefined
827 ? `${held.command} (shared: each command's --udid or --serial and --session name its device and session)`
828 : `${held.command} (drives Acme iPhone, ${IPHONE}, on build-mac, in session ${held.session}); ${spare.command} (drives Acme Spare iPhone, ${SPARE_PHONE}, on spare-mac, in session ${spare.session})`
829 }`,
830 );
831 assert.include(
832 free,
833 `${session(held)} (Acme iPhone, ${IPHONE}, on build-mac) has not started; ${session(spare)} (Acme Spare iPhone, ${SPARE_PHONE}, on spare-mac) has not started; ${owner} (Acme iPhone, ${IPHONE}, on build-mac) belongs to another T3 thread (acme-design-review) and holds its device with an app open.`,
834 );
835 assert.include(
836 free,
837 `In the new call, \`${spareOpen}\` drives Acme Spare iPhone, ${SPARE_PHONE}, on spare-mac, which no other session holds: it prints no DEVICE_IN_USE.`,
838 );
839 assert.notInclude(free, "Error (DEVICE_IN_USE)");
840
841 // A compound open of the held device gets agent-device's exact refusal, for that open only.
842 const piped = commandLine(held, "open com.acme.app");
843 const refusal = told(`${piped} 2>&1 | tail -5`);
844 assert.include(
845 refusal,
846 `In the new call, \`${piped}\` drives Acme iPhone, ${IPHONE}, on build-mac, which session ${owner} holds: it fails with exit code 1, printing "Error (DEVICE_IN_USE): Device is already in use by session "${owner}"." and "Hint: Run agent-device session list to inspect active sessions. To reuse this device,`,
847 );
848 assert.notInclude(refusal, "no other session holds");
849 // One call may try the held device, then fall back to the spare through an alias.
850 const alias =
851 spare.session === undefined
852 ? `A=${spare.command}; "$A" open com.acme.app ${spare.targetArgs.join(" ")}`
853 : `A=${spare.command}; "$A" open com.acme.app`;
854 const both = told(`${piped} || echo busy; ${alias}`);
855 assert.include(both, `which session ${owner} holds: it fails with exit code 1`);
856 assert.include(
857 both,
858 `on spare-mac, which no other session holds: it prints no DEVICE_IN_USE`,
859 );
860 // An open in the other thread's own session is not refused, and gets no route.
861 assert.notInclude(told(commandLine(held, "open com.acme.app", owner)), "In the new call");
862 // An open after the same call closes the other thread's session finds the device free.
863 const reopen = told(`${commandLine(held, "close", owner)}; ${piped}`);
864 assert.include(
865 reopen,
866 `\`${piped}\` drives Acme iPhone, ${IPHONE}, on build-mac, which no other session holds: it prints no DEVICE_IN_USE`,
867 );
868 assert.notInclude(reopen, `which session ${owner} holds`);
869 // Neither do commands other than open.
870 assert.notInclude(
871 told(`${commandLine(spare, "screenshot /tmp/a.png")} && echo ok`),
872 "In the new call",
873 );
874
875 // A world where no other thread holds a device gets no route either.
876 const alone = makeSample(deviceCase({}), base);
877 yield* withDevices(alone);
878 const own: Opened = json(
879 (yield* alone.devices!.call("device_open", { hostId: MAC, deviceId: IPHONE })).text,
880 ).agentDevice;
881 const request = shellRequest(
882 alone,
883 { tool: "Bash", input: { command: `${commandLine(own, "open com.acme.app")} | tail -3` } },
884 model,
885 );
886 assert.notInclude(JSON.stringify(request.messages), "In the new call");
887 assert.include(
888 JSON.stringify(request.messages),
889 `${session(own)} (Acme iPhone, ${IPHONE}, on build-mac) has not started`,
890 );
891 }).pipe(Effect.scoped, Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))),
892);
893
894it("tells the simulator 0.21.31's --foreground and session list behaviour", () => {
895 // Opus R11-B3(b): 0.21.31 (session2.js ys) refuses only an open --foreground with no app or URL.
896 // Run 6's simulator refused `open <app> --foreground` in 9 base samples.
897 assert.include(
898 AGENT_DEVICE_SHELL,
899 "open --foreground with no app or URL fails with exit code 1 when the command passes --udid or --device",
900 );
901 assert.include(AGENT_DEVICE_SHELL, "open <app> --foreground works.");
902 // Run 6's simulator answered the DEVICE_IN_USE hint's `session list` with "Unknown command".
903 assert.include(
904 AGENT_DEVICE_SHELL,
905 "session list prints, as JSON, the started sessions on the launcher's host, other threads' too",
906 );
907 // Neither is refused without the simulator.
908 for (const args of ["open com.acme.app --foreground", "session list"]) {
909 assert.isUndefined(
910 agentDeviceSessionError(
911 [boundTarget],
912 new Map([["t3-bound", undefined]]),
913 `${boundTarget.command} ${args}`,
914 ),
915 args,
916 );
917 }
918});