Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/server/src/device/DeviceHubOperations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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,
}),
),
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/device/DeviceService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> = Effect.void,
bootError?: string | { error?: string; errors?: Array<{ message: string; error?: string }> },
Expand Down
83 changes: 83 additions & 0 deletions apps/server/src/mcp/McpHttpServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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";
Expand Down Expand Up @@ -140,6 +143,86 @@ const callTool = (name: string, args: Record<string, unknown>) =>

const callSnapshot = (args: Record<string, unknown>) => 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<unknown>) => {
const RecordingToolkit = Toolkit.make(
OrchestratorToolkit.tools.delegate_task,
Expand Down
12 changes: 8 additions & 4 deletions apps/server/src/mcp/McpHttpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
},
);
});

Expand Down
Loading