diff --git a/apps/server/src/device/DeviceHubOperations.ts b/apps/server/src/device/DeviceHubOperations.ts index 9371484f4c93f..131e3b95241ae 100644 --- a/apps/server/src/device/DeviceHubOperations.ts +++ b/apps/server/src/device/DeviceHubOperations.ts @@ -7,7 +7,7 @@ import { import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; -import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; +import { HttpClient, HttpClientError, HttpClientRequest, HttpClientResponse } from "effect/http"; import type { DeviceHostReady, DeviceOperations, HostDevice } from "./DeviceHost.ts"; @@ -239,7 +239,10 @@ export const makeDeviceHubOperations = (options: { (cause) => new DeviceOperationError({ operation: "screenshot", - reason: "request_failed", + reason: + HttpClientError.isHttpClientError(cause) && cause.reason._tag === "StatusCodeError" + ? "hub_rejected" + : "request_failed", cause, }), ), diff --git a/apps/server/src/device/DeviceService.test.ts b/apps/server/src/device/DeviceService.test.ts index a9ff957b91997..b97a7f2a320d4 100644 --- a/apps/server/src/device/DeviceService.test.ts +++ b/apps/server/src/device/DeviceService.test.ts @@ -42,6 +42,37 @@ const decodeJson = Schema.decodeUnknownSync(Schema.fromJsonString(Schema.Unknown const decodeBootError = Schema.decodeUnknownSync(Schema.fromJsonString(DeviceBootError)); const encodeBootError = Schema.encodeSync(Schema.fromJsonString(DeviceBootError)); +it.effect.each([400, 500])( + "distinguishes screenshot HTTP rejection (%s) from transport failure", + (status) => + Effect.gen(function* () { + const operations = makeDeviceHubOperations({ + hostId: LOCAL_DEVICE_HOST_ID, + origin: "http://device.test", + http: HttpClient.make((request) => + Effect.succeed( + HttpClientResponse.fromWeb(request, new Response("private hub output", { status })), + ), + ), + run: () => Effect.die("Screenshot does not run a command"), + androidAvailable: Effect.succeed(false), + }); + const result = yield* operations + .screenshot({ + id: "phone", + name: "Phone", + version: "iOS 26.3", + platform: "ios", + booted: true, + physical: false, + }) + .pipe(Effect.result); + expect(result).toMatchObject({ + failure: { _tag: "DeviceOperationError", reason: "hub_rejected" }, + }); + }), +); + const fixture = Effect.fn("fixture")(function* ( onBoot: Effect.Effect = Effect.void, bootError?: string | { error?: string; errors?: Array<{ message: string; error?: string }> }, diff --git a/apps/server/src/mcp/McpHttpServer.test.ts b/apps/server/src/mcp/McpHttpServer.test.ts index 1f77dbfcc087d..902785778a975 100644 --- a/apps/server/src/mcp/McpHttpServer.test.ts +++ b/apps/server/src/mcp/McpHttpServer.test.ts @@ -5,6 +5,8 @@ import { expect, it } from "@effect/vitest"; import { NodeHttpServer } from "@effect/platform-node"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { + DeviceId, + DeviceOperationError, EnvironmentId, OrchestratorMcpEnvironmentTarget, OrchestratorMcpFailure, @@ -24,6 +26,7 @@ import { McpSchema, McpServer, Tool, Toolkit } from "effect/ai"; import { HttpBody, HttpClient, HttpClientRequest, HttpRouter } from "effect/http"; import * as ProjectService from "../project/ProjectService.ts"; +import * as DeviceService from "../device/DeviceService.ts"; import * as PullRequestReviewGuideService from "../pullRequest/PullRequestReviewGuideService.ts"; import * as ServerConfig from "../config.ts"; import * as ServerSettings from "../serverSettings.ts"; @@ -140,6 +143,86 @@ const callTool = (name: string, args: Record) => const callSnapshot = (args: Record) => callTool("preview_snapshot", args); +it.effect.each([ + { input: {}, open: false, message: "No device is open in this thread. Call device_open first." }, + { + input: { deviceId: "missing", hostId: "local" }, + open: false, + message: "Device missing is not open in this thread. Call device_open first.", + }, + { + input: {}, + open: true, + message: + "Device screenshot failed: Could not communicate with device support. Try refreshing devices.", + }, + { + input: {}, + open: true, + rejected: true, + message: + "Device screenshot failed: The device hub could not complete the request. Call device_list to check the device state, and device_open if it needs to be reopened.", + }, +])( + "preserves actionable screenshot failure text: $message", + ({ input, open, message, ...options }) => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const result = yield* server.callTool({ name: "device_screenshot", arguments: input }).pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, { + ...invocation, + capabilities: new Set(["device"] as const), + }), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(result.isError).toBe(true); + expect(result.content).toEqual([{ type: "text", text: message }]); + expect(result.structuredContent).toMatchObject({ + error: { message, operation: "screenshot" }, + }); + expect(JSON.stringify(result)).not.toContain("private hub output"); + }).pipe( + Effect.provide( + McpHttpServer.layerDeviceToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(McpToolAccessTestkit.liveThreadsLayer), + Layer.provide( + Layer.mock(DeviceService.DeviceService)({ + locateSession: () => Effect.succeedNone, + sessionsForThread: () => + Effect.succeed( + open + ? [ + { + threadId, + hostId: "local", + deviceId: DeviceId.make("phone"), + platform: "ios", + openedAt: "2026-10-23T00:00:00Z", + }, + ] + : [], + ), + screenshot: () => + Effect.fail( + new DeviceOperationError({ + operation: "screenshot", + reason: + "rejected" in options && options.rejected ? "hub_rejected" : "request_failed", + cause: new Error("private hub output"), + }), + ), + }), + ), + Layer.provide(Layer.mock(ProjectService.ProjectService)({})), + Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-device-mcp-test-" })), + Layer.provide(NodeServices.layer), + ), + ), + Effect.scoped, + ), +); + const layerRecordingTools = (calls: Array) => { const RecordingToolkit = Toolkit.make( OrchestratorToolkit.tools.delegate_task, diff --git a/apps/server/src/mcp/McpHttpServer.ts b/apps/server/src/mcp/McpHttpServer.ts index 4365267e2144d..e684316f48d9a 100644 --- a/apps/server/src/mcp/McpHttpServer.ts +++ b/apps/server/src/mcp/McpHttpServer.ts @@ -766,10 +766,14 @@ const registerDeviceScreenshot = Effect.fn("McpHttpServer.registerDeviceScreensh Effect.provideService(ThreadManagementService.ThreadManagementService, threads), ), "screenshot", - (error) => - isDeviceToolError(error) || isOrchestratorMcpFailure(error) - ? error.message - : "Device screenshot failed.", + (error) => { + if (!isDeviceToolError(error)) { + return isOrchestratorMcpFailure(error) ? error.message : "Device screenshot failed."; + } + return error._tag === "DeviceOperationError" && error.reason === "hub_rejected" + ? `${error.message} Call device_list to check the device state, and device_open if it needs to be reopened.` + : error.message; + }, ); });