Skip to content
Merged
41 changes: 41 additions & 0 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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));
Expand Down Expand Up @@ -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",
Expand Down
33 changes: 20 additions & 13 deletions apps/server/src/device/DeviceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand All @@ -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,
}),
),
Expand Down Expand Up @@ -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(
Expand All @@ -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,
}),
),
Expand Down
22 changes: 19 additions & 3 deletions apps/server/src/device/DeviceToolchain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,13 @@ export class DeviceToolchainInstallError extends Schema.TaggedError<DeviceToolch
tool: Schema.String,
step: Schema.String,
exitCode: Schema.optional(Schema.Number),
reason: Schema.optional(Schema.Literal("npm_not_found")),
cause: Schema.optional(Schema.Defect()),
},
) {
override get message(): string {
if (this.reason === "npm_not_found")
return "Device support needs npm, which is not on the T3 server's PATH. Install npm (it comes with Node.js from nodejs.org), restart T3 Code, then retry.";
const suffix = this.exitCode === undefined ? "" : ` (exit code ${this.exitCode})`;
return `Installing ${this.tool} failed while ${this.step}${suffix}.`;
}
Expand Down Expand Up @@ -93,6 +96,11 @@ const isInstalled = Effect.fn("DeviceToolchain.isInstalled")(function* (
return entryExists && Option.isSome(sentinel) && sentinel.value.trim() === version;
});

const isCommandNotFound = (error: ProcessRunner.ProcessRunError) =>
error._tag === "ProcessSpawnError" &&
error.cause instanceof PlatformError.PlatformError &&
error.cause.reason._tag === "NotFound";

const installTool = Effect.fn("DeviceToolchain.installTool")(function* (
spec: ToolSpec,
paths: DeviceToolPaths,
Expand Down Expand Up @@ -130,16 +138,24 @@ 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],
timeout: INSTALL_TIMEOUT,
})
: 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({
Expand Down
52 changes: 52 additions & 0 deletions apps/server/src/mcp/McpDeviceToolkit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 29 additions & 4 deletions apps/server/src/mcp/toolkits/device/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type DeviceHostSummary,
type DeviceId,
type DevicePlatform,
type DeviceServiceState,
type DeviceSummary,
DeviceToolUnavailableError,
LOCAL_DEVICE_HOST_ID,
Expand Down Expand Up @@ -143,9 +144,19 @@ const mapLocateErrors = <A, R>(
}),
);

const failedHosts = <H extends { readonly id: DeviceHostId }>(
state: { readonly hostStatuses: DeviceServiceState["hostStatuses"] },
hosts: ReadonlyArray<H>,
) =>
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<DeviceHostSummary>;
readonly hostStatuses: DeviceServiceState["hostStatuses"];
readonly devices: ReadonlyArray<DeviceSummary>;
},
input: {
Expand All @@ -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;
Expand All @@ -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));
Expand Down
Loading