diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index a34dbc534f59a..f2e0bcdf0c725 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -23,6 +23,7 @@ import * as FileSystem from "effect/FileSystem"; import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; +import * as PlatformError from "effect/PlatformError"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -42,6 +43,7 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.ts"; import * as SshDeviceHost from "./SshDeviceHost.ts"; import { agentDeviceSession, releaseAgentDeviceSession } from "./AgentDeviceTarget.ts"; +import { ensureDeviceHub } from "./DeviceToolchain.ts"; const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown)); const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); @@ -380,6 +382,45 @@ describe("device setup consent", () => { }).pipe(Effect.scoped), ); + it.effect("names the missing npm in the host status when device support cannot install", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-device-no-npm-" }); + const installError = yield* ensureDeviceHub(baseDir).pipe( + Effect.provideService(ProcessRunner.ProcessRunner, { + run: (input) => + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: input.command, + argumentCount: input.args.length, + cause: PlatformError.systemError({ + _tag: "NotFound", + module: "ChildProcess", + method: "spawn", + description: `spawn ${input.command} ENOENT`, + }), + }), + ), + }), + Effect.flip, + ); + const { service, settings } = yield* fixture( + Effect.void, + undefined, + false, + new DeviceHost.DeviceHostError({ + hostId: LOCAL_DEVICE_HOST_ID, + step: "installing device support", + cause: installError, + }), + ); + yield* Ref.update(settings, (current) => ({ ...current, enableDeviceSupport: true })); + const detail = (yield* service.list).hostStatuses[LOCAL_DEVICE_HOST_ID]?.detail; + expect(detail).toMatch(/\bnpm\b/); + expect(detail).toMatch(/Node\.js/); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + it.effect.each([ { name: "a Windows host", diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index 790daabe7a445..b365a6b9fe5c1 100644 --- a/apps/server/src/device/DeviceService.ts +++ b/apps/server/src/device/DeviceService.ts @@ -36,7 +36,11 @@ import { SshCommandError } from "@t3tools/ssh/errors"; 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 { + DeviceToolchainInstallError, + ensureAgentDevice, + ensureDeviceHub, +} from "./DeviceToolchain.ts"; import { agentDeviceCloseOutcome, bindAgentDeviceLauncher, @@ -108,6 +112,15 @@ const sshProbeFailureMessage = (error: DeviceHost.DeviceHostError): string => { return `Connected over SSH, but the device host check failed (exit code ${cause.exitCode}).`; }; +const isToolchainInstallError = Schema.is(DeviceToolchainInstallError); + +const actionableHostFailure = (error: DeviceHost.DeviceHostError): string | undefined => { + if (error.step === "probe") return sshProbeFailureMessage(error); + if (isToolchainInstallError(error.cause) && error.cause.reason === "npm_not_found") + return error.cause.message; + return undefined; +}; + export interface DeviceScreenshot { readonly device: DeviceSummary; readonly png: Uint8Array; @@ -450,9 +463,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* setHostStatus(host.id, { status: "failed", detail: - error._tag === "DeviceHostError" && error.step === "probe" - ? sshProbeFailureMessage(error) - : error.message, + (error._tag === "DeviceHostError" && actionableHostFailure(error)) || error.message, }), ), Effect.mapError( @@ -463,9 +474,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* reason: error._tag === "NodeRuntimeUnavailableError" ? nodeRuntimeUnavailableMessage("Local device support") - : error.step === "probe" - ? sshProbeFailureMessage(error) - : `Device support failed during ${error.step}.`, + : (actionableHostFailure(error) ?? + `Device support failed during ${error.step}.`), cause: error, }), ), @@ -513,9 +523,7 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* setHostStatus(host.id, { status: "failed", detail: - error._tag === "DeviceHostError" && error.step === "probe" - ? sshProbeFailureMessage(error) - : error.message, + (error._tag === "DeviceHostError" && actionableHostFailure(error)) || error.message, }), ), Effect.mapError( @@ -530,9 +538,8 @@ export const makeWithHosts = Effect.fn("DeviceService.makeWithHosts")(function* ? `Agent tools did not start within ${error.timeoutMs} ms.` : error._tag === "AgentDeviceStartError" ? error.message - : error.step === "probe" - ? sshProbeFailureMessage(error) - : `Device support failed during ${error.step}.`, + : (actionableHostFailure(error) ?? + `Device support failed during ${error.step}.`), cause: error, }), ), diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index eb299f9e29cdd..7b041a54502c9 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -37,10 +37,13 @@ export class DeviceToolchainInstallError extends Schema.TaggedError + error._tag === "ProcessSpawnError" && + error.cause instanceof PlatformError.PlatformError && + error.cause.reason._tag === "NotFound"; + const installTool = Effect.fn("DeviceToolchain.installTool")(function* ( spec: ToolSpec, paths: DeviceToolPaths, @@ -130,8 +138,7 @@ const installTool = Effect.fn("DeviceToolchain.installTool")(function* ( .pipe( Effect.catchTags({ ProcessSpawnError: (error) => - error.cause instanceof PlatformError.PlatformError && - error.cause.reason._tag === "NotFound" + isCommandNotFound(error) ? runner.run({ command: "pnpm", args: ["--package=npm@11", "dlx", "npm", ...installArgs], @@ -139,7 +146,16 @@ const installTool = Effect.fn("DeviceToolchain.installTool")(function* ( }) : Effect.fail(error), }), - Effect.mapError(fail("running npm install")), + Effect.mapError((cause) => + isCommandNotFound(cause) + ? new DeviceToolchainInstallError({ + tool: spec.name, + step: "running npm install", + reason: "npm_not_found", + cause, + }) + : fail("running npm install")(cause), + ), ); if (result.code !== 0) { return yield* new DeviceToolchainInstallError({ diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 0dbd6cbe1c177..9af31f1f30212 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -285,6 +285,58 @@ it.effect("rejects unavailable agent access before booting or opening a device", ); }); +it.effect("device_open reports a failed host's setup error instead of no devices", () => { + const failure = "Device support failed: npm was not found. Install Node.js, which includes npm."; + const failedState = { + ...state, + hostStatus: "failed" as const, + hostStatusDetail: failure, + hostStatuses: { local: { status: "failed" as const, detail: failure } }, + devices: [], + }; + const layerFailed = Layer.mock(DeviceService.DeviceService)({ + state: Effect.succeed(failedState), + list: Effect.succeed(failedState), + locateDevice: () => Effect.succeedNone, + open: () => Effect.die("Must not open a device on a host whose setup failed"), + }); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + for (const args of [ + { platform: "android" }, + { deviceId: "Pixel_API_35", hostId: "local" }, + { deviceId: "Pixel_API_35" }, + ]) { + const result = yield* server + .callTool({ name: "device_open", arguments: args }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, invocation(["device"])), + Effect.provideService(McpSchema.McpServerClient, client), + ); + const text = result.content + .flatMap((entry) => (entry.type === "text" ? [entry.text] : [])) + .join("\n"); + expect(result.isError).toBe(true); + expect(text).not.toContain("No android devices were found"); + expect(text).toContain(failure); + } + }).pipe( + Effect.scoped, + Effect.provide( + McpHttpServer.layerDeviceToolkit.pipe( + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), + Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide(layerFailed), + Layer.provide(layerProjectsMock), + Layer.provide( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-mcp-device-toolkit-test-" }), + ), + Layer.provide(NodeServices.layer), + ), + ), + ); +}); + describe("device targeting across hosts", () => { const macHost = { id: "mac-mini", diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index b73661899c85f..d4f0a4fac6a67 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -4,6 +4,7 @@ import { type DeviceHostSummary, type DeviceId, type DevicePlatform, + type DeviceServiceState, type DeviceSummary, DeviceToolUnavailableError, LOCAL_DEVICE_HOST_ID, @@ -143,9 +144,19 @@ const mapLocateErrors = ( }), ); +const failedHosts = ( + state: { readonly hostStatuses: DeviceServiceState["hostStatuses"] }, + hosts: ReadonlyArray, +) => + hosts.flatMap((host) => { + const status = state.hostStatuses[host.id]; + return status?.status === "failed" && status.detail ? [{ host, detail: status.detail }] : []; + }); + const pickDevice = Effect.fn("handlers.pickDevice")(function* ( state: { readonly hosts: ReadonlyArray; + readonly hostStatuses: DeviceServiceState["hostStatuses"]; readonly devices: ReadonlyArray; }, input: { @@ -159,8 +170,19 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( const devices = yield* DeviceService.DeviceService; const located = yield* devices.locateDevice({ deviceId, hostId }).pipe(mapLocateErrors); if (Option.isSome(located)) return located.value; + const target = + hostId === undefined + ? undefined + : (state.hosts.find((host) => host.id === hostId) ?? { id: hostId }); + const failures = failedHosts(state, target === undefined ? state.hosts : [target]).map( + ({ host, detail }) => `${hostName(host)} failed to start: ${detail}`, + ); return yield* new DeviceToolUnavailableError({ - 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.`, + reason: [ + `No device ${deviceId} on ${target === undefined ? "any host" : hostName(target)}.`, + ...failures, + "Call device_list for current ids.", + ].join(" "), }); } const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; @@ -170,11 +192,14 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( (input.platform === undefined || device.platform === input.platform), ); if (candidates.length === 0) { + const [failure] = failedHosts(state, [{ id: hostId }]); return yield* new DeviceToolUnavailableError({ reason: - input.platform === undefined - ? "No simulators or emulators were found. Call device_list to see why." - : `No ${input.platform} devices were found on host ${hostId}. Call device_list to see why.`, + failure !== undefined + ? failure.detail + : input.platform === undefined + ? "No simulators or emulators were found. Call device_list to see why." + : `No ${input.platform} devices were found on host ${hostId}. Call device_list to see why.`, }); } const platforms = new Set(candidates.map((device) => device.platform));