From 770564ec0f5d7497d78c459c1bcd7a404d98398b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 18:24:47 +0000 Subject: [PATCH 1/5] test(server): reproduce device setup hiding missing npm (#28227) These tests reproduce pingdotgg/t3code#28227 and fail on main: - DeviceService: when neither npm nor the pnpm fallback can be spawned, the host status detail says only "Device support failed during installing device support." and never names npm or Node.js. - device_open: on a host whose status is "failed", the tool says "No android devices were found" instead of the host's failure. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.test.ts | 41 ++++++++++++++++++ apps/server/src/mcp/McpDeviceToolkit.test.ts | 45 ++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index 9b680280d3298..a4efcd79c9007 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -20,6 +20,7 @@ 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 PlatformError from "effect/PlatformError"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; @@ -37,6 +38,7 @@ import { NodeRuntimeUnavailableError } from "@t3tools/shared/nodeRuntime"; import * as DeviceService from "./DeviceService.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)); @@ -360,6 +362,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("listing and provider startup do not start helpers before consent", () => Effect.gen(function* () { const { service, starts, requests } = yield* fixture(); diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 6341d23b6bfc3..fdec11902971e 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -269,6 +269,51 @@ 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), + open: () => Effect.die("Must not open a device on a host whose setup failed"), + }); + return Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server + .callTool({ name: "device_open", arguments: { platform: "android" } }) + .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(McpServer.McpServer.layer), + 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", From e5870dd41f1c5d4f0b0dd88518f0efe92b53a42c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 18:30:42 +0000 Subject: [PATCH 2/5] fix(server): device setup names the missing npm When neither npm nor the pnpm fallback could be spawned, local device setup reported only "Device support failed during installing device support.", and device_open on that host said no devices were found. The toolchain install error now carries an `npm_not_found` reason whose message says to install Node.js so npm is on the T3 server's PATH, and DeviceService shows that message for the host and agent-tools install. Other install failures keep their generic text. device_open reports a failed host's status detail instead of "No devices were found". Fixes pingdotgg/t3code#28227 Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceService.ts | 33 +++++++++++-------- apps/server/src/device/DeviceToolchain.ts | 22 +++++++++++-- .../src/mcp/toolkits/device/handlers.ts | 11 +++++-- 3 files changed, 47 insertions(+), 19 deletions(-) diff --git a/apps/server/src/device/DeviceService.ts b/apps/server/src/device/DeviceService.ts index d91c9d8d0b53b..d039ab81c73a8 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, @@ -101,6 +105,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; @@ -427,9 +440,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( @@ -440,9 +451,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, }), ), @@ -490,9 +500,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( @@ -507,9 +515,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 250e6cf14f3be..f3c2c384d7b05 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/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 6812754af199b..9f637999aac04 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, @@ -123,6 +124,7 @@ const mapLocateErrors = ( const pickDevice = Effect.fn("handlers.pickDevice")(function* ( state: { readonly hosts: ReadonlyArray; + readonly hostStatuses: DeviceServiceState["hostStatuses"]; readonly devices: ReadonlyArray; }, input: { @@ -147,11 +149,14 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( (input.platform === undefined || device.platform === input.platform), ); if (candidates.length === 0) { + const status = state.hostStatuses[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.`, + status?.status === "failed" && status.detail + ? status.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)); From ea22d3875c5dd3aeb626084eb2170d045aa228da Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 18:41:38 +0000 Subject: [PATCH 3/5] fix(server): device_open by id reports a failed host; npm guidance says to restart Co-Authored-By: Claude Opus 5.5 --- apps/server/src/device/DeviceToolchain.ts | 2 +- apps/server/src/mcp/McpDeviceToolkit.test.ts | 27 ++++++++++--------- .../src/mcp/toolkits/device/handlers.ts | 21 ++++++++++++--- 3 files changed, 33 insertions(+), 17 deletions(-) diff --git a/apps/server/src/device/DeviceToolchain.ts b/apps/server/src/device/DeviceToolchain.ts index f3c2c384d7b05..f6573db4cb038 100644 --- a/apps/server/src/device/DeviceToolchain.ts +++ b/apps/server/src/device/DeviceToolchain.ts @@ -43,7 +43,7 @@ export class DeviceToolchainInstallError extends Schema.TaggedError 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; - const result = yield* server - .callTool({ name: "device_open", arguments: { platform: "android" } }) - .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); + for (const args of [{ platform: "android" }, { deviceId: "Pixel_API_35", hostId: "local" }]) { + 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( diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 9f637999aac04..7daeb6fe4dcc3 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -121,6 +121,15 @@ const mapLocateErrors = ( }), ); +const failedHostDetails = ( + state: { readonly hostStatuses: DeviceServiceState["hostStatuses"] }, + hostIds: ReadonlyArray, +) => + hostIds.flatMap((id) => { + const status = state.hostStatuses[id]; + return status?.status === "failed" && status.detail ? [status.detail] : []; + }); + const pickDevice = Effect.fn("handlers.pickDevice")(function* ( state: { readonly hosts: ReadonlyArray; @@ -138,8 +147,12 @@ 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 failures = failedHostDetails( + state, + hostId === undefined ? Object.keys(state.hostStatuses) : [hostId], + ); 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 ${hostId === undefined ? "any host" : hostName(state.hosts.find((host) => host.id === hostId) ?? { id: hostId })}. ${failures.length > 0 ? failures.join(" ") : "Call device_list for current ids."}`, }); } const hostId = input.hostId ?? LOCAL_DEVICE_HOST_ID; @@ -149,11 +162,11 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( (input.platform === undefined || device.platform === input.platform), ); if (candidates.length === 0) { - const status = state.hostStatuses[hostId]; + const [failure] = failedHostDetails(state, [hostId]); return yield* new DeviceToolUnavailableError({ reason: - status?.status === "failed" && status.detail - ? status.detail + failure !== undefined + ? failure : 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.`, From b1979de5dab01095ffc6abae854b84ae8d2ad22b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 31 Oct 2026 18:48:01 +0000 Subject: [PATCH 4/5] fix(server): device_open names which host failed and keeps the device_list hint Co-Authored-By: Claude Opus 5.5 --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 6 +++- .../src/mcp/toolkits/device/handlers.ts | 29 ++++++++++++------- 2 files changed, 23 insertions(+), 12 deletions(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index 8c6e1fd5af9dc..63958a9cdf55a 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -286,7 +286,11 @@ it.effect("device_open reports a failed host's setup error instead of no devices }); return Effect.gen(function* () { const server = yield* McpServer.McpServer; - for (const args of [{ platform: "android" }, { deviceId: "Pixel_API_35", hostId: "local" }]) { + 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( diff --git a/apps/server/src/mcp/toolkits/device/handlers.ts b/apps/server/src/mcp/toolkits/device/handlers.ts index 7daeb6fe4dcc3..15b4198098d21 100644 --- a/apps/server/src/mcp/toolkits/device/handlers.ts +++ b/apps/server/src/mcp/toolkits/device/handlers.ts @@ -121,13 +121,13 @@ const mapLocateErrors = ( }), ); -const failedHostDetails = ( +const failedHosts = ( state: { readonly hostStatuses: DeviceServiceState["hostStatuses"] }, - hostIds: ReadonlyArray, + hosts: ReadonlyArray, ) => - hostIds.flatMap((id) => { - const status = state.hostStatuses[id]; - return status?.status === "failed" && status.detail ? [status.detail] : []; + 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* ( @@ -147,12 +147,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 failures = failedHostDetails( - state, - hostId === undefined ? Object.keys(state.hostStatuses) : [hostId], + 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 })}. ${failures.length > 0 ? failures.join(" ") : "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; @@ -162,11 +169,11 @@ const pickDevice = Effect.fn("handlers.pickDevice")(function* ( (input.platform === undefined || device.platform === input.platform), ); if (candidates.length === 0) { - const [failure] = failedHostDetails(state, [hostId]); + const [failure] = failedHosts(state, [{ id: hostId }]); return yield* new DeviceToolUnavailableError({ reason: failure !== undefined - ? failure + ? 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.`, From 8e62335d61ec8d5ce8e7ed3077ca4b7833d9b486 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 23:49:38 +0000 Subject: [PATCH 5/5] test(server): build the failed-host device_open test on the in-process MCP server layer AI-assisted; verified with the tests and tools listed above. --- apps/server/src/mcp/McpDeviceToolkit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/server/src/mcp/McpDeviceToolkit.test.ts b/apps/server/src/mcp/McpDeviceToolkit.test.ts index bdf0aed0a9ace..9af31f1f30212 100644 --- a/apps/server/src/mcp/McpDeviceToolkit.test.ts +++ b/apps/server/src/mcp/McpDeviceToolkit.test.ts @@ -324,7 +324,7 @@ it.effect("device_open reports a failed host's setup error instead of no devices Effect.scoped, Effect.provide( McpHttpServer.layerDeviceToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), + Layer.provideMerge(McpHttpServer.layerInProcessMcpServer), Layer.provideMerge(McpToolAccessTestkit.liveThreadsLayer), Layer.provide(layerFailed), Layer.provide(layerProjectsMock),