diff --git a/apps/server/src/device/AgentDeviceShim.ts b/apps/server/src/device/AgentDeviceShim.ts index 4a84b895ee3e1..44861501e1e4b 100644 --- a/apps/server/src/device/AgentDeviceShim.ts +++ b/apps/server/src/device/AgentDeviceShim.ts @@ -6,49 +6,303 @@ import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; const SHIM_DIR = "device/bin"; +const TARGETS_DIR = "device/targets"; +const STALE_RUNTIME = + "This agent-device command is out of date because T3 Code moved or updated its runtime. Call device_open again for a fresh one."; -export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: { - readonly entryPath: string; - readonly stateDir: string; -}) { - const { entryPath } = input; - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const platform = yield* HostProcess.Platform; - const node = yield* resolveNodeExecutable("Device automation"); - const shimDir = path.join(input.stateDir, SHIM_DIR); - yield* fs.makeDirectory(shimDir, { recursive: true }); - const launcherPath = path.join(shimDir, "agent-device-launcher.mjs"); - yield* fs.writeFileString( - launcherPath, - `import { spawn } from "node:child_process"; +const quoteShell = (value: string) => "'" + value.replaceAll("'", "'\"'\"'") + "'"; + +/** agent-device 0.21.31's flags that take a value, so the launcher can find the command past them. */ +const VALUE_FLAGS: ReadonlyArray = [ + "--activity", + "--all", + "--android-device-allowlist", + "--app", + "--appium-version", + "--artifact", + "--artifacts-dir", + "--aws-app-arn", + "--aws-device-arn", + "--aws-interaction-mode", + "--aws-project-arn", + "--aws-region", + "-b", + "--baseline", + "--bearer-token", + "--bundle-url", + "--button", + "--config", + "--count", + "--crop-on", + "--custom-network", + "-d", + "--daemon-auth-token", + "--daemon-base-url", + "--daemon-server-mode", + "--daemon-transport", + "--delay-ms", + "--depth", + "--device", + "--device-orientation", + "--dsym", + "--duration-ms", + "--fps", + "--from", + "--geo-location", + "--github-actions-artifact", + "--header", + "--hold-ms", + "--host", + "--in-app", + "--include", + "--interval-ms", + "--ios-simulator-device-set", + "--ios-xctest-derived-data-path", + "--ios-xctest-env-dir", + "--ios-xctestrun-file", + "--jitter-px", + "--keyframes", + "--kind", + "--language", + "--launch-args", + "--launch-console", + "--launch-url", + "--lease-backend", + "--lease-id", + "--level", + "--listen-host", + "--locale", + "--max-passes", + "--max-steps", + "--metro-host", + "--metro-kind", + "--metro-port", + "--network-profile", + "--on-error", + "--os-version", + "--out", + "--pattern", + "--pause-ms", + "--pixel-density", + "--pixels", + "--plan-digest", + "--platform", + "--pointer-count", + "--port", + "--probe-timeout-ms", + "--project-root", + "--provider", + "--provider-app", + "--provider-appium-version", + "--provider-build", + "--provider-custom-network", + "--provider-device-orientation", + "--provider-device-type", + "--provider-geo-location", + "--provider-language", + "--provider-locale", + "--provider-network-profile", + "--provider-os-version", + "--provider-project", + "--provider-session", + "--provider-session-name", + "--provider-timezone", + "--proxy-base-url", + "--public-base-url", + "--quality", + "--record-as", + "--remote-config", + "--reporter", + "--report-junit", + "--retention-ms", + "--retries", + "--run-id", + "--runtime-file", + "-s", + "--scale", + "--scope", + "--search-path", + "--serial", + "--session", + "--session-isolation", + "--session-lock", + "--settle-quiet", + "--shard-all", + "--shard-split", + "--startup-timeout-ms", + "--state-dir", + "--status-host", + "--steps", + "--steps-file", + "--surface", + "--system", + "--target", + "--target-app", + "--template", + "--tenant", + "--threshold", + "--timeout", + "--timezone", + "--udid", + "--until", + "--wait", +]; + +// Help gets no bound flags (agent-device reads --config even for help); only args before `--` are flags. +// agent-device 0.21.31 refuses some bound flags per command; connect/disconnect/auth/daemon would +// replace T3's connection; device status reads this machine's claims. It reports a sandboxed shell and a +// dropped tunnel alike, so check first, at --daemon-base-url if given, as agent-device prefers it. +const launcherSource = (node: string, entryPath: string, boundArgs: ReadonlyArray) => + `import { spawn } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { connect } from "node:net"; +const entry = ${JSON.stringify(entryPath)}; +const bound = ${JSON.stringify(boundArgs)}; +const refused = new Map([["cdp", ["--platform", "--udid", "--config", "--session"]], ["connection", ["--platform", "--udid"]]]); +const managed = ["connect", "disconnect", "auth", "daemon"]; const args = process.argv.slice(2); -const informational = args.length === 1 && ["help", "--help", "-h", "--version", "version"].includes(args[0]); -const hasValue = flag => { const index = args.indexOf(flag); return index >= 0 && !!args[index + 1] && !args[index + 1].startsWith("--"); }; -if (!informational && !(hasValue("--config") && hasValue("--session"))) { - console.error("Call device_open first and include its --config and --session flags."); +const end = args.indexOf("--"); +const options = end < 0 ? args : args.slice(0, end); +const valued = new Set(${JSON.stringify(VALUE_FLAGS)}); +let name = ""; +for (let index = 0; index < args.length; index++) { + const arg = args[index]; + if (arg === "--") { name = args[index + 1] ?? ""; break; } + if (!arg.startsWith("-")) { name = arg; break; } + if (!arg.includes("=") && valued.has(arg)) index++; +} +const informational = args.length === 0 || ["help", "--help", "-h", "--version", "version"].includes(args[0]) || options.includes("--help") || options.includes("-h"); +if (!informational && managed.includes(name)) { + console.error("T3 Code manages this command's connection to the device, so \`" + name + "\` is not available through it."); process.exit(1); } +if (!informational && name === "device") { + console.error("\`device status\` reads this machine's device claims, not those on the device's host, so it is not available through this command. Quote the DEVICE_IN_USE error instead."); + process.exit(1); +} +const skip = refused.get(name) ?? []; +if (!informational) for (let index = 0; index + 1 < bound.length; index += 2) if (!skip.includes(bound[index]) && !options.some(arg => arg === bound[index] || arg.startsWith(bound[index] + "="))) options.push(bound[index], bound[index + 1]); +const command = end < 0 ? options : [...options, ...args.slice(end)]; +const hasValue = flag => { const index = options.indexOf(flag); return options.some(arg => arg.startsWith(flag + "=") && arg.length > flag.length + 1) || (index >= 0 && !!options[index + 1] && !options[index + 1].startsWith("--")); }; +if (!informational && !skip.includes("--session") && !(hasValue("--config") && hasValue("--session"))) { + console.error("Call device_open first and run the agent-device command it returns."); + process.exit(1); +} +if (!existsSync(entry)) { + console.error(${JSON.stringify(STALE_RUNTIME)}); + process.exit(1); +} +const valueOf = flag => { const inline = options.find(arg => arg.startsWith(flag + "=")); if (inline) return inline.slice(flag.length + 1); const index = options.indexOf(flag); return index >= 0 ? options[index + 1] : undefined; }; +const endpoint = () => { try { const url = new URL(valueOf("--daemon-base-url") ?? JSON.parse(readFileSync(valueOf("--config"), "utf8")).daemonBaseUrl); return ["127.0.0.1", "localhost", "[::1]"].includes(url.hostname) && url.port ? url : undefined; } catch { return undefined; } }; +const local = informational || refused.has(name) ? undefined : endpoint(); +if (local) { + const failure = await new Promise(resolve => { + const socket = connect({ host: local.hostname.replace(/^\\[|\\]$/g, ""), port: Number(local.port) }); + socket.setTimeout(3000, () => { socket.destroy(); resolve("ETIMEDOUT"); }); + socket.once("connect", () => { socket.destroy(); resolve(undefined); }); + socket.once("error", error => resolve(error.code ?? "ERROR")); + }); + if (failure === "EPERM" || failure === "EACCES") { + console.error("This shell cannot reach T3 Code's device connection at " + local.host + " (connect " + failure + "): a network sandbox is blocking localhost. Run this command again with network access, outside the sandbox."); + process.exit(1); + } + if (failure) { + console.error("T3 Code's device connection at " + local.host + " is not reachable from this shell (connect " + failure + "). Call device_open again to reconnect; if that succeeds and this repeats, this shell runs in a network sandbox, so run the command with network access, outside the sandbox."); + process.exit(1); + } +} const env = { ...process.env, ELECTRON_RUN_AS_NODE: "1" }; delete env.AGENT_DEVICE_DAEMON_BASE_URL; delete env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; delete env.AGENT_DEVICE_CONFIG; -const child = spawn(${JSON.stringify(node)}, [${JSON.stringify(entryPath)}, ...args], { stdio: "inherit", env }); +const child = spawn(${JSON.stringify(node)}, [entry, ...command], { stdio: "inherit", env }); child.on("error", error => { console.error(error.message); process.exitCode = 1; }); child.on("exit", code => { process.exitCode = code ?? 1; }); -`, - ); +`; + +/** Writes a launcher into `dir` and returns the executable agents run. */ +const writeLauncher = Effect.fn("AgentDeviceShim.writeLauncher")(function* (input: { + readonly dir: string; + readonly entryPath: string; + readonly boundArgs: ReadonlyArray; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcess.Platform; + const node = yield* resolveNodeExecutable("Device automation"); + yield* fs.makeDirectory(input.dir, { recursive: true }); + const launcherPath = path.join(input.dir, "agent-device-launcher.mjs"); + yield* fs.writeFileString(launcherPath, launcherSource(node, input.entryPath, input.boundArgs)); if (platform === "win32") { - const script = `@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${node}" "${launcherPath}" %*\r\n`; - yield* fs.writeFileString(path.join(shimDir, "agent-device.cmd"), script); - } else { - const command = [node, launcherPath] - .map((value) => "'" + value.replaceAll("'", "'\"'\"'") + "'") - .join(" "); - const script = `#!/bin/sh\nexport ELECTRON_RUN_AS_NODE=1\nexec ${command} "$@"\n`; - const shimPath = path.join(shimDir, "agent-device"); - yield* fs.writeFileString(shimPath, script); - yield* fs.chmod(shimPath, 0o755); + const script = `@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\nif not exist "${node}" (echo ${STALE_RUNTIME} 1>&2 & exit /b 1)\r\nset "ELECTRON_RUN_AS_NODE=1"\r\n"${node}" "${launcherPath}" %*\r\n`; + const scriptPath = path.join(input.dir, "agent-device.cmd"); + yield* fs.writeFileString(scriptPath, script); + return scriptPath; } + const script = `#!/bin/sh\n[ -x ${quoteShell(node)} ] || { echo ${quoteShell(STALE_RUNTIME)} >&2; exit 1; }\nexport ELECTRON_RUN_AS_NODE=1\nexec ${quoteShell(node)} ${quoteShell(launcherPath)} "$@"\n`; + const scriptPath = path.join(input.dir, "agent-device"); + yield* fs.writeFileString(scriptPath, script); + yield* fs.chmod(scriptPath, 0o755); + return scriptPath; +}); + +/** The unbound launcher: every call needs the device's target flags. */ +export const ensureAgentDeviceShim = Effect.fn("AgentDeviceShim.ensure")(function* (input: { + readonly entryPath: string; + readonly stateDir: string; +}) { + const path = yield* Path.Path; + const shimDir = path.join(input.stateDir, SHIM_DIR); + yield* writeLauncher({ dir: shimDir, entryPath: input.entryPath, boundArgs: [] }); return shimDir; }); + +/** + * A launcher for one agent-device session with its target flags built in, so + * agents pass none, and a shell that does not word-split a variable cannot mangle them. + */ +export const bindAgentDeviceLauncher = Effect.fn("AgentDeviceShim.bind")(function* (input: { + readonly entryPath: string; + readonly stateDir: string; + readonly session: string; + readonly targetArgs: ReadonlyArray; +}) { + const path = yield* Path.Path; + return yield* writeLauncher({ + dir: path.join(input.stateDir, TARGETS_DIR, input.session), + entryPath: input.entryPath, + boundArgs: input.targetArgs, + }); +}); + +/** Removes the bound launchers of agent-device sessions not in `live`. */ +export const pruneAgentDeviceLaunchers = Effect.fn("AgentDeviceShim.prune")(function* (input: { + readonly stateDir: string; + readonly live: ReadonlySet; +}) { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = path.join(input.stateDir, TARGETS_DIR); + const sessions = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + sessions.filter((session) => !input.live.has(session)), + (session) => fs.remove(path.join(dir, session), { recursive: true, force: true }), + { discard: true }, + ).pipe(Effect.ignore); +}); + +export type AgentDeviceCloseOutcome = "closed" | "no-session" | "failed" | "timed-out"; + +/** What a launcher's `close` did; only a failure or timeout can leave the device claimed. */ +export const agentDeviceCloseOutcome = (result: { + readonly code: number | null; + readonly stderr: string; + readonly timedOut: boolean; +}): AgentDeviceCloseOutcome => + result.timedOut + ? "timed-out" + : result.code === 0 + ? "closed" + : result.stderr.includes("SESSION_NOT_FOUND") + ? "no-session" + : "failed"; diff --git a/apps/server/src/device/AgentDeviceTarget.test.ts b/apps/server/src/device/AgentDeviceTarget.test.ts index 24a71c34c4e48..c1c744ae1d50e 100644 --- a/apps/server/src/device/AgentDeviceTarget.test.ts +++ b/apps/server/src/device/AgentDeviceTarget.test.ts @@ -2,12 +2,18 @@ import * as HostProcess from "@t3tools/shared/HostProcess"; import { describe, expect, it } from "@effect/vitest"; import * as NodeChildProcess from "node:child_process"; +import * as NodeNet from "node:net"; import * as NodeUtil from "node:util"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import { ensureAgentDeviceShim } from "./AgentDeviceShim.ts"; +import { + agentDeviceCloseOutcome, + bindAgentDeviceLauncher, + ensureAgentDeviceShim, + pruneAgentDeviceLaunchers, +} from "./AgentDeviceShim.ts"; import { agentDeviceConfigPath, agentDeviceSession, @@ -16,6 +22,18 @@ import { const exec = NodeUtil.promisify(NodeChildProcess.execFile); +/** A loopback port that accepts connections until the scope closes, like T3's device connection. */ +const listeningPort = Effect.acquireRelease( + Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer((socket) => socket.destroy()); + server.listen(0, "127.0.0.1", () => resolve(server)); + }), + ), + (server) => Effect.promise(() => new Promise((resolve) => server.close(() => resolve()))), +).pipe(Effect.map((server) => (server.address() as NodeNet.AddressInfo).port)); + describe("host-bound agent commands", () => { it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( "runs both desktop hops as Node with a stripped environment, preserving arguments and exit code", @@ -67,6 +85,318 @@ exec '${process.execPath.replaceAll("'", "'\"'\"'")}' "$@" }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + it.effect("binds one device's flags into its launcher and leaves help alone", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-bound-" }); + const entryPath = path.join(dir, "cli.mjs"); + yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));"); + const target = [ + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + "--session", + "t3-a", + ]; + const command = yield* bindAgentDeviceLauncher({ + entryPath, + stateDir: dir, + session: "t3-a", + targetArgs: target, + }); + const shared = yield* ensureAgentDeviceShim({ entryPath, stateDir: dir }); + const launch = (launcher: string, args: ReadonlyArray) => + exec(process.execPath, [path.join(launcher, "agent-device-launcher.mjs"), ...args]).then( + (result) => JSON.parse(result.stdout), + ); + const bound = path.dirname(command); + expect(yield* Effect.promise(() => launch(bound, ["snapshot", "-i"]))).toEqual([ + "snapshot", + "-i", + ...target, + ]); + // A flag the agent passes itself wins over the bound one. + expect(yield* Effect.promise(() => launch(bound, ["open", "--session", "mine"]))).toEqual([ + "open", + "--session", + "mine", + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + ]); + expect(yield* Effect.promise(() => launch(bound, ["snapshot", "--session=mine"]))).toEqual([ + "snapshot", + "--session=mine", + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + ]); + // After `--` everything is text: no help, no overrides, and bound flags go before it. + for (const text of [["--help"], ["literal text", "--session", "mine"]]) { + expect(yield* Effect.promise(() => launch(bound, ["fill", "@e5", "--", ...text]))).toEqual([ + "fill", + "@e5", + ...target, + "--", + ...text, + ]); + } + expect( + yield* Effect.promise(() => + launch(shared, ["snapshot", "--config=/h.json", "--session=t3-a"]), + ), + ).toEqual(["snapshot", "--config=/h.json", "--session=t3-a"]); + yield* Effect.promise(() => + expect( + launch(shared, ["fill", "@e5", "--", "--config", "/h.json", "--session", "t3-a"]), + ).rejects.toThrow("Call device_open first"), + ); + for (const args of [["help", "gesture"], ["open", "--help"], ["record", "-h"], []]) { + expect(yield* Effect.promise(() => launch(bound, args))).toEqual(args); + expect(yield* Effect.promise(() => launch(shared, args))).toEqual(args); + } + yield* fs.remove(entryPath); + yield* Effect.promise(() => + expect(launch(bound, ["snapshot"])).rejects.toThrow("Call device_open again"), + ); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("passes each command only the bound flags agent-device accepts for it", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-commands-" }); + const entryPath = path.join(dir, "cli.mjs"); + yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));"); + const command = yield* bindAgentDeviceLauncher({ + entryPath, + stateDir: dir, + session: "t3-a", + targetArgs: [ + "--platform", + "ios", + "--udid", + "UDID-1", + "--config", + "/h.json", + "--session", + "t3-a", + ], + }); + const launch = (args: ReadonlyArray) => + exec(process.execPath, [ + path.join(path.dirname(command), "agent-device-launcher.mjs"), + ...args, + ]).then((result) => JSON.parse(result.stdout)); + // agent-device 0.21.31 rejects these flags with "unknown option" or "not supported for command". + const cdp = ["cdp", "target", "list", "--url", "http://127.0.0.1:8081"]; + expect(yield* Effect.promise(() => launch(cdp))).toEqual(cdp); + expect(yield* Effect.promise(() => launch(["connection"]))).toEqual([ + "connection", + "--config", + "/h.json", + "--session", + "t3-a", + ]); + // The command is found past leading flags and their values, in either form. + for (const config of [["--config", "/o.json"], ["--config=/o.json"]]) { + expect(yield* Effect.promise(() => launch([...config, "connection"]))).toEqual([ + ...config, + "connection", + "--session", + "t3-a", + ]); + // device status reads claims on the machine it runs on, which is not the device's host. + yield* Effect.promise(() => + expect(launch([...config, "device", "status"])).rejects.toThrow( + "`device status` reads this machine's device claims, not those on the device's host", + ), + ); + } + expect(yield* Effect.promise(() => launch(["--json", ...cdp]))).toEqual(["--json", ...cdp]); + for (const managed of [ + ["connect"], + ["disconnect"], + ["auth", "status"], + ["daemon", "stop"], + ["--config", "/o.json", "auth", "status"], + ["--", "connect"], + ]) { + yield* Effect.promise(() => + expect(launch(managed)).rejects.toThrow( + `T3 Code manages this command's connection to the device, so \`${managed.find((arg) => ["connect", "disconnect", "auth", "daemon"].includes(arg))}\` is not available through it.`, + ), + ); + } + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + + it("tells a released session from a close that failed or timed out", () => { + const result = (code: number | null, stderr = "", timedOut = false) => ({ + stdout: "", + stderr, + code, + timedOut, + }); + expect(agentDeviceCloseOutcome(result(0))).toBe("closed"); + // A session that never ran `open` has nothing to release. + expect( + agentDeviceCloseOutcome( + result(1, "Error (SESSION_NOT_FOUND): No active session. Run open first."), + ), + ).toBe("no-session"); + expect( + agentDeviceCloseOutcome(result(1, "Error (COMMAND_FAILED): Remote daemon is unavailable")), + ).toBe("failed"); + expect(agentDeviceCloseOutcome(result(null, "", true))).toBe("timed-out"); + }); + + it.live("stops before agent-device when T3's device connection is down", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-endpoint-" }); + const entryPath = path.join(dir, "cli.mjs"); + yield* fs.writeFileString(entryPath, "console.log(JSON.stringify(process.argv.slice(2)));"); + const configPath = path.join(dir, "host.json"); + // A port that was just free: nothing listens on it, so a connect is refused. + const port = yield* Effect.promise( + () => + new Promise((resolve) => { + const server = NodeNet.createServer(); + server.listen(0, "127.0.0.1", () => { + const address = server.address() as NodeNet.AddressInfo; + server.close(() => resolve(address.port)); + }); + }), + ); + yield* writeAgentDeviceConfig(configPath, { + baseUrl: `http://127.0.0.1:${port}`, + token: "secret", + }); + const command = yield* bindAgentDeviceLauncher({ + entryPath, + stateDir: dir, + session: "t3-a", + targetArgs: ["--config", configPath, "--session", "t3-a"], + }); + const launch = (args: ReadonlyArray) => + exec(process.execPath, [ + path.join(path.dirname(command), "agent-device-launcher.mjs"), + ...args, + ]); + yield* Effect.promise(() => + expect(launch(["screenshot", "/tmp/shot.png"])).rejects.toMatchObject({ + code: 1, + stdout: "", + stderr: `T3 Code's device connection at 127.0.0.1:${port} is not reachable from this shell (connect ECONNREFUSED). Call device_open again to reconnect; if that succeeds and this repeats, this shell runs in a network sandbox, so run the command with network access, outside the sandbox.\n`, + }), + ); + // An explicit --daemon-base-url wins over the config, as it does in agent-device. + const override = `http://127.0.0.1:${yield* listeningPort}`; + for (const flag of [["--daemon-base-url", override], [`--daemon-base-url=${override}`]]) { + expect( + JSON.parse( + (yield* Effect.promise(() => launch(["screenshot", "/tmp/shot.png", ...flag]))).stdout, + ), + ).toEqual([ + "screenshot", + "/tmp/shot.png", + ...flag, + "--config", + configPath, + "--session", + "t3-a", + ]); + } + // cdp talks to Metro, not to T3's connection, so it still runs. + expect( + JSON.parse((yield* Effect.promise(() => launch(["cdp", "target", "list"]))).stdout), + ).toEqual(["cdp", "target", "list"]); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect("prunes the launchers of ended sessions and keeps the shared one", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const stateDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-prune-" }); + const entryPath = path.join(stateDir, "cli.mjs"); + const shared = yield* ensureAgentDeviceShim({ entryPath, stateDir }); + const bind = (session: string) => + bindAgentDeviceLauncher({ entryPath, stateDir, session, targetArgs: [] }); + const kept = yield* bind("t3-open"); + const ended = yield* bind("t3-closed"); + yield* pruneAgentDeviceLaunchers({ stateDir, live: new Set(["t3-open"]) }); + expect(yield* fs.exists(kept)).toBe(true); + expect(yield* fs.exists(path.dirname(ended))).toBe(false); + expect(yield* fs.exists(shared)).toBe(true); + yield* pruneAgentDeviceLaunchers({ stateDir, live: new Set() }); + expect(yield* fs.readDirectory(path.join(stateDir, "device", "targets"))).toEqual([]); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provideService(HostProcess.ExecutablePath, process.execPath), + Effect.provide(NodeServices.layer), + ), + ); + + it.effect.skipIf(HostProcess.Platform.defaultValue() === "win32")( + "tells the agent to reopen the device when the runtime it was bound to is gone", + () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-stale-" }); + const runtime = path.join(dir, "old-node"); + yield* fs.writeFileString(runtime, "#!/bin/sh\nexit 0\n"); + yield* fs.chmod(runtime, 0o755); + const command = yield* bindAgentDeviceLauncher({ + entryPath: path.join(dir, "cli.mjs"), + stateDir: dir, + session: "t3-a", + targetArgs: ["--config", "/h.json", "--session", "t3-a"], + }).pipe(Effect.provideService(HostProcess.ExecutablePath, runtime)); + yield* fs.remove(runtime); + yield* Effect.promise(() => + expect(exec(command, ["snapshot"])).rejects.toMatchObject({ + code: 1, + stderr: expect.stringContaining("Call device_open again"), + }), + ); + }).pipe( + Effect.scoped, + Effect.provideService(HostProcess.IsExecutable, false), + Effect.provide(NodeServices.layer), + ), + ); + it.effect("runs two hosts concurrently and only updates the reconnected host", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -90,9 +420,10 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, const files = yield* Effect.forEach(["mini", "android"], (host) => agentDeviceConfigPath(dir, host, path), ); + const ports = [yield* listeningPort, yield* listeningPort, yield* listeningPort]; for (const [index, file] of files.entries()) yield* writeAgentDeviceConfig(file, { - baseUrl: `http://127.0.0.1:${1000 + index}`, + baseUrl: `http://127.0.0.1:${ports[index]}`, token: `token-${index}`, }); const invoke = (file: string) => @@ -109,12 +440,12 @@ if (process.env.AGENT_DEVICE_DAEMON_BASE_URL) process.exit(2);`, { env: { ...process.env, AGENT_DEVICE_DAEMON_BASE_URL: "http://wrong-host" } }, ).then((result) => JSON.parse(result.stdout)); expect(yield* Effect.promise(() => Promise.all(files.map(invoke)))).toEqual([ - { daemonBaseUrl: "http://127.0.0.1:1000", daemonAuthToken: "token-0" }, - { daemonBaseUrl: "http://127.0.0.1:1001", daemonAuthToken: "token-1" }, + { daemonBaseUrl: `http://127.0.0.1:${ports[0]}`, daemonAuthToken: "token-0" }, + { daemonBaseUrl: `http://127.0.0.1:${ports[1]}`, daemonAuthToken: "token-1" }, ]); const second = yield* fs.readFileString(files[1]!); yield* writeAgentDeviceConfig(files[0]!, { - baseUrl: "http://127.0.0.1:2000", + baseUrl: `http://127.0.0.1:${ports[2]}`, token: "new", }); expect((yield* Effect.promise(() => invoke(files[0]!))).daemonAuthToken).toBe("new"); diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index c13179d9d4932..1261094338901 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -19,6 +19,8 @@ import * as Deferred from "effect/Deferred"; import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -34,6 +36,7 @@ import { makeDeviceHubOperations } from "./DeviceHubOperations.ts"; import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; +import { agentDeviceSession } from "./AgentDeviceTarget.ts"; const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); @@ -603,7 +606,7 @@ it.effect("keeps shutdown successful when subsequent discovery fails", () => ); it.effect.each(["shutdown", "close"] as const)( - "%s releases iOS capture so reopening uses a fresh session", + "%s releases iOS capture and the session's launcher so reopening uses a fresh session", (operation) => Effect.gen(function* () { const deviceId = DeviceId.make("11111111-1111-1111-1111-111111111111"); @@ -680,19 +683,107 @@ it.effect.each(["shutdown", "close"] as const)( stopAgent: Effect.void, stop: Effect.void, }; - const service = yield* DeviceService.makeWithHosts(new Map([[host.id, host]])).pipe( - Effect.provide(NodeCrypto.layer), - ); + const launchers = new Set(["t3-from-a-previous-run"]); + const released: Array<[string, boolean]> = []; + // Whether the device was still on when each session was released. + const releasedWhileBooted: Array = []; + let pauseBind: { entered: Deferred.Deferred; proceed: Deferred.Deferred } | null = + null; + const service = yield* DeviceService.makeWithHosts( + new Map([[host.id, host]]), + undefined, + undefined, + undefined, + { + bind: ({ session }) => + Effect.gen(function* () { + if (pauseBind) { + yield* Deferred.succeed(pauseBind.entered, undefined); + yield* Deferred.await(pauseBind.proceed); + } + launchers.add(session); + return session; + }), + prune: (live) => + Effect.sync(() => { + for (const session of launchers) if (!live.has(session)) launchers.delete(session); + }), + // Records whether the launcher still existed, since its close runs through it. + release: (launcher) => + Effect.sync(() => { + released.push([launcher, launchers.has(launcher)]); + releasedWhileBooted.push(booted); + }), + }, + ).pipe(Effect.provide(NodeCrypto.layer)); + expect(launchers).toEqual(new Set()); const input = { threadId, deviceId, platform: "ios" as const }; - yield* service.open(input); + const other = { ...input, threadId: ThreadId.make("capture-recovery-other") }; + const target = (thread: ThreadId) => ({ + threadId: thread, + hostId: LOCAL_DEVICE_HOST_ID, + deviceId, + }); + const bind = Effect.fnUntraced(function* (open: typeof input) { + yield* service.open(open); + yield* service.agentLauncher({ ...target(open.threadId), targetArgs: [] }); + return yield* agentDeviceSession(open.threadId, LOCAL_DEVICE_HOST_ID, deviceId); + }, Effect.provide(NodeCrypto.layer)); + const session = yield* bind(input); + yield* bind(other); expect(capture).toBe(1); + expect(launchers.size).toBe(2); if (operation === "shutdown") yield* service.shutdown(input); else yield* service.close({ threadId, deviceId, shutdown: true }); expect(capture).toBeNull(); expect((yield* service.state).sessions).toEqual([]); - yield* service.open(input); + expect(launchers).toEqual(new Set()); + // Retiring a session ends its agent-device session too, which releases the device claim. + // Powering the device off retires the other thread's session on it as well. + const firstOther = yield* agentDeviceSession( + other.threadId, + LOCAL_DEVICE_HOST_ID, + deviceId, + ).pipe(Effect.provide(NodeCrypto.layer)); + expect(new Set(released.map(([launcher]) => launcher))).toEqual( + new Set([session, firstOther]), + ); + expect(released.every(([, existed]) => existed)).toBe(true); + // agent-device can close the session's app only while the device is on. + expect(releasedWhileBooted).toEqual([true, true]); + // A device_open that was still preparing when the close landed. + expect(yield* service.agentLauncher({ ...target(threadId), targetArgs: [] })).toEqual( + Option.none(), + ); + expect(launchers).toEqual(new Set()); + expect(yield* bind(input)).toBe(session); + // Reopening the same device ends nothing. + expect(released).toHaveLength(2); expect(capture).toBe(2); expect((yield* service.state).sessions).toHaveLength(1); + const otherSession = yield* bind(other); + yield* service.close({ threadId: other.threadId }); + expect(launchers).toEqual(new Set([session])); + expect(released.slice(2)).toEqual([[otherSession, true]]); + expect(otherSession).not.toBe(session); + // A caller interrupted after retirement committed, while a bind holds the launcher lock. + pauseBind = { entered: yield* Deferred.make(), proceed: yield* Deferred.make() }; + const binding = yield* Effect.forkChild( + service.agentLauncher({ ...target(threadId), targetArgs: [] }), + ); + yield* Deferred.await(pauseBind.entered); + const updates = yield* service.subscribe; + const retiring = yield* Effect.forkChild( + operation === "shutdown" ? service.shutdown(input) : service.close({ threadId }), + ); + expect((yield* PubSub.take(updates)).sessions).toEqual([]); + // list writes through the same state lock, so the retirement has committed. + expect((yield* service.list).sessions).toEqual([]); + yield* Effect.sync(() => retiring.interruptUnsafe()); + yield* Deferred.succeed(pauseBind.proceed, undefined); + yield* Fiber.join(binding); + yield* Fiber.await(retiring); + expect(launchers).toEqual(new Set()); }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped), ); @@ -1150,3 +1241,191 @@ it.effect("a failed tool update reports npm's exit code but not its output", () Effect.scoped, ), ); + +describe("locating a device id across hosts", () => { + const threadId = ThreadId.make("thread-locate"); + const thisMachine = { id: LOCAL_DEVICE_HOST_ID, label: "This machine" }; + const macMini = { id: "mac-mini", label: "Mac Mini" }; + const listingHost = ( + host: { readonly id: string; readonly label: string }, + ids: ReadonlyArray, + ): DeviceHost.DeviceHost["Service"] => { + const devices = ids.map((id) => ({ + id, + name: id, + platform: id.startsWith("Pixel") ? ("android" as const) : ("ios" as const), + version: "test", + booted: true, + physical: false, + })); + const ready: DeviceHost.DeviceHostReady = { + nodePath: process.execPath, + hub: { origin: "http://device.test" }, + devices: { + list: Effect.succeed({ devices, detail: undefined }), + boot: (device) => Effect.succeed(device.id), + attach: () => Effect.void, + shutdown: () => Effect.void, + screenshot: () => Effect.succeed(new Uint8Array()), + }, + // Opening an emulator checks its host for ffmpeg's H.264 encoder. + run: () => Effect.succeed({ code: 0, stdout: " V....D libx264", stderr: "" }), + helpers: { serveSimAxSettings: null, serveSimCli: null }, + }; + return { + ...host, + summary: Effect.succeed({ + ...host, + kind: host.id === LOCAL_DEVICE_HOST_ID ? "local" : "ssh", + platforms: [ + { platform: "ios", available: true }, + { platform: "android", available: true }, + ], + hubInstalled: true, + agentDeviceInstalled: false, + }), + platformAvailability: (platform) => Effect.succeed({ platform, available: true }), + ensureReady: () => Effect.succeed(ready), + ensureAgentReady: () => Effect.die("Agent access is not used in this test"), + current: Effect.succeed(ready), + stopAgent: Effect.void, + stop: Effect.void, + }; + }; + // An Android AVD name, like Pixel_10 here, can exist on two hosts. + const withTwoHosts = ( + test: (service: DeviceService.DeviceService["Service"]) => Effect.Effect, + ) => + Effect.gen(function* () { + const service = yield* DeviceService.makeWithHosts( + new Map([ + [thisMachine.id, listingHost(thisMachine, ["Pixel_10"])], + [macMini.id, listingHost(macMini, ["MAC-UDID", "Pixel_10"])], + ]), + ).pipe(Effect.provide(NodeCrypto.layer)); + yield* service.list; + return yield* test(service); + }).pipe(Effect.provide(ServerSettings.layerTest({ enableDeviceSupport: true })), Effect.scoped); + const open = ( + service: DeviceService.DeviceService["Service"], + hostId: string, + deviceId: string, + thread = threadId, + ) => + service.open({ + threadId: thread, + hostId, + deviceId: DeviceId.make(deviceId), + platform: deviceId.startsWith("Pixel") ? "android" : "ios", + }); + + it.effect("finds a device id that exists only on another host", () => + withTwoHosts((service) => + Effect.gen(function* () { + const located = yield* service.locateDevice({ deviceId: DeviceId.make("MAC-UDID") }); + expect(Option.getOrUndefined(located)).toMatchObject({ + hostId: "mac-mini", + id: "MAC-UDID", + }); + }), + ), + ); + + it.effect("refuses a device id that exists on two hosts and names both", () => + withTwoHosts((service) => + Effect.gen(function* () { + const error = yield* service + .locateDevice({ deviceId: DeviceId.make("Pixel_10") }) + .pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DeviceHostAmbiguousError", deviceId: "Pixel_10" }); + expect(error.hosts).toHaveLength(2); + expect(error.hosts).toEqual(expect.arrayContaining([thisMachine, macMini])); + }), + ), + ); + + it.effect("keeps an explicit hostId and says which host has the device", () => + withTwoHosts((service) => + Effect.gen(function* () { + const error = yield* service + .locateDevice({ deviceId: DeviceId.make("MAC-UDID"), hostId: LOCAL_DEVICE_HOST_ID }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceOnOtherHostError", + deviceId: "MAC-UDID", + host: thisMachine, + hosts: [macMini], + }); + const onMac = yield* service.locateDevice({ + deviceId: DeviceId.make("Pixel_10"), + hostId: "mac-mini", + }); + expect(Option.getOrUndefined(onMac)).toMatchObject({ hostId: "mac-mini", id: "Pixel_10" }); + }), + ), + ); + + it.effect("finds nothing for a device id that no host lists", () => + withTwoHosts((service) => + Effect.gen(function* () { + const deviceId = DeviceId.make("Pixel_9"); + expect(yield* service.locateDevice({ deviceId })).toEqual(Option.none()); + expect(yield* service.locateDevice({ deviceId, hostId: "mac-mini" })).toEqual( + Option.none(), + ); + expect(yield* service.locateDevice({ deviceId, hostId: "unknown-host" })).toEqual( + Option.none(), + ); + }), + ), + ); + + it.effect("finds an open device by id on whichever host it is on", () => + withTwoHosts((service) => + Effect.gen(function* () { + yield* open(service, "mac-mini", "MAC-UDID"); + const deviceId = DeviceId.make("MAC-UDID"); + expect( + Option.getOrUndefined(yield* service.locateSession({ threadId, deviceId })), + ).toMatchObject({ threadId, hostId: "mac-mini", deviceId }); + const error = yield* service + .locateSession({ threadId, deviceId, hostId: LOCAL_DEVICE_HOST_ID }) + .pipe(Effect.flip); + expect(error).toMatchObject({ + _tag: "DeviceOnOtherHostError", + host: thisMachine, + hosts: [macMini], + }); + }), + ), + ); + + it.effect("refuses an id open on two hosts unless the hostId picks one", () => + withTwoHosts((service) => + Effect.gen(function* () { + yield* open(service, LOCAL_DEVICE_HOST_ID, "Pixel_10"); + yield* open(service, "mac-mini", "Pixel_10"); + const deviceId = DeviceId.make("Pixel_10"); + const error = yield* service.locateSession({ threadId, deviceId }).pipe(Effect.flip); + expect(error).toMatchObject({ _tag: "DeviceHostAmbiguousError", deviceId }); + expect(error.hosts).toEqual(expect.arrayContaining([thisMachine, macMini])); + const onMac = yield* service.locateSession({ threadId, deviceId, hostId: "mac-mini" }); + expect(Option.getOrUndefined(onMac)).toMatchObject({ hostId: "mac-mini", deviceId }); + }), + ), + ); + + it.effect("finds no session for a device this thread has not opened", () => + withTwoHosts((service) => + Effect.gen(function* () { + const deviceId = DeviceId.make("MAC-UDID"); + expect(yield* service.locateSession({ threadId, deviceId })).toEqual(Option.none()); + yield* open(service, "mac-mini", "MAC-UDID", ThreadId.make("thread-other")); + expect(yield* service.locateSession({ threadId, deviceId })).toEqual(Option.none()); + expect( + yield* service.locateSession({ threadId, deviceId, hostId: LOCAL_DEVICE_HOST_ID }), + ).toEqual(Option.none()); + }), + ), + ); +}); diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d685dc46abf68..6d7ede5e845fb 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -5,8 +5,8 @@ import { type DeviceDetail, type DeviceDetailInput, type DeviceError, - type DeviceHostId, - type DeviceId, + DeviceHostId, + DeviceId, DeviceHostUnavailableError, DeviceNotFoundError, DeviceOperationError, @@ -33,6 +33,12 @@ import * as FileSystem from "effect/FileSystem"; import { resolveNodeExecutable, nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; import * as Path from "effect/Path"; import { ensureAgentDevice, ensureDeviceHub } from "./DeviceToolchain.ts"; +import { + agentDeviceCloseOutcome, + bindAgentDeviceLauncher, + ensureAgentDeviceShim, + pruneAgentDeviceLaunchers, +} from "./AgentDeviceShim.ts"; import * as ServerConfig from "../config.ts"; import { agentDeviceConfigPath, @@ -44,7 +50,9 @@ import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as PubSub from "effect/PubSub"; +import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; @@ -101,10 +109,30 @@ export interface DeviceAgentReadiness extends DeviceReadiness { readonly agentDevice: DeviceHost.AgentDeviceEndpoint | null; } +const DeviceHostName = Schema.Struct({ id: DeviceHostId, label: Schema.optional(Schema.String) }); +const hostName = (host: typeof DeviceHostName.Type) => host.label ?? `host ${host.id}`; + +export class DeviceHostAmbiguousError extends Schema.TaggedError()( + "DeviceHostAmbiguousError", + { deviceId: DeviceId, hosts: Schema.Array(DeviceHostName) }, +) { + override get message(): string { + return `Device ${this.deviceId} is on more than one host: ${this.hosts.map(hostName).join(", ")}.`; + } +} + +export class DeviceOnOtherHostError extends Schema.TaggedError()( + "DeviceOnOtherHostError", + { deviceId: DeviceId, host: DeviceHostName, hosts: Schema.Array(DeviceHostName) }, +) { + override get message(): string { + return `Device ${this.deviceId} is not on ${hostName(this.host)}. It is on ${this.hosts.map(hostName).join(", ")}.`; + } +} + export class DeviceService extends Context.Service< DeviceService, { - readonly agentCli: Effect.Effect; readonly testHost: ( config: SshDeviceHostConfig, ) => Effect.Effect; @@ -121,9 +149,23 @@ export class DeviceService extends Context.Service< hostId: DeviceHostId; deviceId: DeviceId; }) => Effect.Effect< - { readonly agentDeviceId: string; readonly args: ReadonlyArray }, + { + readonly agentDeviceId: string; + readonly session: string; + readonly args: ReadonlyArray; + }, DeviceError >; + /** + * The command agents run for one agent-device session; it is removed when the session ends. + * None when the session closed before it could be bound. + */ + readonly agentLauncher: (input: { + readonly threadId: ThreadId; + readonly hostId: DeviceHostId; + readonly deviceId: DeviceId; + readonly targetArgs: ReadonlyArray; + }) => Effect.Effect, DeviceError>; readonly state: Effect.Effect; readonly subscribe: Effect.Effect, never, Scope.Scope>; readonly configure: ( @@ -145,6 +187,27 @@ export class DeviceService extends Context.Service< readonly hostId?: DeviceHostId | undefined; readonly deviceId: DeviceId; }) => Effect.Effect; + /** + * The device an id names in the last listing. Ids are unique per host, not across hosts (an + * Android AVD name can exist on two), so a hostId decides, and without one the id has to be + * on a single host. + */ + readonly locateDevice: (input: { + readonly hostId?: DeviceHostId | undefined; + readonly deviceId: DeviceId; + }) => Effect.Effect< + Option.Option, + DeviceHostAmbiguousError | DeviceOnOtherHostError + >; + /** As `locateDevice`, among the devices a thread has open. */ + readonly locateSession: (input: { + readonly threadId: ThreadId; + readonly hostId?: DeviceHostId | undefined; + readonly deviceId: DeviceId; + }) => Effect.Effect< + Option.Option, + DeviceHostAmbiguousError | DeviceOnOtherHostError + >; readonly readiness: (hostId?: DeviceHostId) => Effect.Effect; readonly readinessIfSupported: ( hostId?: DeviceHostId, @@ -161,6 +224,17 @@ interface ServiceState { readonly state: DeviceServiceState; } +export interface AgentDeviceLaunchers { + readonly bind: (input: { + readonly session: string; + readonly targetArgs: ReadonlyArray; + }) => Effect.Effect; + /** Removes every launcher whose session is not in `live`. */ + readonly prune: (live: ReadonlySet) => Effect.Effect; + /** Ends the agent-device session behind a launcher, which releases its claim on the device. */ + readonly release?: (launcher: string) => Effect.Effect; +} + export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ( hosts: ReadonlyMap, testHost: DeviceService["Service"]["testHost"] = (host) => @@ -183,6 +257,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ), installTool?: (tool: "hub" | "agent") => Effect.Effect, + launchers?: AgentDeviceLaunchers, ) { const settings = yield* ServerSettings.ServerSettingsService; const crypto = yield* Crypto.Crypto; @@ -221,11 +296,46 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }, }); + const launcherLock = yield* Semaphore.make(1); + /** Launchers bound since startup, by agent-device session. */ + const boundLaunchers = new Map(); + // Binding and pruning read sessions under the lock, so neither acts on a session list the other has outdated. + // A retired session's agent-device session ends before its launcher goes, and a reopen of the same + // device waits for the lock, so the close cannot end the reopened session. + const pruneLaunchers = launcherLock.withPermit( + Effect.gen(function* () { + if (!launchers) return; + const { state } = yield* SynchronizedRef.get(stateRef); + const live = new Set( + yield* Effect.forEach(state.sessions, (session) => + agentDeviceSession(session.threadId, session.hostId, session.deviceId), + ).pipe(Effect.provideService(Crypto.Crypto, crypto)), + ); + const retired = [...boundLaunchers].filter(([session]) => !live.has(session)); + for (const [session] of retired) boundLaunchers.delete(session); + const release = launchers.release; + if (release) + yield* Effect.forEach(retired, ([, launcher]) => release(launcher), { + concurrency: "unbounded", + discard: true, + }); + yield* launchers.prune(live); + }), + ); + + // Uninterruptible so a committed retirement always prunes, even if its caller is cancelled. const publish = (update: (state: DeviceServiceState) => DeviceServiceState) => - SynchronizedRef.updateAndGetEffect(stateRef, ({ state }) => { + SynchronizedRef.modifyEffect(stateRef, ({ state }) => { const next = { ...update(state), revision: state.revision + 1 }; - return PubSub.publish(statePubSub, next).pipe(Effect.as({ state: next })); - }).pipe(Effect.map(({ state }) => state)); + const retired = state.sessions.some((session) => !next.sessions.includes(session)); + return PubSub.publish(statePubSub, next).pipe( + Effect.as([{ next, retired }, { state: next }] as const), + ); + }).pipe( + Effect.tap(({ retired }) => (retired ? pruneLaunchers : Effect.void)), + Effect.map(({ next }) => next), + Effect.uninterruptible, + ); const resolveHost = Effect.fn("DeviceService.resolveHost")(function* ( hostId: DeviceHostId | undefined, @@ -727,9 +837,16 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* (input.deviceId === undefined || session.deviceId === input.deviceId), ); if (closing.length === 0) return; + // Powering off ends every session on the device, so all of them retire, and release, while it is on. + const ending = (session: DeviceSession) => + closing.includes(session) || + (input.shutdown === true && + closing.some( + (closed) => closed.hostId === session.hostId && closed.deviceId === session.deviceId, + )); yield* publish((current) => ({ ...current, - sessions: current.sessions.filter((session) => !closing.includes(session)), + sessions: current.sessions.filter((session) => !ending(session)), })); if (input.shutdown) { yield* Effect.forEach( @@ -741,16 +858,18 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }, ); + // Sessions retire before the power-off, while their agent-device sessions can still close the app; + // agent-device keeps a claim whose app close fails. const shutdown: DeviceService["Service"]["shutdown"] = Effect.fn("DeviceService.shutdown")( function* (input) { const host = yield* resolveHost(input.hostId); - yield* shutdownDevice(host.id, input.deviceId, input.platform); yield* publish((current) => ({ ...current, sessions: current.sessions.filter( (session) => !(session.hostId === host.id && session.deviceId === input.deviceId), ), })); + yield* shutdownDevice(host.id, input.deviceId, input.platform); }, ); @@ -801,6 +920,64 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* Effect.map(({ state }) => state.sessions.filter((session) => session.threadId === threadId)), ); + const locate = ( + state: DeviceServiceState, + input: { readonly hostId?: DeviceHostId | undefined; readonly deviceId: DeviceId }, + matches: ReadonlyArray, + ): Effect.Effect, DeviceHostAmbiguousError | DeviceOnOtherHostError> => { + const named = (id: DeviceHostId) => ({ + id, + label: state.hosts.find((host) => host.id === id)?.label, + }); + const onHost = matches.filter( + (match) => input.hostId === undefined || match.hostId === input.hostId, + ); + const hostIds = [...new Set(onHost.map((match) => match.hostId))]; + if (hostIds.length === 1) return Effect.succeedSome(onHost.at(-1)!); + if (hostIds.length > 1) { + return Effect.fail( + new DeviceHostAmbiguousError({ deviceId: input.deviceId, hosts: hostIds.map(named) }), + ); + } + const elsewhere = [...new Set(matches.map((match) => match.hostId))]; + return input.hostId !== undefined && elsewhere.length > 0 + ? Effect.fail( + new DeviceOnOtherHostError({ + deviceId: input.deviceId, + host: named(input.hostId), + hosts: elsewhere.map(named), + }), + ) + : Effect.succeedNone; + }; + + const locateDevice: DeviceService["Service"]["locateDevice"] = Effect.fn( + "DeviceService.locateDevice", + )(function* (input) { + const { state } = yield* SynchronizedRef.get(stateRef); + return yield* locate( + state, + input, + state.devices.filter((device) => device.id === input.deviceId), + ); + }); + + const locateSession: DeviceService["Service"]["locateSession"] = Effect.fn( + "DeviceService.locateSession", + )(function* (input) { + const { state } = yield* SynchronizedRef.get(stateRef); + return yield* locate( + state, + input, + state.sessions.filter( + (session) => session.threadId === input.threadId && session.deviceId === input.deviceId, + ), + ); + }); + + // Sessions live in memory, so this removes every launcher a previous run left behind. + yield* pruneLaunchers; + return { ...DeviceService.of({ testHost, @@ -827,12 +1004,6 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* }), ); }), - agentCli: Effect.fail( - new DeviceHostUnavailableError({ - hostId: LOCAL_DEVICE_HOST_ID, - reason: "Agent CLI installation is unavailable in this device service.", - }), - ), agentTarget: Effect.fn("DeviceService.agentTarget")(function* (input) { const host = yield* resolveHost(input.hostId); const ready = yield* agentReadinessIfSupported(input.hostId); @@ -875,7 +1046,39 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* input.hostId, input.deviceId, ).pipe(Effect.provideService(Crypto.Crypto, crypto)); - return { agentDeviceId, args: ["--config", configPath, "--session", session] }; + return { agentDeviceId, session, args: ["--config", configPath, "--session", session] }; + }), + agentLauncher: Effect.fn("DeviceService.agentLauncher")(function* ({ + targetArgs, + ...target + }) { + if (!launchers) + return yield* new DeviceHostUnavailableError({ + hostId: target.hostId, + reason: "Agent launchers are unavailable in this device service.", + }); + const session = yield* agentDeviceSession( + target.threadId, + target.hostId, + target.deviceId, + ).pipe(Effect.provideService(Crypto.Crypto, crypto)); + return yield* launcherLock.withPermit( + Effect.gen(function* () { + const { state } = yield* SynchronizedRef.get(stateRef); + if ( + !state.sessions.some( + (live) => + live.threadId === target.threadId && + live.hostId === target.hostId && + live.deviceId === target.deviceId, + ) + ) + return Option.none(); + const launcher = yield* launchers.bind({ session, targetArgs }); + boundLaunchers.set(session, launcher); + return Option.some(launcher); + }), + ); }), state: SynchronizedRef.get(stateRef).pipe(Effect.map(({ state }) => state)), subscribe: PubSub.subscribe(statePubSub), @@ -887,6 +1090,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* detail, action, screenshot, + locateDevice, + locateSession, readiness, readinessIfSupported, agentReadinessIfSupported, @@ -952,6 +1157,94 @@ export const make = Effect.gen(function* () { }), ), ); + const agentCli = resolveNodeExecutable("Device automation").pipe( + Effect.andThen(ensureAgentDevice(config.baseDir)), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.provideService(ProcessRunner.ProcessRunner, runner), + Effect.map((tool) => tool.entryPath), + Effect.mapError((error) => + error._tag === "NodeRuntimeUnavailableError" + ? new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + hostLabel: LOCAL_DEVICE_HOST_LABEL, + reason: nodeRuntimeUnavailableMessage("Device automation"), + cause: error, + }) + : new DeviceOperationError({ + operation: "install agent CLI", + reason: "command_failed", + cause: error, + }), + ), + ); + const launchers: AgentDeviceLaunchers = { + bind: Effect.fn("DeviceService.bindLauncher")( + function* (input) { + const entryPath = yield* agentCli; + // The unbound launcher stays current for agents that still call it with flags. + yield* ensureAgentDeviceShim({ entryPath, stateDir: config.stateDir }); + return yield* bindAgentDeviceLauncher({ entryPath, stateDir: config.stateDir, ...input }); + }, + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.catchTags({ + NodeRuntimeUnavailableError: (cause) => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + hostLabel: LOCAL_DEVICE_HOST_LABEL, + reason: nodeRuntimeUnavailableMessage("Device automation"), + cause, + }), + ), + PlatformError: (cause) => + Effect.fail( + new DeviceHostUnavailableError({ + hostId: LOCAL_DEVICE_HOST_ID, + hostLabel: LOCAL_DEVICE_HOST_LABEL, + reason: "Could not prepare the agent-device launcher.", + cause, + }), + ), + }), + ), + prune: (live) => + pruneAgentDeviceLaunchers({ stateDir: config.stateDir, live }).pipe( + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + ), + // Best effort: an unreachable host fails fast in the launcher's connection check, and the + // launcher goes either way. Only a failed or timed-out close can leave the device claimed. + release: (launcher) => + resolveNodeExecutable("Device automation").pipe( + Effect.flatMap((node) => + runner.run({ + command: node, + args: [path.join(path.dirname(launcher), "agent-device-launcher.mjs"), "close"], + env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, + timeout: "15 seconds", + timeoutBehavior: "timedOutResult", + }), + ), + Effect.provideService(FileSystem.FileSystem, fs), + Effect.provideService(Path.Path, path), + Effect.flatMap((result) => { + const outcome = agentDeviceCloseOutcome(result); + return outcome === "failed" || outcome === "timed-out" + ? Effect.logWarning("Could not end the agent-device session", { + launcher, + outcome, + stderr: result.stderr.slice(-500), + }) + : Effect.void; + }), + Effect.tapError((cause) => + Effect.logWarning("Could not end the agent-device session", { launcher, cause }), + ), + Effect.ignore, + ), + }; const probeContext = yield* Effect.context>>(); const localTargetContext = @@ -991,6 +1284,7 @@ export const make = Effect.gen(function* () { }), ), ), + launchers, ); const hostContext = yield* Effect.context>>(); @@ -1132,27 +1426,6 @@ export const make = Effect.gen(function* () { return { ...service, easAccount, - agentCli: resolveNodeExecutable("Device automation").pipe( - Effect.andThen(ensureAgentDevice(config.baseDir)), - Effect.provideService(FileSystem.FileSystem, fs), - Effect.provideService(Path.Path, path), - Effect.provideService(ProcessRunner.ProcessRunner, runner), - Effect.map((tool) => tool.entryPath), - Effect.mapError((error) => - error._tag === "NodeRuntimeUnavailableError" - ? new DeviceHostUnavailableError({ - hostId: LOCAL_DEVICE_HOST_ID, - hostLabel: LOCAL_DEVICE_HOST_LABEL, - reason: nodeRuntimeUnavailableMessage("Device automation"), - cause: error, - }) - : new DeviceOperationError({ - operation: "install agent CLI", - reason: "command_failed", - cause: error, - }), - ), - ), }; }); diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index ca96fd582f328..6341d23b6bfc3 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "@effect/vitest"; +import { describe, expect, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { DeviceHostUnavailableError, @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import { McpSchema, McpServer } from "effect/ai"; import * as ServerConfig from "../config.ts"; @@ -104,6 +105,13 @@ const layerProjectsMock = Layer.mock(ProjectService.ProjectService)({ getById: () => Effect.succeedNone, }); +const openSession = { + threadId, + hostId: "local", + deviceId: DeviceId.make("UDID-1"), + platform: "ios" as const, + openedAt: "2026-09-08T00:00:00.000Z", +}; const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ state: Effect.succeed(state), list: Effect.succeed(state), @@ -117,30 +125,25 @@ const layerDeviceServiceMock = Layer.mock(DeviceService.DeviceService)({ openedAt: "2026-09-08T00:00:00.000Z", }), - sessionsForThread: (id) => + sessionsForThread: (id) => Effect.succeed(id === threadId ? [openSession] : []), + locateSession: (input) => Effect.succeed( - id === threadId - ? [ - { - threadId, - hostId: "local", - deviceId: DeviceId.make("UDID-1"), - platform: "ios" as const, - openedAt: "2026-09-08T00:00:00.000Z", - }, - ] - : [], + input.threadId === threadId && input.deviceId === openSession.deviceId + ? Option.some(openSession) + : Option.none(), ), screenshot: () => Effect.succeed({ device, png }), close: () => Effect.void, - agentCli: Effect.succeed("/cli"), testHost: () => Effect.die("not used"), // As for an EAS device, agent-device knows the device by a different id than T3. agentTarget: () => Effect.succeed({ agentDeviceId: "SIM-UDID", + session: "thread-device", args: ["--config", "/host.json", "--session", "thread-device"], }), + agentLauncher: ({ targetArgs }) => + Effect.succeedSome(`/state/device/targets/thread-device/${targetArgs.join("_")}/agent-device`), }); const layerTest = McpHttpServer.layerDeviceToolkit.pipe( @@ -166,8 +169,31 @@ it.effect("registers the device tools and returns the screenshot as image conten .callTool({ name: "device_open", arguments: { platform: "ios" } }) .pipe(callWith(["device"]), Effect.provideService(McpSchema.McpServerClient, client)); expect(opened.isError).toBe(false); - const openedContent = opened.structuredContent as { quickStart: string }; - expect(openedContent.quickStart).toContain("--udid SIM-UDID"); + const openedContent = opened.structuredContent as { + agentDevice: { command: string; targetArgs: ReadonlyArray; session: string }; + quickStart: string; + }; + // Callers that pass the target flags, like test-t3-mobile's pair-client.sh, keep working. + expect(openedContent.agentDevice).toMatchObject({ + targetArgs: [ + "--platform", + "ios", + "--udid", + "SIM-UDID", + "--config", + "/host.json", + "--session", + "thread-device", + ], + session: "thread-device", + }); + expect(openedContent.quickStart).toContain( + `${openedContent.agentDevice.command} snapshot -i`, + ); + // The launcher selects the device by agent-device's id for it, not T3's. + expect(openedContent.agentDevice.command).toBe( + "/state/device/targets/thread-device/--platform_ios_--udid_SIM-UDID_--config_/host.json_--session_thread-device/agent-device", + ); const shot = yield* server .callTool({ name: "device_screenshot", arguments: { deviceId: "UDID-1" } }) @@ -188,6 +214,14 @@ it.effect("registers the device tools and returns the screenshot as image conten .callTool({ name: "device_list", arguments: {} }) .pipe(callWith(["preview"]), Effect.provideService(McpSchema.McpServerClient, client)); expect(denied.isError).toBe(true); + // The agent can't see Settings, and a running session keeps the capabilities it started with, + // so the error says where the user turns access on and when it applies. + expect(denied.content).toEqual([ + { + type: "text", + text: "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", + }, + ]); }), ).pipe(Effect.provide(layerTest)), ); @@ -234,3 +268,341 @@ it.effect("rejects unavailable agent access before booting or opening a device", ), ); }); + +describe("device targeting across hosts", () => { + const macHost = { + id: "mac-mini", + kind: "ssh" as const, + label: "Mac Mini", + platforms: [ + { platform: "ios" as const, available: true }, + { platform: "android" as const, available: true }, + ], + hubInstalled: true, + agentDeviceInstalled: true, + }; + const macIphone = { ...device, hostId: "mac-mini", id: "MAC-UDID" }; + const localPixel = { + hostId: "local", + id: "Pixel_10", + platform: "android" as const, + name: "Pixel 10", + version: "Android 16", + booted: false, + physical: false, + }; + const macPixel = { ...localPixel, hostId: "mac-mini" }; + const multiHostState = { + ...state, + hosts: [...state.hosts, macHost], + hostStatuses: { + local: { status: "ready" as const }, + "mac-mini": { status: "ready" as const }, + }, + devices: [device, macIphone, localPixel, macPixel], + }; + const session = (hostId: string, deviceId: string, platform: "ios" | "android") => ({ + threadId, + hostId, + deviceId: DeviceId.make(deviceId), + platform, + openedAt: "2026-09-08T00:00:00.000Z", + }); + const thisMachine = { id: "local", label: "This machine" }; + const macMini = { id: macHost.id, label: macHost.label }; + // DeviceService's lookup errors; DeviceService.test.ts covers when each arises. + const onTwoHosts = new DeviceService.DeviceHostAmbiguousError({ + deviceId: DeviceId.make("Pixel_10"), + hosts: [thisMachine, macMini], + }); + const onTheMac = new DeviceService.DeviceOnOtherHostError({ + deviceId: DeviceId.make("MAC-UDID"), + host: thisMachine, + hosts: [macMini], + }); + + const run = ( + sessions: ReadonlyArray>, + program: (calls: { + readonly opened: Array<{ hostId: string; deviceId: string }>; + readonly closed: Array<{ hostId?: string | undefined; deviceId?: string | undefined }>; + readonly call: ( + name: string, + args: Record, + ) => Effect.Effect< + McpSchema.CallToolResult, + McpSchema.InternalError | McpSchema.InvalidParams, + McpServer.McpServer + >; + }) => Effect.Effect, + overrides: Partial = {}, + ) => { + const opened: Array<{ hostId: string; deviceId: string }> = []; + const closed: Array<{ hostId?: string | undefined; deviceId?: string | undefined }> = []; + const layerDevices = Layer.mock(DeviceService.DeviceService)({ + state: Effect.succeed(multiHostState), + list: Effect.succeed(multiHostState), + agentReadinessIfSupported: () => Effect.succeed(readiness), + open: (input) => + Effect.sync(() => { + opened.push({ hostId: input.hostId ?? "local", deviceId: input.deviceId }); + return session(input.hostId ?? "local", input.deviceId, input.platform); + }), + sessionsForThread: () => Effect.succeed(sessions), + locateDevice: (input) => + Effect.succeed( + Option.fromUndefinedOr( + multiHostState.devices.find( + (candidate) => + candidate.id === input.deviceId && + (input.hostId === undefined || candidate.hostId === input.hostId), + ), + ), + ), + locateSession: (input) => + Effect.succeed( + Option.fromUndefinedOr( + sessions.find( + (candidate) => + candidate.deviceId === input.deviceId && + (input.hostId === undefined || candidate.hostId === input.hostId), + ), + ), + ), + screenshot: (target) => + Effect.succeed({ + device: + multiHostState.devices.find( + (candidate) => candidate.hostId === target.hostId && candidate.id === target.deviceId, + ) ?? device, + png, + }), + close: (input) => + Effect.sync(() => { + closed.push({ hostId: input.hostId, deviceId: input.deviceId }); + }), + agentTarget: () => + Effect.succeed({ + agentDeviceId: "MAC-UDID", + session: "thread-device", + args: ["--config", "/mac.json", "--session", "thread-device"], + }), + agentLauncher: () => Effect.succeedSome("/state/device/targets/thread-device/agent-device"), + ...overrides, + }); + const call = (name: string, args: Record) => + McpServer.McpServer.pipe( + Effect.flatMap((server) => server.callTool({ name, arguments: args })), + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])), + Effect.provideService(McpSchema.McpServerClient, client), + ); + return program({ opened, closed, call }).pipe( + Effect.scoped, + Effect.provide( + McpHttpServer.layerDeviceToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide(layerDevices), + Layer.provide(layerProjectsMock), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }), + ), + Layer.provide(NodeServices.layer), + ), + ), + ); + }; + const text = (result: McpSchema.CallToolResult) => + result.content.flatMap((entry) => (entry.type === "text" ? [entry.text] : [])).join("\n"); + + it.effect("opens a device id that exists only on another host", () => + run([], ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "MAC-UDID" }); + expect(text(result)).not.toContain("No device"); + expect(result.isError).toBe(false); + expect(opened).toEqual([{ hostId: "mac-mini", deviceId: "MAC-UDID" }]); + }), + ), + ); + + it.effect("binds the launcher to the id the device booted with", () => { + const targets: Array = []; + return run( + [], + ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "Pixel_10", hostId: "mac-mini" }); + expect(result.isError).toBe(false); + expect(targets).toEqual(["emulator-5554", "emulator-5554"]); + }), + { + open: (input) => + Effect.succeed(session(input.hostId ?? "local", "emulator-5554", "android")), + agentTarget: (target) => + Effect.sync(() => { + targets.push(target.deviceId); + return { agentDeviceId: target.deviceId, session: "thread-device", args: [] }; + }), + agentLauncher: (target) => + Effect.sync(() => { + targets.push(target.deviceId); + return Option.some("/state/device/targets/thread-device/agent-device"); + }), + }, + ); + }); + + it.effect("says a device closed during device_open was closed, not that its host is down", () => + run( + [], + ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "MAC-UDID" }); + expect(result.isError).toBe(true); + expect(text(result)).toBe( + "The device was closed while device_open was preparing it. Call device_open again only if you still need it.", + ); + }), + { agentLauncher: () => Effect.succeedNone }, + ), + ); + + it.effect("refuses a device id that exists on two hosts and names both", () => + run( + [], + ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "Pixel_10" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId local"); + expect(text(result)).toContain("hostId mac-mini"); + expect(text(result)).toBe( + "Device Pixel_10 is on more than one host: 'This machine' (hostId local), 'Mac Mini' (hostId mac-mini). Pass hostId.", + ); + expect(opened).toEqual([]); + }), + { locateDevice: () => Effect.fail(onTwoHosts) }, + ), + ); + + it.effect("keeps an explicit hostId and says which host has the device", () => + run( + [], + ({ opened, call }) => + Effect.gen(function* () { + const result = yield* call("device_open", { deviceId: "MAC-UDID", hostId: "local" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId mac-mini"); + expect(text(result)).toBe( + "Device MAC-UDID is not on 'This machine' (hostId local). It is on 'Mac Mini' (hostId mac-mini); pass that hostId.", + ); + expect(opened).toEqual([]); + }), + { locateDevice: () => Effect.fail(onTheMac) }, + ), + ); + + it.effect("says no host has a device id, naming the host it was asked on", () => + run([], ({ opened, call }) => + Effect.gen(function* () { + const missing = (args: Record) => + call("device_open", { deviceId: "Pixel_9", ...args }).pipe(Effect.map(text)); + expect(yield* missing({})).toBe( + "No device Pixel_9 on any host. Call device_list for current ids.", + ); + expect(yield* missing({ hostId: "mac-mini" })).toBe( + "No device Pixel_9 on 'Mac Mini' (hostId mac-mini). Call device_list for current ids.", + ); + expect(yield* missing({ hostId: "unknown-host" })).toBe( + "No device Pixel_9 on hostId unknown-host. Call device_list for current ids.", + ); + expect(opened).toEqual([]); + }), + ), + ); + + it.effect("screenshots an open device by id on whichever host it is on", () => + run([session("mac-mini", "MAC-UDID", "ios")], ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_screenshot", { deviceId: "MAC-UDID" }); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ device: { hostId: "mac-mini" } }); + }), + ), + ); + + it.effect("says why device_screenshot failed instead of a generic error", () => + run( + [session("mac-mini", "MAC-UDID", "ios")], + ({ call }) => + Effect.gen(function* () { + const wrongHost = yield* call("device_screenshot", { + deviceId: "MAC-UDID", + hostId: "local", + }); + expect(wrongHost.isError).toBe(true); + expect(text(wrongHost)).toContain("It is on 'Mac Mini' (hostId mac-mini)"); + expect(text(wrongHost)).toBe( + "Device MAC-UDID is not on 'This machine' (hostId local). It is on 'Mac Mini' (hostId mac-mini); pass that hostId.", + ); + }), + { locateSession: () => Effect.fail(onTheMac) }, + ), + ); + + it.effect("tells an agent to open a device id before screenshotting it", () => + run([], ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_screenshot", { deviceId: "MAC-UDID" }); + expect(result.isError).toBe(true); + expect(text(result)).toBe( + "Device MAC-UDID is not open in this thread. Call device_open first.", + ); + }), + ), + ); + + it.effect("tells an agent with no open device to call device_open before a screenshot", () => + run([], ({ call }) => + Effect.gen(function* () { + const result = yield* call("device_screenshot", {}); + expect(result.isError).toBe(true); + expect(text(result)).toBe("No device is open in this thread. Call device_open first."); + expect(result.structuredContent).toMatchObject({ + error: { _tag: "DeviceToolUnavailableError" }, + }); + }), + ), + ); + + it.effect("refuses to close an id open on two hosts instead of closing both", () => + run( + [session("local", "Pixel_10", "android"), session("mac-mini", "Pixel_10", "android")], + ({ closed, call }) => + Effect.gen(function* () { + const result = yield* call("device_close", { deviceId: "Pixel_10" }); + expect(result.isError).toBe(true); + expect(text(result)).toContain("hostId mac-mini"); + expect(text(result)).toBe( + "Device Pixel_10 is on more than one host: 'This machine' (hostId local), 'Mac Mini' (hostId mac-mini). Pass hostId.", + ); + expect(closed).toEqual([]); + }), + { locateSession: () => Effect.fail(onTwoHosts) }, + ), + ); + + it.effect("closes an open device on its host and treats an unopened one as closed", () => + run([session("mac-mini", "MAC-UDID", "ios")], ({ closed, call }) => + Effect.gen(function* () { + expect((yield* call("device_close", { deviceId: "MAC-UDID" })).isError).toBe(false); + expect((yield* call("device_close", { deviceId: "Pixel_9" })).isError).toBe(false); + expect(closed).toEqual([ + { hostId: "mac-mini", deviceId: "MAC-UDID" }, + { hostId: undefined, deviceId: "Pixel_9" }, + ]); + }), + ), + ); +}); diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 1173b4cdcade7..4365267e2144d 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -14,6 +14,7 @@ import type * as Types from "effect/Types"; import { AiError, McpProtocol, McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/http"; import { + DeviceToolError, OrchestratorMcpFailure, PreviewAutomationError, ProviderInteractionMode, @@ -765,11 +766,15 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh Effect.provideService(ThreadManagementService.ThreadManagementService, threads), ), "screenshot", - "Device screenshot failed.", + (error) => + isDeviceToolError(error) || isOrchestratorMcpFailure(error) + ? error.message + : "Device screenshot failed.", ); }); const isOrchestratorMcpFailure = Schema.is(OrchestratorMcpFailure); +const isDeviceToolError = Schema.is(DeviceToolError); const registerHtmlPreview = Effect.fn("McpHttpServer.registerHtmlPreview")(function* () { const htmlRender = yield* HtmlRender.HtmlRender; diff --git a/apps/server/src/mcp/toolkits/device/handlers.test.ts b/apps/server/src/mcp/toolkits/device/handlers.test.ts index 0ad4b95581b2d..666a244ed50f6 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.test.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.test.ts @@ -26,6 +26,34 @@ describe("device tool helpers", () => { expect(text).not.toContain("is on PATH"); }); + it("shows a bound launcher's commands without flags and keeps device actions off simctl and ssh", () => { + const command = "/state/device/targets/t3-a/agent-device"; + const text = agentDeviceQuickStart(device, [], command); + const examples = text.split("\n").filter((line) => line.trimStart().startsWith(command)); + expect(examples).toContain(` ${command} snapshot -i # accessibility tree with @eN refs`); + expect(examples.find((line) => line.includes(" screenshot "))).toContain("--pixel-density "); + expect(agentDeviceQuickStart({ ...device, platform: "android" }, [], command)).not.toContain( + "--pixel-density", + ); + expect(examples.join("\n")).not.toMatch(/--(?:platform|udid|serial|config|session)\b/); + expect(text).not.toContain("remain available"); + expect(text).toContain("Do not use simctl, idb, adb or ssh for those actions."); + expect(text).toContain("If it is missing, call device_open again."); + }); + + it("opens the app before snapshots, CLI screenshots or input, looks with device_screenshot, and records the device screen", () => { + const command = "/state/device/targets/t3-a/agent-device"; + const text = agentDeviceQuickStart(device, [], command); + // device_open starts no agent-device session, and an iOS snapshot needs one with an app. + expect(text).toContain( + "Run open (apps lists them) before snapshots, CLI screenshots or input; it starts the session they need. Use device_screenshot just to look.", + ); + // App-scoped recording needs an app opened in the session; device scope does not. + expect(text).toContain( + ` ${command} record start --scope device /tmp/run.mp4 # then record stop`, + ); + }); + it("reads PNG dimensions from the IHDR chunk", () => { const png = new Uint8Array(24); new DataView(png.buffer).setUint32(0, 0x89504e47); diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 8fdbd264b8726..4036f083ca910 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -1,6 +1,7 @@ import { type DeviceError, type DeviceHostId, + type DeviceHostSummary, type DeviceId, type DevicePlatform, type DeviceSummary, @@ -8,12 +9,7 @@ import { LOCAL_DEVICE_HOST_ID, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; -import * as Path from "effect/Path"; -import * as HostProcess from "@t3tools/shared/HostProcess"; -import * as ServerConfig from "../../../config.ts"; -import { ensureAgentDeviceShim } from "../../../device/AgentDeviceShim.ts"; -import { nodeRuntimeUnavailableMessage } from "@t3tools/shared/nodeRuntime"; - +import * as Option from "effect/Option"; import * as DeviceService from "../../../device/DeviceService.ts"; import { threadWorkspaceRoot } from "../../../project/threadWorkspaceRoot.ts"; import * as McpInvocationContext from "../../McpInvocationContext.ts"; @@ -26,20 +22,19 @@ function agentDeviceTargetArgs(device: DeviceSummary): ReadonlyArray { : ["--platform", "android", "--serial", device.id]; } +const shellWord = (value: string) => + /^[a-zA-Z0-9_./:-]+$/.test(value) ? value : "'" + value.replaceAll("'", "'\"'\"'") + "'"; + +/** `targetArgs` are the flags the command still needs; a bound launcher needs none. */ export function agentDeviceQuickStart( device: DeviceSummary, targetArgs = agentDeviceTargetArgs(device), command = "agent-device", cloud = false, ): string { - const executable = /^[a-zA-Z0-9_./:-]+$/.test(command) - ? command - : "'" + command.replaceAll("'", "'\"'\"'") + "'"; - const target = targetArgs - .map((arg) => - /^[a-zA-Z0-9_./:-]+$/.test(arg) ? arg : "'" + arg.replaceAll("'", "'\"'\"'") + "'", - ) - .join(" "); + const executable = shellWord(command); + const target = targetArgs.map(shellWord).join(" "); + const run = (args: string) => [executable, args, target].filter(Boolean).join(" "); const platformNotes = cloud ? "This is an EAS Simulator session in the cloud. It bills the Expo account per minute until it stops, so call device_close with shutdown: true when you are done. It starts with no apps installed: install-from-source installs a build from a URL (iOS needs a .tar.gz containing a .app), and install uploads one from this machine." : device.platform === "ios" @@ -47,18 +42,31 @@ export function agentDeviceQuickStart( : "The Android snapshot helper installs itself on first use."; return [ `The user is watching ${device.name} (${device.version}) in the Device panel.`, - `Drive it with ${executable}. Use this exact executable path; login shells may reset PATH. Always pass ${target}.`, - "Typical loop:", - ` ${executable} open ${target} # or: open `, - ` ${executable} snapshot -i ${target} # accessibility tree with @eN refs`, - ` ${executable} click @e3 ${target}`, - ` ${executable} fill @e5 "text" ${target}`, - ` ${executable} screenshot /tmp/shot.png ${target} # or call device_screenshot`, - ` ${executable} install ${target}`, - `Prefer snapshot refs over coordinates. Run ${executable} help for workflow guides and ${executable} --help for flags.`, - "Prefer agent-device for driving this device. simctl, adb, and xcrun remain available for anything it does not cover.", - "For remote hosts, arrange builds, app installation, and any Metro reverse forwarding yourself. T3 provides discovery, streaming, and control only.", - "Keep the returned --config and --session flags on every command. Other hosts can be used concurrently; opening one does not switch these commands.", + target === "" + ? `Drive it with ${executable}. It is bound to this device and session, so it needs no --platform, --udid, --serial, --config or --session flags. Use this exact path; login shells may reset PATH.` + : `Drive it with ${executable}. Use this exact path; login shells may reset PATH. Always pass ${target}.`, + // Screenshots and input need the session agent-device's open starts, and an iOS snapshot also + // needs its app. device_open does not run open itself: on iOS that can start a minutes-long + // XCTest runner build. + "Every app launch, deep link, tap, text entry, alert, setting, screenshot and recording on this device goes through it. Run open (apps lists them) before snapshots, CLI screenshots or input; it starts the session they need. Use device_screenshot just to look.", + ` ${run("open --relaunch")} # restarts the app; Expo dev clients connect to their Metro, --metro-port picks it`, + ` ${run("open ")}`, + ` ${run("snapshot -i")} # accessibility tree with @eN refs`, + ` ${run("press @e3 --settle")}`, + ` ${run('fill @e5 "text" --settle')}`, + ` ${run("alert accept")}`, + ` ${run("settings appearance dark")} # also text-size, permission grant `, + ` ${run("screenshot /tmp/shot.png")} # ${device.platform === "ios" ? "iOS saves 1x points; add --pixel-density (3 on most iPhones) for device resolution, " : ""}or call device_screenshot to look`, + ` ${run("record start --scope device /tmp/run.mp4")} # then record stop`, + ` ${run("install ")}`, + `Prefer snapshot refs over coordinates. Run ${executable} help for flags and ${executable} help workflow for guides.`, + "Do not use simctl, idb, adb or ssh for those actions. They stay fine for builds, installs, code sync, Metro, port forwards, logs, test runners and what this command cannot do; come back to it for every UI action and screenshot so the user can watch.", + "Each device_open returns its own command; opening another device does not retarget this one.", + ...(target === "" + ? [ + "The command is removed when the device closes or T3 Code restarts. If it is missing, call device_open again.", + ] + : []), platformNotes, ].join("\n"); } @@ -67,30 +75,61 @@ const requireDeviceAccess = McpInvocationContext.requireThreadMcpCapability("dev Effect.mapError( () => new DeviceToolUnavailableError({ - reason: "Agent device access is turned off for this environment.", + reason: + "Agent device access is turned off for this environment. Ask the user to turn on Agent device access in Settings > Integrations > Devices; it applies when the agent session next starts, such as in a new thread.", }), ), ); +const hostName = (host: { readonly id: DeviceHostId; readonly label?: string | undefined }) => + host.label === undefined ? `hostId ${host.id}` : `'${host.label}' (hostId ${host.id})`; + +const mapLocateErrors = ( + effect: Effect.Effect< + A, + DeviceService.DeviceHostAmbiguousError | DeviceService.DeviceOnOtherHostError, + R + >, +) => + effect.pipe( + Effect.catchTags({ + DeviceHostAmbiguousError: (error) => + Effect.fail( + new DeviceToolUnavailableError({ + reason: `Device ${error.deviceId} is on more than one host: ${error.hosts.map(hostName).join(", ")}. Pass hostId.`, + }), + ), + DeviceOnOtherHostError: (error) => + Effect.fail( + new DeviceToolUnavailableError({ + reason: `Device ${error.deviceId} is not on ${hostName(error.host)}. It is on ${error.hosts.map(hostName).join(", ")}; pass that hostId.`, + }), + ), + }), + ); + const pickDevice = Effect.fn("handlers.pickDevice")(function* ( - devices: ReadonlyArray, + state: { + readonly hosts: ReadonlyArray; + readonly devices: ReadonlyArray; + }, input: { readonly deviceId?: DeviceId | undefined; readonly platform?: DevicePlatform | undefined; readonly hostId?: DeviceHostId | undefined; }, -): Effect.fn.Return { - const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; +): Effect.fn.Return { if (input.deviceId !== undefined) { - const match = devices.find( - (device) => device.hostId === hostId && device.id === input.deviceId, - ); - if (match) return match; + const { deviceId, hostId } = input; + const devices = yield* DeviceService.DeviceService; + const located = yield* devices.locateDevice({ deviceId, hostId }).pipe(mapLocateErrors); + if (Option.isSome(located)) return located.value; return yield* new DeviceToolUnavailableError({ - reason: `No device ${input.deviceId} on host ${hostId}. Call device_list for current ids.`, + reason: `No device ${deviceId} on ${hostId === undefined ? "any host" : hostName(state.hosts.find((host) => host.id === hostId) ?? { id: hostId })}. Call device_list for current ids.`, }); } - const candidates = devices.filter( + const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; + const candidates = state.devices.filter( (device) => device.hostId === hostId && (input.platform === undefined || device.platform === input.platform), @@ -153,7 +192,7 @@ const handlers = { "Device support is off. Ask the user to enable it in the Device panel before installing or starting device tools.", }); } - const target = yield* pickDevice(state.devices, input); + const target = yield* pickDevice(state, input); // Resolve consent and agent connectivity before booting or registering a session. if (!(yield* devices.agentReadinessIfSupported(target.hostId))) { return yield* new DeviceToolUnavailableError({ @@ -177,42 +216,29 @@ const handlers = { ) ?? target; // Target the opened device: an EAS device and its agent-device daemon // exist only once its session runs, and Android ids change on boot. - const agent = yield* devices.agentTarget({ + const opened = { threadId: scope.thread.threadId, - hostId: device.hostId, - deviceId: device.id, - }); + hostId: session.hostId, + deviceId: session.deviceId, + }; + const agent = yield* devices.agentTarget(opened); const cloud = after.hosts.some((host) => host.id === device.hostId && host.kind === "eas"); const targetArgs = [ ...agentDeviceTargetArgs({ ...device, id: agent.agentDeviceId }), ...agent.args, ]; - const config = yield* ServerConfig.ServerConfig; - const path = yield* Path.Path; - const platform = yield* HostProcess.Platform; - const shimDir = yield* ensureAgentDeviceShim({ - entryPath: yield* devices.agentCli, - stateDir: config.stateDir, - }).pipe( - Effect.mapError( - (error) => - new DeviceToolUnavailableError({ - reason: - error._tag === "NodeRuntimeUnavailableError" - ? nodeRuntimeUnavailableMessage("Device automation") - : "Could not prepare the agent-device launcher.", - cause: error, - }), - ), - ); - const command = path.join( - shimDir, - platform === "win32" ? "agent-device.cmd" : "agent-device", - ); + const launcher = yield* devices.agentLauncher({ ...opened, targetArgs }); + if (Option.isNone(launcher)) { + return yield* new DeviceToolUnavailableError({ + reason: + "The device was closed while device_open was preparing it. Call device_open again only if you still need it.", + }); + } + const command = launcher.value; return { device, - agentDevice: { command, targetArgs }, - quickStart: agentDeviceQuickStart(device, targetArgs, command, cloud), + agentDevice: { command, targetArgs, session: agent.session }, + quickStart: agentDeviceQuickStart(device, [], command, cloud), }; }).pipe(Effect.mapError(toolError)), ), @@ -220,20 +246,23 @@ const handlers = { Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; - const sessions = yield* devices.sessionsForThread(scope.thread.threadId); - const hostId = - input.deviceId === undefined ? input.hostId : (input.hostId ?? LOCAL_DEVICE_HOST_ID); - const target = sessions.findLast( - (session) => - (hostId === undefined || session.hostId === hostId) && - (input.deviceId === undefined || session.deviceId === input.deviceId), - ); + const threadId = scope.thread.threadId; + const target = + input.deviceId === undefined + ? (yield* devices.sessionsForThread(threadId)).findLast( + (session) => input.hostId === undefined || session.hostId === input.hostId, + ) + : Option.getOrUndefined( + yield* devices + .locateSession({ threadId, deviceId: input.deviceId, hostId: input.hostId }) + .pipe(mapLocateErrors), + ); if (!target) { return yield* new DeviceToolUnavailableError({ reason: input.deviceId === undefined ? "No device is open in this thread. Call device_open first." - : `Device ${input.deviceId} on host ${hostId} is not open in this thread. Call device_open first.`, + : `Device ${input.deviceId} is not open in this thread. Call device_open first.`, }); } const shot = yield* devices.screenshot(target); @@ -251,10 +280,18 @@ const handlers = { Effect.gen(function* () { const scope = yield* requireDeviceAccess; const devices = yield* DeviceService.DeviceService; + const deviceId = input.deviceId; + const open = + deviceId === undefined + ? Option.none() + : yield* devices + .locateSession({ threadId: scope.thread.threadId, deviceId, hostId: input.hostId }) + .pipe(mapLocateErrors); + const hostId = Option.isSome(open) ? open.value.hostId : input.hostId; yield* devices.close({ threadId: scope.thread.threadId, - ...(input.hostId === undefined ? {} : { hostId: input.hostId }), - ...(input.deviceId === undefined ? {} : { deviceId: input.deviceId }), + ...(hostId === undefined ? {} : { hostId }), + ...(deviceId === undefined ? {} : { deviceId }), ...(input.shutdown === undefined ? {} : { shutdown: input.shutdown }), }); return {}; diff --git a/apps/server/src/mcp/toolkits/device/tools.ts b/apps/server/src/mcp/toolkits/device/tools.ts index f02f39b32462a..0a79af2efb0f6 100644 --- a/apps/server/src/mcp/toolkits/device/tools.ts +++ b/apps/server/src/mcp/toolkits/device/tools.ts @@ -29,7 +29,7 @@ const DeviceToolFailure = Schema.Union([DeviceToolError, OrchestratorMcpFailure] const DeviceListTool = Tool.make("device_list", { description: - "List iOS Simulators and Android Emulators here, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.", + "List iOS Simulators and Android Emulators on every device host, what each host can run, and what is open in this thread's Device panel. Call before `device_open` when you do not know a device id.", // An empty struct serializes as `anyOf [object, array]`, which some // providers reject and then drop every tool on the server with it. parameters: Schema.Struct({ @@ -49,7 +49,7 @@ const DeviceListTool = Tool.make("device_list", { const DeviceOpenTool = Tool.make("device_open", { description: - "Open a simulator or emulator for this thread. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.", + "Open a simulator or emulator for this thread. Pass `deviceId` and `hostId` from `device_list`. Boots it if needed and shows it in the user's Device panel. Returns the `agent-device` CLI call pinned to that device. Drive the device with that CLI.", parameters: DeviceToolOpenInput, success: DeviceToolOpenResult, failure: DeviceToolFailure, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9f2e601afa382..d7b3c756ecfd2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -79,6 +79,7 @@ import { attachmentRelativePath } from "../../attachmentStore.ts"; import { WorkflowAgentTranscriptReadObserver } from "../workflowAgentTranscript.ts"; import * as ServerConfig from "../../config.ts"; import * as McpProviderSession from "@t3tools/provider-core/server/mcpSession"; +import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH } from "@t3tools/provider-core/server/orchestrationInstructions"; import * as McpProviderSessions from "@t3tools/provider-core/server/McpProviderSessions"; import { PreviewControlsToolkit } from "../../mcp/toolkits/previewControls/tools.ts"; import { HtmlToolkit } from "../../mcp/toolkits/html/tools.ts"; @@ -603,6 +604,7 @@ describe("ClaudeAdapterV2 native protocol logging", () => { assert.equal(systemPrompt.preset, "claude_code"); assert.include(systemPrompt.append ?? "", "Use `delegate_task`"); assert.include(systemPrompt.append ?? "", "call `t3_background_wait`"); + assert.notInclude(systemPrompt.append ?? "", "## T3 Code devices"); const logged = ClaudeAdapterV2.loggedClaudeQueryOptions(options); assert.equal(logged.hasMcpServers, true); assert.notInclude(JSON.stringify(logged), "secret-claude-token"); @@ -622,6 +624,73 @@ describe("ClaudeAdapterV2 native protocol logging", () => { }); }); +describe("ClaudeAdapterV2 device instructions", () => { + const options = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) => + ClaudeAdapterV2.makeClaudeQueryOptions({ + modelSelection: { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-opus-5-5", + }, + nativeThreadId: "native-thread-claude-devices", + resume: false, + cwd: "/workspace", + ...(input.mcp + ? { mcpServers: { "t3-code": { type: "http" as const, url: "http://127.0.0.1/mcp" } } } + : {}), + deviceToolsAvailable: input.deviceToolsAvailable, + environment: {}, + }); + const append = (input: { readonly mcp: boolean; readonly deviceToolsAvailable: boolean }) => + (options(input).systemPrompt as { readonly append?: string }).append ?? ""; + + it("gives a session with device access the device block and the ToolSearch line to load it", () => { + const text = append({ mcp: true, deviceToolsAvailable: true }); + assert.include(text, T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH); + assert.include(text, "Use `delegate_task`"); + }); + + it("leaves the device block out without device access or without T3 tools", () => { + assert.notInclude(append({ mcp: true, deviceToolsAvailable: false }), "## T3 Code devices"); + assert.notInclude(append({ mcp: false, deviceToolsAvailable: true }), "## T3 Code devices"); + }); + + it("gives native subagents the device block only in a session with device access", async () => { + // Subagents start from their task prompt, not the parent's system prompt. + const subagentContext = (input: { + readonly mcp: boolean; + readonly deviceToolsAvailable: boolean; + }) => + Promise.all( + (options(input).hooks?.SubagentStart ?? []).flatMap((matcher) => + matcher.hooks.map((hook) => + hook( + { + hook_event_name: "SubagentStart", + agent_id: "agent-1", + agent_type: "general-purpose", + session_id: "native-thread-claude-devices", + transcript_path: "/transcript.jsonl", + cwd: "/workspace", + }, + undefined, + { signal: new AbortController().signal }, + ), + ), + ), + ); + assert.deepEqual(await subagentContext({ mcp: true, deviceToolsAvailable: true }), [ + { + hookSpecificOutput: { + hookEventName: "SubagentStart", + additionalContext: T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + }, + }, + ]); + assert.deepEqual(await subagentContext({ mcp: true, deviceToolsAvailable: false }), []); + assert.deepEqual(await subagentContext({ mcp: false, deviceToolsAvailable: true }), []); + }); +}); + describe("ClaudeAdapterV2 context usage", () => { it("dates cache expiry from the request start, not the response end", () => { const usage = ClaudeAdapterV2.claudeProviderTurnTokenUsage( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5c676d684f686..fb204153f27e8 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -29,6 +29,7 @@ import { type ForkSessionResult, getSessionMessages, getSubagentMessages, + type HookCallback, query, resolveSettings as resolveClaudeSettings, type Options as ClaudeQueryOptions, @@ -167,7 +168,10 @@ import { } from "../../provider/claudeUsageLimits.ts"; import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; -import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + T3_CODE_ORCHESTRATION_INSTRUCTIONS, +} from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import { mcpToolPresentation, @@ -1431,6 +1435,15 @@ const CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS = ` To wait for a background command or Monitor you started (tests, a build), call \`t3_background_wait\` with its task ID before you end your turn. Do not mark work you leave running, or PRs. T3 MCP tools are deferred: load one with ToolSearch before you call it. `; +// Native subagents start from their task, not this session's system prompt. +const subagentDeviceInstructions: HookCallback = () => + Promise.resolve({ + hookSpecificOutput: { + hookEventName: "SubagentStart", + additionalContext: T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + }, + }); + export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly nativeThreadId: string; @@ -1442,6 +1455,8 @@ export function makeClaudeQueryOptions(input: { readonly sdkSettings?: string | ClaudeSdkSettings; readonly environment?: NodeJS.ProcessEnv; readonly mcpServers?: ClaudeQueryOptions["mcpServers"]; + /** The thread's T3 MCP session holds the `device` capability. */ + readonly deviceToolsAvailable?: boolean; readonly tools?: ClaudeAgentSdkQueryTools; readonly allowedTools?: ReadonlyArray; readonly disallowedTools?: ReadonlyArray; @@ -1506,6 +1521,7 @@ export function makeClaudeQueryOptions(input: { ...compactQuerySettings, env: { ...compactQuerySettings?.env, ENABLE_TOOL_SEARCH: input.toolSearchOverride }, } as ClaudeSdkSettings); + const deviceInstructions = input.mcpServers !== undefined && input.deviceToolsAvailable === true; const options: ClaudeAgentSdkQueryOptions = { model: compiledSelection.apiModelId, tools: claudeAgentSdkQueryToolsForSdk(selectedTools), @@ -1558,10 +1574,17 @@ export function makeClaudeQueryOptions(input: { buildRuntimeInstructions({ harness: "Claude Code" }) + (input.mcpServers === undefined ? "" - : T3_CODE_ORCHESTRATION_INSTRUCTIONS + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS), + : T3_CODE_ORCHESTRATION_INSTRUCTIONS + + CLAUDE_BACKGROUND_WAIT_INSTRUCTIONS + + (deviceInstructions + ? `\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH}\n` + : "")), ...launchAppendSystemPrompt, ].join("\n\n"), }, + ...(deviceInstructions + ? { hooks: { SubagentStart: [{ hooks: [subagentDeviceInstructions] }] } } + : {}), ...(Object.keys(extraArgs).length === 0 ? {} : { extraArgs }), }; const additionalDirectories = [ @@ -9689,8 +9712,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId: string, ) { const queryPolicy = yield* queryPolicyForTurn(turnInput); + const mcpSession = yield* mcpSessions.read(turnInput.threadId); const mcpOverrides = claudeMcpQueryOverrides({ - mcpSession: yield* mcpSessions.read(turnInput.threadId), + mcpSession, readOnlySandbox: sandboxPolicyKindForClaudeRuntimePolicy(turnInput.runtimePolicy) === "readOnly", ...(queryPolicy.allowedTools === undefined @@ -9826,6 +9850,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ...(mcpOverrides.mcpServers === undefined ? {} : { mcpServers: mcpOverrides.mcpServers }), + deviceToolsAvailable: mcpSession?.capabilities?.has("device") === true, permissionMode: queryPolicy.permissionMode, ...(queryPolicy.allowDangerouslySkipPermissions === undefined ? {} diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 8398fca9cb6a5..9ae9ddce81a99 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -225,6 +225,7 @@ function makeThreadCreatedEvent(input: { readonly threadId: ThreadId; readonly now: DateTime.Utc; readonly projectId?: ProjectId; + readonly delegatedBy?: ThreadId; }) { return Effect.gen(function* () { const projectId = @@ -249,11 +250,14 @@ function makeThreadCreatedEvent(input: { branch: null, worktreePath: null, activeProviderThreadId: providerThreadId, - lineage: { - parentThreadId: null, - relationshipToParent: null, - rootThreadId: input.threadId, - }, + lineage: + input.delegatedBy === undefined + ? { parentThreadId: null, relationshipToParent: null, rootThreadId: input.threadId } + : { + parentThreadId: input.delegatedBy, + relationshipToParent: "subagent", + rootThreadId: input.delegatedBy, + }, forkedFrom: null, createdAt: input.now, updatedAt: input.now, @@ -633,6 +637,7 @@ function runBrowserAccessScenario(input: { readonly deviceOverride?: boolean; readonly createThread?: boolean; readonly projectExists?: boolean; + readonly delegatedBy?: ThreadId; }) { return Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -661,7 +666,15 @@ function runBrowserAccessScenario(input: { }); if (input.createThread !== false) { yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now, projectId })], + events: [ + yield* makeThreadCreatedEvent({ + idAllocator, + threadId, + now, + projectId, + ...(input.delegatedBy === undefined ? {} : { delegatedBy: input.delegatedBy }), + }), + ], }); } yield* manager @@ -3628,3 +3641,16 @@ it.effect( assert.isFalse(denied?.capabilities?.has("device")); }), ); + +// Device instructions reach delegated children only because their own sessions hold `device`. +it.effect("ProviderSessionManagerV2 gives a delegated child its project's device access", () => + Effect.gen(function* () { + const child = yield* runBrowserAccessScenario({ + enableAgentBrowserAccess: false, + projectOverride: false, + deviceOverride: true, + delegatedBy: ThreadId.make("thread-provider-session-manager-parent"), + }); + assert.isTrue(child?.capabilities?.has("device")); + }), +); diff --git a/apps/server/src/provider/CodexDeveloperInstructions.test.ts b/apps/server/src/provider/CodexDeveloperInstructions.test.ts index 92e242a7ad714..87473e8da2f11 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.test.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.test.ts @@ -1,5 +1,6 @@ import * as NodeAssert from "node:assert/strict"; +import { T3_CODE_DEVICE_TOOL_INSTRUCTIONS } from "@t3tools/provider-core/server/orchestrationInstructions"; import { describe, it } from "vite-plus/test"; import { @@ -76,6 +77,18 @@ describe("Codex orchestration context", () => { }); }); +describe("Codex device context", () => { + it("adds the shared device block only when the session holds device access", () => { + const tools = (device: boolean) => + buildCodexAdditionalContext({ model: "gpt-6.1-sol" }, { browser: true, device }).t3_code_tools + ?.value ?? ""; + + NodeAssert.ok(tools(true).includes(T3_CODE_DEVICE_TOOL_INSTRUCTIONS)); + NodeAssert.ok(!tools(true).includes("ToolSearch")); + NodeAssert.ok(!tools(false).includes("## T3 Code devices")); + }); +}); + function runtimeInstructions(runtime: Parameters[0]) { return buildCodexAdditionalContext(runtime).t3_code_runtime!.value; } diff --git a/apps/server/src/provider/CodexDeveloperInstructions.ts b/apps/server/src/provider/CodexDeveloperInstructions.ts index 3ec4c33a0a3f1..cbfd514eb11a6 100644 --- a/apps/server/src/provider/CodexDeveloperInstructions.ts +++ b/apps/server/src/provider/CodexDeveloperInstructions.ts @@ -3,16 +3,13 @@ import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeI import { T3_CODE_BROWSER_TOOL_INSTRUCTIONS, + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, T3_CODE_ORCHESTRATION_SECTIONS, } from "@t3tools/provider-core/server/orchestrationInstructions"; /** Codex silently truncates additionalContext values over 1,000 tokens, counted as UTF-8 bytes / 4. */ export const CODEX_ADDITIONAL_CONTEXT_VALUE_MAX_BYTES = 4_000; -const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = `## T3 Code devices - -For iOS Simulators and Android Emulators here, call \`device_list\`, then \`device_open\`. Drive the device with the exact \`agent-device\` launcher and flags it returns, on every command. Prefer \`agent-device snapshot -i\` refs over coordinates. Use \`device_screenshot\` to see the screen. Use \`xcrun simctl\` or \`adb\` only for builds, logs, and ports. If a platform is unavailable, say so.`; - export interface T3CodeToolAvailability { readonly browser: boolean; readonly device: boolean; diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index e67da4d775a89..d5496ea8cfc57 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5135, - "codexAdditionalContextBytes": 5687, - "mcpToolListJsonBytes": 45842 + "codexAdditionalContextBytes": 6865, + "mcpToolListJsonBytes": 45907 } diff --git a/docs/internals/devices.md b/docs/internals/devices.md index 0f5f1556d850b..c50cb504a84e7 100644 --- a/docs/internals/devices.md +++ b/docs/internals/devices.md @@ -93,9 +93,24 @@ already-running agent without the CLI. How to drive a device is returned from `device_open`, not kept in an always-loaded prompt or skill: it costs nothing in threads that never open a -device and cannot drift from the pinned CLI version. The always-on prompt block -is a few lines that point at the tools and prefer them over raw `simctl` and -`adb`, which stay available for what the tools do not cover. +device and cannot drift from the pinned CLI version. `device_open` returns a +launcher bound to its agent-device session, so agents need no target flags. +The launcher leaves out the flags a command refuses (`cdp` talks to Metro, not +to the device), refuses the commands that would replace T3's connection, and +checks that connection first: agent-device reports a sandboxed shell and a +dropped tunnel alike as an unreachable daemon, so the launcher names which one it +is. Ending a session (`device_close`, the Device panel, a shutdown) also closes +its agent-device session, which quits the app that session opened and releases the CLI's claim on the device. + +Agents choose between these tools and ssh before they open anything, so +sessions that hold `device` get a short block from +[`orchestrationInstructions.ts`](../../packages/provider-core/src/server/orchestrationInstructions.ts) +through their adapter's instruction channel. It points simulator and emulator UI +work on local and SSH hosts at the tools; `simctl`, `adb` and ssh stay +available for what the tools do not cover. Pi and Muse do not send it yet. +Claude's native subagents never see the session's system prompt, so a +`SubagentStart` hook passes them the block, where the runtime runs hooks and the +subagent's context is not isolated. ## The viewer decodes both vendored protocols diff --git a/evals/mcp-tool-use/run.test.ts b/evals/mcp-tool-use/run.test.ts index b06f46d373e0f..33edb6465ab4c 100644 --- a/evals/mcp-tool-use/run.test.ts +++ b/evals/mcp-tool-use/run.test.ts @@ -248,6 +248,8 @@ it("rebuilds the template from this checkout and sends a child T3's task prompt" "You are Claude Code.", `Main prompt.\n - Primary working directory: /home/user/app\n\n${t3SystemAppend("claude-sonnet-5-5")}`, ]); + // The eval offers the device tools, so Claude's append carries the device block. + assert.include(t3SystemAppend("claude-sonnet-5-5"), "## T3 Code devices"); assert.equal(base.environment.cwd, "/home/user/app"); assert.include(system[2], "model named Sonnet 5.5. The exact model ID is claude-sonnet-5-5."); const deferred = system[2]?.split("Load them first:\n")[1]?.split("\n\n")[0]?.split("\n") ?? []; diff --git a/packages/contracts/src/device.test.ts b/packages/contracts/src/device.test.ts index bfce0a9031d9b..6de9f15125d45 100644 --- a/packages/contracts/src/device.test.ts +++ b/packages/contracts/src/device.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { Schema } from "effect"; import { + DeviceBootError, DeviceServiceState, EasDeviceHostConfig, EasDeviceHostConfigs, @@ -79,3 +80,28 @@ describe("device state for clients from before EAS hosts", () => { expect(decodeState(wire).hosts).toEqual(state.hosts); }); }); + +describe("device boot errors", () => { + it("gives a next step instead of blaming the environment server when the host sent no details", () => { + const message = new DeviceBootError({ + hostId: "mac", + deviceId: "Pixel_10", + reason: "launch_failed", + cause: null, + }).message; + expect(message).not.toContain("environment server"); + expect(message).toContain("Try again or open another device"); + }); + + it("keeps the host's diagnostics when it sent them", () => { + const message = new DeviceBootError({ + hostId: "mac", + deviceId: "Pixel_10", + reason: "launch_failed", + diagnostics: "emulator: PANIC: Missing emulator engine program for 'x86_64' CPU.", + cause: null, + }).message; + expect(message).toContain("PANIC: Missing emulator engine program"); + expect(message).not.toContain("Try again"); + }); +}); diff --git a/packages/contracts/src/device.ts b/packages/contracts/src/device.ts index 0fc7fde4c3bd4..dbb90f6d41e58 100644 --- a/packages/contracts/src/device.ts +++ b/packages/contracts/src/device.ts @@ -492,13 +492,14 @@ export class DeviceBootError extends Schema.TaggedError()("Devi }) { override get message(): string { const explanation = { - disk_space: "There is not enough free disk space on the environment server.", + disk_space: "There is not enough free disk space on its device host.", timeout: "The device did not become ready in time.", - launch_failed: - "The simulator or emulator could not start. Check its configuration on the environment server.", + launch_failed: "The simulator or emulator could not start.", }[this.reason]; const summary = `Device ${this.deviceId} failed to boot: ${explanation}`; - return this.diagnostics ? `${summary}\n\n${this.diagnostics}` : summary; + return this.diagnostics + ? `${summary}\n\n${this.diagnostics}` + : `${summary} The device host sent no details. Try again or open another device; if it keeps failing, check the device on that host.`; } } @@ -584,6 +585,7 @@ export const DeviceToolOpenResult = Schema.Struct({ agentDevice: Schema.Struct({ command: Schema.String, targetArgs: Schema.Array(Schema.String), + session: Schema.String, }), quickStart: Schema.String, }); diff --git a/packages/provider-acp/src/server/adapter.ts b/packages/provider-acp/src/server/adapter.ts index 7e7f43fa632cc..b88e31d203948 100644 --- a/packages/provider-acp/src/server/adapter.ts +++ b/packages/provider-acp/src/server/adapter.ts @@ -7171,9 +7171,11 @@ export const makeAcpAdapterV2 = Effect.fn("makeAcpAdapterV2")(function* ( sessionId: string, ) { const prompt: Array = []; + const mcpSession = yield* mcpSessions.read(turnInput.threadId); const instructionState = { interactionMode: turnInput.runtimePolicy.interactionMode, - hasT3Mcp: (yield* readMcpContext(turnInput.threadId)).servers.length > 0, + hasT3Mcp: acpMcpContext(mcpSession, self).servers.length > 0, + hasDeviceTools: mcpSession?.capabilities?.has("device") === true, } satisfies T3AcpInstructionState; const previousInstructionState = (yield* Ref.get(promptInstructionStates)).get(sessionId); const compactionMarker = (yield* Ref.get(compactionMarkers)).get(sessionId); diff --git a/packages/provider-core/src/server/orchestrationInstructions.test.ts b/packages/provider-core/src/server/orchestrationInstructions.test.ts new file mode 100644 index 0000000000000..c8dd21cc02634 --- /dev/null +++ b/packages/provider-core/src/server/orchestrationInstructions.test.ts @@ -0,0 +1,85 @@ +import { assert, describe, it } from "@effect/vitest"; + +import { + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, + T3_CODE_ORCHESTRATION_INSTRUCTIONS, + t3AcpPromptWithInstructions, + t3OrchestrationPrompt, + t3OrchestrationSystemPrompt, +} from "./orchestrationInstructions.ts"; + +describe("T3 device instructions", () => { + it("route device work through device_open and give Claude the line that loads the tools", () => { + for (const intent of ["`device_list`", "`device_open`", "`agent-device`", "ssh", "`hostId`"]) { + assert.include(T3_CODE_DEVICE_TOOL_INSTRUCTIONS, intent); + } + assert.notInclude(T3_CODE_DEVICE_TOOL_INSTRUCTIONS, "ToolSearch"); + const loadLine = + " Load them with ToolSearch `select:mcp__t3-code__device_list,mcp__t3-code__device_open,mcp__t3-code__device_screenshot,mcp__t3-code__device_close`."; + assert.include(T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH, loadLine); + assert.equal( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH.replace(loadLine, ""), + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + ); + }); + + it("open the app before the returned command's snapshots, screenshots and input", () => { + assert.include( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + "its snapshots, screenshots and input need `open ` first,", + ); + }); + + it("keep the failure rule from taking over or closing a session another thread holds", () => { + // agent-device's DEVICE_IN_USE advice is to rerun with the owner's --session or close it first. + assert.include( + T3_CODE_DEVICE_TOOL_INSTRUCTIONS, + "follow its advice once, but never reuse or close another session; if it still fails,", + ); + }); + + it("reach OpenCode and Cursor only when the session holds device access", () => { + assert.include(t3OrchestrationSystemPrompt(true, true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(t3OrchestrationSystemPrompt(true, false), "## T3 Code devices"); + assert.isUndefined(t3OrchestrationSystemPrompt(false, true)); + + const firstRun = (hasDeviceTools: boolean) => + t3OrchestrationPrompt({ + prompt: "Check the login screen.", + hasT3Mcp: true, + hasDeviceTools, + orchestrationDelivered: false, + deviceInstructionsDelivered: false, + }); + assert.include(firstRun(true), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(firstRun(false), "## T3 Code devices"); + + const laterRun = (deviceInstructionsDelivered: boolean) => + t3OrchestrationPrompt({ + prompt: "Check the login screen.", + hasT3Mcp: true, + hasDeviceTools: true, + orchestrationDelivered: true, + deviceInstructionsDelivered, + }); + assert.include(laterRun(false), T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.notInclude(laterRun(false), T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()); + assert.equal(laterRun(true), "Check the login screen."); + }); + + it("reach ACP agents again when device access changes mid-thread", () => { + const state = { interactionMode: "default", hasT3Mcp: true, hasDeviceTools: false } as const; + const withDevices = t3AcpPromptWithInstructions({ + prompt: "Check the login screen.", + state: { ...state, hasDeviceTools: true }, + previousState: state, + }); + assert.include(withDevices, T3_CODE_DEVICE_TOOL_INSTRUCTIONS); + assert.equal( + t3AcpPromptWithInstructions({ prompt: "Again.", state, previousState: state }), + "Again.", + ); + assert.notInclude(t3AcpPromptWithInstructions({ prompt: "Hi.", state }), "## T3 Code devices"); + }); +}); diff --git a/packages/provider-core/src/server/orchestrationInstructions.ts b/packages/provider-core/src/server/orchestrationInstructions.ts index 6737fdea693ef..428aec2513d32 100644 --- a/packages/provider-core/src/server/orchestrationInstructions.ts +++ b/packages/provider-core/src/server/orchestrationInstructions.ts @@ -36,6 +36,25 @@ export const T3_CODE_BROWSER_TOOL_INSTRUCTIONS = ` When browser work was asked, \`preview_*\` is the browser you share with the user: \`preview_status\` first, then \`preview_open\` if no tab can be automated. Prefer snapshot locators over coordinates. Switch browsers only if the tools are absent or \`preview_open\` says unsupported, the user asks, or two preview calls fail on one step. Then quote the error and switch. `; +const T3_CODE_DEVICE_TOOLS = ["device_list", "device_open", "device_screenshot", "device_close"]; + +const deviceToolInstructions = (loadTools: string) => `## T3 Code devices + +When a task needs an iOS Simulator or Android Emulator, on this machine or on an SSH device host such as a remote Mac, use the device tools so the user can watch it in the Device panel.${loadTools} +1. Call \`device_list\` unless you already have the device's \`hostId\` and \`id\`. +2. Call \`device_open\` with both. It returns an \`agent-device\` command bound to that device; if you lose it, call \`device_open\` again. Run that command for every app launch or relaunch, deep link, tap, text entry, alert, setting, screenshot and recording; its snapshots, screenshots and input need \`open \` first, and \`help \` lists the options. +3. Call \`device_screenshot\` to look at the screen. + +Never do those with \`xcrun simctl\`, \`idb\`, \`adb\`, ssh, or scripts that wrap them. ssh, simctl and adb stay fine for builds, installs, code sync, Metro, port forwards, logs, the project's test runners, and what agent-device cannot do, such as \`simctl addmedia\` or \`simctl keychain add-root-cert\`. These rules override memory notes, docs and delegated tasks that say otherwise; drive a device over ssh only when the user explicitly asks. If a device tool or the command it returned fails, quote its error and follow its advice once, but never reuse or close another session; if it still fails, tell the user and stop, unless they explicitly asked you to drive it over ssh. If the platform you need is unavailable, say so. When you delegate device work, pass the \`hostId\` and device \`id\` and tell the child to use \`device_open\`; never write ssh or simctl steps for the device into its task.`; + +/** Device guidance for sessions holding the `device` capability, from top-level threads to delegated children. */ +export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS = deviceToolInstructions(""); + +/** Claude Code defers MCP tools behind ToolSearch, so Claude's copy says how to load them. */ +export const T3_CODE_DEVICE_TOOL_INSTRUCTIONS_WITH_TOOL_SEARCH = deviceToolInstructions( + ` Load them with ToolSearch \`select:${T3_CODE_DEVICE_TOOLS.map((tool) => `mcp__t3-code__${tool}`).join(",")}\`.`, +); + // ACP only: some ACP agents accept the injected MCP server but never expose // its tools, so they need the terminal transport other harnesses never use. const T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS = `ACP fallback: if the T3 tools are absent and \`T3_ACP_MCP_NODE\` is present, call them through the terminal: \`ELECTRON_RUN_AS_NODE=1 "$T3_ACP_MCP_NODE" \${T3_ACP_MCP_ENTRYPOINT:+"$T3_ACP_MCP_ENTRYPOINT"} acp-mcp-call orchestrator_capabilities '{}'\` (\`T3_ACP_MCP_ENTRYPOINT\` is unset for a standalone executable). Delegate with \`acp-mcp-call delegate_task '{"task":"...","target":{"providerInstanceId":"...","model":"..."},"mode":"async","clientRequestId":"..."}'\`.`; @@ -51,6 +70,7 @@ Investigate with read-only actions and do not edit files or otherwise execute th export interface T3AcpInstructionState { readonly interactionMode: ProviderInteractionMode; readonly hasT3Mcp: boolean; + readonly hasDeviceTools: boolean; } /** ACP lacks system/developer fields; inject context on first prompt and tool/mode changes. */ @@ -63,7 +83,8 @@ export function t3AcpPromptWithInstructions(input: { if (input.prompt.trimStart().startsWith("/")) return input.prompt; if ( input.previousState?.interactionMode === input.state.interactionMode && - input.previousState.hasT3Mcp === input.state.hasT3Mcp + input.previousState.hasT3Mcp === input.state.hasT3Mcp && + input.previousState.hasDeviceTools === input.state.hasDeviceTools ) { return input.prompt; } @@ -74,6 +95,7 @@ export function t3AcpPromptWithInstructions(input: { ...(input.state.hasT3Mcp ? [ T3_CODE_BROWSER_TOOL_INSTRUCTIONS.trim(), + ...(input.state.hasDeviceTools ? [T3_CODE_DEVICE_TOOL_INSTRUCTIONS] : []), T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim(), T3_CODE_ACP_MCP_FALLBACK_INSTRUCTIONS, ] @@ -82,20 +104,38 @@ export function t3AcpPromptWithInstructions(input: { return `\n${instructions.join("\n\n")}\n\n\n\n${input.prompt}\n`; } -function prependT3OrchestrationInstructions(prompt: string): string { - return `${T3_CODE_ORCHESTRATION_INSTRUCTIONS.trim()}\n\n\n${prompt}\n`; +const orchestrationInstructions = (hasDeviceTools: boolean) => + hasDeviceTools + ? `${T3_CODE_ORCHESTRATION_INSTRUCTIONS}\n${T3_CODE_DEVICE_TOOL_INSTRUCTIONS}\n` + : T3_CODE_ORCHESTRATION_INSTRUCTIONS; + +function prependT3OrchestrationInstructions(prompt: string, instructions: string): string { + return `${instructions.trim()}\n\n\n${prompt}\n`; } -export function t3OrchestrationPromptForFirstRun(input: { +/** For providers whose instructions ride on the prompt, so a compacted conversation loses them. */ +export function t3OrchestrationPrompt(input: { readonly prompt: string; - readonly runOrdinal: number; readonly hasT3Mcp: boolean; + readonly hasDeviceTools: boolean; + readonly orchestrationDelivered: boolean; + readonly deviceInstructionsDelivered: boolean; }): string { - return input.runOrdinal === 1 && input.hasT3Mcp - ? prependT3OrchestrationInstructions(input.prompt) + if (!input.hasT3Mcp) return input.prompt; + if (!input.orchestrationDelivered) { + return prependT3OrchestrationInstructions( + input.prompt, + orchestrationInstructions(input.hasDeviceTools), + ); + } + return input.hasDeviceTools && !input.deviceInstructionsDelivered + ? prependT3OrchestrationInstructions(input.prompt, T3_CODE_DEVICE_TOOL_INSTRUCTIONS) : input.prompt; } -export function t3OrchestrationSystemPrompt(hasT3Mcp: boolean): string | undefined { - return hasT3Mcp ? T3_CODE_ORCHESTRATION_INSTRUCTIONS : undefined; +export function t3OrchestrationSystemPrompt( + hasT3Mcp: boolean, + hasDeviceTools: boolean, +): string | undefined { + return hasT3Mcp ? orchestrationInstructions(hasDeviceTools) : undefined; } diff --git a/packages/provider-cursor/src/server/adapter.test.ts b/packages/provider-cursor/src/server/adapter.test.ts index 7ab3aa8c1a3be..5e7093b67ae21 100644 --- a/packages/provider-cursor/src/server/adapter.test.ts +++ b/packages/provider-cursor/src/server/adapter.test.ts @@ -586,6 +586,158 @@ describe("CursorAdapterV2", () => { ), ); + it.effect("sends T3 instructions to each Cursor conversation that lacks them", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspace = yield* fileSystem.makeTempDirectoryScoped({ prefix: "cursor-v2-device-" }); + const sentMessages: Array = []; + const instanceId = ProviderInstanceId.make("cursor"); + const threadId = ThreadId.make("cursor-device-thread"); + const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const setDeviceAccess = (device: boolean) => + mcpSessions.set({ + environmentId: EnvironmentId.make("cursor-device-environment"), + threadId, + providerSessionId: "cursor-device-session", + providerInstanceId: instanceId, + endpoint: "http://127.0.0.1:43123/mcp", + authorizationHeader: "Bearer synthetic-cursor-token", + browserToolsAvailable: true, + capabilities: new Set(device ? ["device"] : []), + }); + const modelSelection = { instanceId, model: "composer-2.5" }; + const runtimePolicy = ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd: workspace, + }); + const adapter = yield* makeCursorAdapterV2({ + instanceId, + environment: { HOME: workspace }, + }).pipe( + Effect.provideService(CursorAgentSdk.CursorAgentSdkRunner, { + assertComplete: Effect.void, + open: () => + Effect.succeed({ + agentId: "native-cursor-device", + listMessages: Effect.succeed([]), + close: Effect.void, + send: (input) => + Effect.sync(() => { + sentMessages.push( + typeof input.message === "string" ? input.message : input.message.text, + ); + const runId = `native-cursor-run-${sentMessages.length}`; + return { + agentId: "native-cursor-device", + runId, + wait: Effect.succeed({ + id: runId, + requestId: `native-request-${sentMessages.length}`, + status: "finished" as const, + model: { id: "composer-2.5" }, + durationMs: 1, + }), + cancel: Effect.void, + }; + }), + }), + }), + Effect.provide(TestProviderHost.layer({ cwd: workspace })), + ); + const runtime = yield* adapter.openSession({ + threadId, + providerSessionId: ProviderSessionId.make("cursor-device-session"), + modelSelection, + runtimePolicy, + }); + const providerThread = yield* runtime.ensureThread({ + threadId, + modelSelection, + runtimePolicy, + }); + const now = yield* DateTime.now; + const appThread = { + id: threadId, + projectId: ProjectId.make("cursor-device-project"), + createdBy: "user" as const, + creationSource: "web" as const, + title: "Cursor devices", + providerInstanceId: instanceId, + modelSelection, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + branch: null, + worktreePath: null, + activeProviderThreadId: providerThread.id, + lineage: { parentThreadId: null, relationshipToParent: null, rootThreadId: threadId }, + forkedFrom: null, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + lastVisitedAt: null, + deletedAt: null, + }; + // Starts at run 5: a conversation from before device instructions existed, now resumed. + let runOrdinal = 4; + const send = Effect.fnUntraced(function* (compact = false) { + runOrdinal += 1; + const turnInput = { + threadId, + providerThread, + modelSelection, + runtimePolicy, + runId: RunId.make(`cursor-device-run-${runOrdinal}`), + runOrdinal, + providerTurnOrdinal: runOrdinal, + attemptId: RunAttemptId.make(`cursor-device-attempt-${runOrdinal}`), + rootNodeId: NodeId.make(`cursor-device-root-${runOrdinal}`), + appThread, + message: { + messageId: MessageId.make(`cursor-device-message-${runOrdinal}`), + createdBy: "user" as const, + creationSource: "web" as const, + text: "Open the app.", + attachments: [], + }, + }; + yield* compact ? runtime.compactThread!(turnInput) : runtime.startTurn(turnInput); + yield* runtime.events.pipe( + Stream.filter((event) => event.type === "turn.terminal"), + Stream.runHead, + ); + const sent = sentMessages.at(-1)!; + return { + orchestration: sent.includes("## T3 Code orchestration"), + devices: sent.includes("## T3 Code devices"), + }; + }); + const told = (orchestration: boolean, devices: boolean) => ({ orchestration, devices }); + + yield* setDeviceAccess(true); + assert.deepEqual(yield* send(), told(false, true)); + assert.deepEqual(yield* send(), told(false, false)); + yield* send(true); + assert.deepEqual( + yield* send(), + told(true, true), + "compaction drops what the conversation was told", + ); + yield* setDeviceAccess(false); + assert.deepEqual(yield* send(), told(false, false)); + yield* setDeviceAccess(true); + assert.deepEqual(yield* send(), told(false, true), "device access turned on later"); + assert.deepEqual(yield* send(), told(false, false)); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(NodeServices.layer, IdAllocator.layer, McpProviderSessions.layer), + ), + ), + ); + it.effect("projects Cursor directory trees and lint diagnostics as file search results", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/packages/provider-cursor/src/server/adapter.ts b/packages/provider-cursor/src/server/adapter.ts index 915c646be3001..1d98c4523ffef 100644 --- a/packages/provider-cursor/src/server/adapter.ts +++ b/packages/provider-cursor/src/server/adapter.ts @@ -52,7 +52,7 @@ import { hasCursorSkillMention, rewriteCursorSkillMentions, } from "./skills.ts"; -import { t3OrchestrationPromptForFirstRun } from "@t3tools/provider-core/server/orchestrationInstructions"; +import { t3OrchestrationPrompt } from "@t3tools/provider-core/server/orchestrationInstructions"; import { buildRuntimeInstructions } from "@t3tools/provider-core/server/runtimeInstructions"; import * as IdAllocator from "@t3tools/provider-core/server/IdAllocator"; import { makeProviderFailure } from "@t3tools/provider-core/server/failure"; @@ -904,6 +904,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }); const events = yield* Queue.unbounded(); const liveAgent = yield* Ref.make(null); + // What the native conversation was told since it started or was last compacted. + const told = yield* Ref.make<{ + readonly agent: string; + readonly orchestration: boolean; + readonly device: boolean; + } | null>(null); const warnedUnsupportedSandbox = yield* Ref.make(false); const activeTurn = yield* Ref.make(null); const planIds = yield* Ref.make(new Map()); @@ -2181,6 +2187,8 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( const resolveUserMessage = Effect.fnUntraced(function* ( turnInput: ProviderAdapter.ProviderAdapterV2TurnInput, mcpServers: Record | undefined, + hasDeviceTools: boolean, + delivered: { readonly orchestration: boolean; readonly device: boolean }, ) { const rawText = turnInput.message.text; if (rawText.trim() === "/compress" && turnInput.message.attachments.length === 0) { @@ -2200,7 +2208,7 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( .map((skill) => skill.name), ); } - const userText = t3OrchestrationPromptForFirstRun({ + const userText = t3OrchestrationPrompt({ prompt: yield* providerMessageTextWithAttachmentPaths({ text: cursorSkillNames === undefined @@ -2210,8 +2218,10 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( resolveAttachmentPath: host.resolveAttachmentPath, resolveExtractedTextPath: host.resolveExtractedTextPath, }), - runOrdinal: turnInput.runOrdinal, hasT3Mcp: mcpServers !== undefined, + hasDeviceTools, + orchestrationDelivered: delivered.orchestration, + deviceInstructionsDelivered: delivered.device, }); const images = yield* Effect.forEach( turnInput.message.attachments.filter(isProviderNativeImageAttachment), @@ -2275,8 +2285,22 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( modelSelection: turnInput.modelSelection, runtimePolicy: turnInput.runtimePolicy, }); - const mcpServers = cursorMcpServers(yield* mcpSessions.read(turnInput.threadId)); - const message = yield* resolveUserMessage(turnInput, mcpServers); + const mcpSession = yield* mcpSessions.read(turnInput.threadId); + const mcpServers = cursorMcpServers(mcpSession); + const hasDeviceTools = + mcpServers !== undefined && mcpSession?.capabilities?.has("device") === true; + const known = yield* Ref.get(told); + // A conversation this process has not sent to was told on its first run. + const delivered = + known?.agent === agent.nativeThreadId + ? known + : { orchestration: turnInput.runOrdinal > 1, device: false }; + const message = yield* resolveUserMessage( + turnInput, + mcpServers, + hasDeviceTools, + delivered, + ); const pendingUpdates: Array = []; let context: ActiveCursorTurn | null = null; // MCP send overrides replace the ambient servers loaded when the agent opens. @@ -2297,6 +2321,12 @@ export const makeCursorAdapterV2 = Effect.fn("makeCursorAdapterV2")(function* ( }, }) .pipe(Effect.mapError(classifyCursorModelRejection(turnInput.modelSelection))); + const compacted = message === "/compress"; + yield* Ref.set(told, { + agent: agent.nativeThreadId, + orchestration: !compacted && (delivered.orchestration || mcpServers !== undefined), + device: !compacted && hasDeviceTools, + }); const startedAt = yield* DateTime.now; const completed = yield* Deferred.make(); const providerTurnId = idAllocator.derive.providerTurn({ diff --git a/packages/provider-opencode/src/server/adapter.ts b/packages/provider-opencode/src/server/adapter.ts index bad3e8cd7b89a..ef85a7e70b29b 100644 --- a/packages/provider-opencode/src/server/adapter.ts +++ b/packages/provider-opencode/src/server/adapter.ts @@ -948,7 +948,10 @@ export const makeOpenCodeAdapterV2 = Effect.fn("makeOpenCodeAdapterV2")(function const mcpSession = yield* mcpSessions.read(input.threadId); const hasT3Mcp = mcpSession !== undefined && !connection.external; - const orchestrationSystemPrompt = t3OrchestrationSystemPrompt(hasT3Mcp); + const orchestrationSystemPrompt = t3OrchestrationSystemPrompt( + hasT3Mcp, + mcpSession?.capabilities?.has("device") === true, + ); if (hasT3Mcp) { yield* OpenCodeRuntime.runOpenCodeSdk("mcp.add", () => client.mcp.add({ diff --git a/packages/provider-opencode/src/server/v2/adapter.ts b/packages/provider-opencode/src/server/v2/adapter.ts index 9c4ec57c34776..1f2dd94ca2112 100644 --- a/packages/provider-opencode/src/server/v2/adapter.ts +++ b/packages/provider-opencode/src/server/v2/adapter.ts @@ -3051,7 +3051,7 @@ export const make = Effect.fn("OpenCode2Adapter.make")(function* (instanceId: Pr } const instructions = [ buildRuntimeInstructions({ harness: "OpenCode", model: turnInput.modelSelection.model }), - t3OrchestrationSystemPrompt(mcpAvailable), + t3OrchestrationSystemPrompt(mcpAvailable, mcpSession?.capabilities?.has("device") === true), ] .filter((part) => part !== undefined && part.length > 0) .join("\n\n");